tane/apps/app_seeds/lib/state/messages_cubit.dart
vjrj 21ec836bd8 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.
2026-07-10 17:02:40 +02:00

162 lines
5.5 KiB
Dart

import 'dart:async';
import 'package:commons_core/commons_core.dart';
import 'package:equatable/equatable.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import '../services/message_store.dart';
import '../services/social_service.dart';
import '../services/social_settings.dart';
/// A 1:1 conversation with one peer. Holds the running message list plus send
/// status. Transport-agnostic — depends on [MessageTransport], not the relay.
class ChatState extends Equatable {
const ChatState({
this.messages = const [],
this.sending = false,
this.error,
});
/// Messages in this conversation, oldest first. Incoming ones arrive from the
/// inbox; our own are appended optimistically on send (NIP-17 wraps don't come
/// back to the sender).
final List<PrivateMessage> messages;
final bool sending;
final String? error;
ChatState copyWith({
List<PrivateMessage>? messages,
bool? sending,
String? Function()? error,
}) =>
ChatState(
messages: messages ?? this.messages,
sending: sending ?? this.sending,
error: error != null ? error() : this.error,
);
@override
List<Object?> get props => [messages, sending, error];
}
/// Drives a 1:1 chat over a [MessageTransport]. Filters the shared inbox to
/// [peerPubkey], and tags our own sent messages with [selfPubkey] so the UI can
/// tell the two sides apart. Degrades gracefully when offline (null transport).
class MessagesCubit extends Cubit<ChatState> {
MessagesCubit(
this._transport, {
required this.peerPubkey,
required this.selfPubkey,
MessageStore? store,
Future<void> Function()? onDispose,
}) : _store = store,
_onDispose = onDispose,
super(const ChatState());
final MessageTransport? _transport;
final MessageStore? _store;
final String peerPubkey;
final String selfPubkey;
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; 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')),
);
}
final history = await _store?.history(peerPubkey);
if (history != null && history.isNotEmpty) {
// 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]));
}
}
}
/// Sends [text] to the peer and appends it optimistically.
Future<void> send(String text) async {
final transport = _transport;
final trimmed = text.trim();
if (transport == null || trimmed.isEmpty) return;
emit(state.copyWith(sending: true, error: () => null));
try {
await transport.send(toPubkey: peerPubkey, text: trimmed);
final mine = PrivateMessage(
fromPubkey: selfPubkey,
text: trimmed,
at: DateTime.now(),
);
_seen.add(_key(mine));
await _store?.append(peerPubkey, mine);
emit(state.copyWith(
sending: false,
messages: [...state.messages, mine],
));
} catch (e) {
emit(state.copyWith(sending: false, error: () => '$e'));
}
}
/// Whether [message] was sent by us (for right-aligned bubbles).
bool isMine(PrivateMessage message) => message.fromPubkey == selfPubkey;
@override
Future<void> close() async {
await _sub?.cancel();
await _onDispose?.call();
return super.close();
}
}
/// Opens a [MessagesCubit] wired to the social layer for a chat with
/// [peerPubkey], or an offline one that degrades gracefully.
Future<MessagesCubit> createMessagesCubit(
SocialService social,
SocialSettings settings, {
required String peerPubkey,
}) async {
final relays = await settings.relayUrls();
if (relays.isEmpty) {
return MessagesCubit(null,
peerPubkey: peerPubkey, selfPubkey: social.publicKeyHex);
}
try {
final session = await social.openSession(relays);
return MessagesCubit(
session.messages,
peerPubkey: peerPubkey,
selfPubkey: social.publicKeyHex,
onDispose: session.close,
);
} catch (_) {
return MessagesCubit(null,
peerPubkey: peerPubkey, selfPubkey: social.publicKeyHex);
}
}