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.
162 lines
5.5 KiB
Dart
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);
|
|
}
|
|
}
|