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 138027a8ef
commit 3dca732da9
8 changed files with 280 additions and 4 deletions

View file

@ -1,3 +1,4 @@
import 'dart:async';
import 'dart:convert';
import 'package:commons_core/commons_core.dart';
@ -25,6 +26,13 @@ class MessageStore {
MessageStore(this._store);
final SecretStore _store;
/// Serializes [append]'s read-modify-write. Several sources now write the same
/// conversation concurrently (the app-wide inbox listener and an open chat's
/// own subscription), so without this two near-simultaneous messages could
/// read the same history and the second write would clobber the first.
Future<void> _writeTail = Future.value();
static const _prefix = 'tane.social.chat.';
/// Index of peers we have a conversation with (the keystore is key/value with
@ -53,9 +61,25 @@ class MessageStore {
}
/// Appends [message] to the conversation with [peerPubkey] (trimming to the
/// cap) and records the peer in the conversation index.
Future<void> append(String peerPubkey, PrivateMessage message) async {
final next = [...await history(peerPubkey), message];
/// cap) and records the peer in the conversation index. Idempotent: a relay
/// re-delivers stored gift wraps on every (re)subscribe, so an identical
/// message (same sender, timestamp and text) is dropped instead of duplicated.
/// Returns true only when the message was newly stored.
Future<bool> append(String peerPubkey, PrivateMessage message) {
// Chain onto the write tail so concurrent appends run one at a time.
final result = _writeTail.then((_) => _appendLocked(peerPubkey, message));
_writeTail = result.then((_) {}, onError: (_) {});
return result;
}
Future<bool> _appendLocked(String peerPubkey, PrivateMessage message) async {
final existing = await history(peerPubkey);
final isDup = existing.any((m) =>
m.fromPubkey == message.fromPubkey &&
m.text == message.text &&
m.at.millisecondsSinceEpoch == message.at.millisecondsSinceEpoch);
if (isDup) return false;
final next = [...existing, message];
final capped =
next.length > _cap ? next.sublist(next.length - _cap) : next;
await _store.write(
@ -70,6 +94,7 @@ class MessageStore {
]),
);
await _rememberPeer(peerPubkey);
return true;
}
/// Conversations, most-recently-active first (for the messages inbox).