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

@ -4,6 +4,7 @@ import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';
import '../i18n/strings.g.dart';
import '../services/inbox_service.dart';
import '../services/message_store.dart';
import '../services/profile_cache.dart';
import '../services/social_service.dart';
@ -18,6 +19,7 @@ class ChatListScreen extends StatefulWidget {
this.social,
this.settings,
this.profileCache,
this.inbox,
super.key,
});
@ -26,6 +28,9 @@ class ChatListScreen extends StatefulWidget {
final SocialSettings? settings;
final ProfileCache? profileCache;
/// App-wide inbox listener; the list reloads whenever it reports a change.
final InboxService? inbox;
@override
State<ChatListScreen> createState() => _ChatListScreenState();
}
@ -33,11 +38,20 @@ class ChatListScreen extends StatefulWidget {
class _ChatListScreenState extends State<ChatListScreen> {
List<ChatSummary>? _items;
final Map<String, String> _names = {};
StreamSubscription<void>? _changesSub;
@override
void initState() {
super.initState();
_load();
// Live-refresh when the app-wide inbox listener persists a new message.
_changesSub = widget.inbox?.changes.listen((_) => _load());
}
@override
void dispose() {
_changesSub?.cancel();
super.dispose();
}
Future<void> _load() async {