fix(chat): dedupe messages on display, not just in the store
Duplicated bubbles came from the cubit emitting every inbox event to the screen regardless of whether it was already shown. On opening a chat the saved message loads from history AND the live subscription hands back the same stored gift wrap the relay re-delivers — two bubbles. (The store was already idempotent, but the cubit ignored that for display.) MessagesCubit now keeps a seen-set (sender+timestamp+text): a re-delivered or already-loaded message is skipped, and pre-existing duplicates in old saved history collapse on load too. Arrival order preserved (no resort). Tests: re-delivered wrap shown once; history-raced-by-live shown once.
This commit is contained in:
parent
73ee98206f
commit
33d8b2a4d7
2 changed files with 52 additions and 3 deletions
|
|
@ -60,27 +60,43 @@ class MessagesCubit extends Cubit<ChatState> {
|
|||
final Future<void> Function()? _onDispose;
|
||||
StreamSubscription<PrivateMessage>? _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 = <String>{};
|
||||
|
||||
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<void> 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<ChatState> {
|
|||
text: trimmed,
|
||||
at: DateTime.now(),
|
||||
);
|
||||
_seen.add(_key(mine));
|
||||
await _store?.append(peerPubkey, mine);
|
||||
emit(state.copyWith(
|
||||
sending: false,
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue