import 'dart:async'; import 'package:commons_core/commons_core.dart'; import 'package:equatable/equatable.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import '../domain/message_rules.dart'; import '../services/message_store.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 messages; final bool sending; final String? error; ChatState copyWith({ List? messages, bool? sending, String? Function()? error, }) => ChatState( messages: messages ?? this.messages, sending: sending ?? this.sending, error: error != null ? error() : this.error, ); @override List 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 { MessagesCubit( this._transport, { required this.peerPubkey, required this.selfPubkey, MessageStore? store, Future Function()? onDispose, }) : _store = store, _onDispose = onDispose, super(const ChatState()); final MessageTransport? _transport; final MessageStore? _store; final String peerPubkey; final String selfPubkey; 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; 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'))); } 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 send(String text) async { final transport = _transport; final trimmed = text.trim(); // Links aren't allowed in messages (the UI warns; this is the backstop so // nothing sends a URL programmatically). See message_rules.dart. if (transport == null || trimmed.isEmpty || containsUrl(trimmed)) 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 close() async { await _sub?.cancel(); await _onDispose?.call(); return super.close(); } }