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 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; bool get isOnline => _transport != null; /// 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. Future start() async { final transport = _transport; if (transport != null) { _sub = transport.inbox().listen( (message) async { if (message.fromPubkey != peerPubkey) return; // another conversation 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])); } } /// Sends [text] to the peer and appends it optimistically. Future 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(), ); 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(); } } /// Opens a [MessagesCubit] wired to the social layer for a chat with /// [peerPubkey], or an offline one that degrades gracefully. Future 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); } }