diff --git a/apps/app_seeds/lib/state/messages_cubit.dart b/apps/app_seeds/lib/state/messages_cubit.dart index e080e94..f270516 100644 --- a/apps/app_seeds/lib/state/messages_cubit.dart +++ b/apps/app_seeds/lib/state/messages_cubit.dart @@ -60,27 +60,43 @@ class MessagesCubit extends Cubit { final Future Function()? _onDispose; StreamSubscription? _sub; + /// Messages already shown, keyed by sender+timestamp+text. Relays re-deliver + /// stored gift wraps on every (re)subscribe, and the live subscription can + /// hand back the very wrap we just loaded from history — so dedupe on display, + /// not just in the store. (Pre-existing duplicates in old saved history are + /// collapsed here too.) + final _seen = {}; + bool get isOnline => _transport != null; + String _key(PrivateMessage m) => + '${m.fromPubkey}|${m.at.millisecondsSinceEpoch}|${m.text}'; + /// Subscribes to incoming messages, then loads any saved history. Subscribing /// first (before the async history load) avoids dropping an event that arrives - /// during the load. + /// during the load; the seen-set keeps either order duplicate-free while + /// preserving arrival order (history first, live appended). Future start() async { final transport = _transport; if (transport != null) { _sub = transport.inbox().listen( (message) async { if (message.fromPubkey != peerPubkey) return; // another conversation + if (!_seen.add(_key(message))) return; // already shown / re-delivered await _store?.append(peerPubkey, message); emit(state.copyWith(messages: [...state.messages, message])); }, onError: (Object e) => emit(state.copyWith(error: () => '$e')), ); } - // History is older than anything that arrives now, so prepend it. final history = await _store?.history(peerPubkey); if (history != null && history.isNotEmpty) { - emit(state.copyWith(messages: [...history, ...state.messages])); + // Skip any that the live subscription already surfaced during the load, + // and collapse duplicates already sitting in old saved history. + final fresh = history.where((m) => _seen.add(_key(m))).toList(); + if (fresh.isNotEmpty) { + emit(state.copyWith(messages: [...fresh, ...state.messages])); + } } } @@ -97,6 +113,7 @@ class MessagesCubit extends Cubit { text: trimmed, at: DateTime.now(), ); + _seen.add(_key(mine)); await _store?.append(peerPubkey, mine); emit(state.copyWith( sending: false, diff --git a/apps/app_seeds/test/state/messages_cubit_test.dart b/apps/app_seeds/test/state/messages_cubit_test.dart index 96ee0ce..e20f5da 100644 --- a/apps/app_seeds/test/state/messages_cubit_test.dart +++ b/apps/app_seeds/test/state/messages_cubit_test.dart @@ -110,6 +110,38 @@ void main() { await reopened.close(); }); + test('a re-delivered wrap is shown once (relay resends stored events)', + () async { + final transport = FakeMessageTransport(); + final cubit = + MessagesCubit(transport, peerPubkey: peer, selfPubkey: me)..start(); + + final same = msg(peer, 'hola'); // identical sender+timestamp+text + transport.receive(same); + transport.receive(same); // relay re-delivery on the same subscription + await pumpEventQueue(); + + expect(cubit.state.messages, hasLength(1)); + await cubit.close(); + }); + + test('history already surfaced live is not shown twice', () async { + // The stored message is ALSO handed back by the live subscription on open. + final store = MessageStore(InMemorySecretStore()); + final m = msg(peer, 'hola'); + await store.append(peer, m); + + final transport = FakeMessageTransport(); + final cubit = MessagesCubit(transport, + peerPubkey: peer, selfPubkey: me, store: store); + unawaited(cubit.start()); + transport.receive(m); // live redelivery races the history load + await pumpEventQueue(); + + expect(cubit.state.messages.map((m) => m.text), ['hola']); + await cubit.close(); + }); + test('offline (no transport) never throws', () async { final cubit = MessagesCubit(null, peerPubkey: peer, selfPubkey: me)..start(); expect(cubit.isOnline, isFalse);