feat(chat): app-wide inbox listener so messages arrive without opening the chat

Reception only ran inside an open ChatScreen for a known peer, so a first
message from a new peer was invisible: nothing in local storage -> nothing
in the inbox list -> you never opened the chat -> you never subscribed.

Add InboxService: one long-lived NIP-17 inbox subscription for the whole
app (foreground), persisting every incoming message to MessageStore and
firing a 'changes' signal the inbox list live-reloads on. Reconnects when
the network returns; degrades to nothing offline. Started from main when a
social identity exists.

Make MessageStore.append idempotent (dedup by sender+timestamp+text) and
serialized behind a write lock — the global listener and an open chat's own
subscription now write the same conversation concurrently and relays
re-deliver stored gift wraps on every resubscribe. Tests for both.

Known trade-offs (follow-ups): foreground-only (no push yet); each of
InboxService/ChatScreen/MarketScreen opens its own RelayPool (a shared
connection is a later optimization).
This commit is contained in:
vjrj 2026-07-10 16:53:03 +02:00
parent e852b569ce
commit 73ee98206f
8 changed files with 280 additions and 4 deletions

View file

@ -21,6 +21,7 @@ import '../services/auto_backup_store.dart';
import '../services/export_import_service.dart';
import '../services/file_picker_file_service.dart';
import '../services/file_service.dart';
import '../services/inbox_service.dart';
import '../services/locale_store.dart';
import '../services/ocr/label_text_extractor.dart';
import '../services/ocr/ocr_language.dart';
@ -167,7 +168,18 @@ Future<void> configureDependencies() async {
// Optional: absent only if the derivation above failed then the app runs
// inventory-only, by design (market/chat/profile hidden).
if (socialService != null) {
getIt.registerSingleton<SocialService>(socialService);
getIt
..registerSingleton<SocialService>(socialService)
// App-wide inbox listener so private messages arrive (and land in the
// inbox list) even when the specific chat isn't open. Started in `main`.
..registerSingleton<InboxService>(
InboxService(
social: socialService,
settings: getIt<SocialSettings>(),
store: getIt<MessageStore>(),
profileCache: getIt<ProfileCache>(),
),
);
}
// Registered LAST: the "fully wired" sentinel the guard above checks. Anything