tane/apps/app_seeds/lib/state/messages_cubit.dart
vjrj bb4ee2fd89 refactor(social): one shared relay connection per identity
Every feature (offers, messaging, trust, profile, the inbox listener) used
to open its OWN RelayPool via social.openSession — several sockets to the
same relays, more battery, and reconnection logic living only in the inbox
listener.

Add SocialConnection: ONE shared session per identity, lazily connected and
reused by all. It watches connectivity — dropping the session when the
network goes and reconnecting when it returns, announcing each change on a
sessions stream so the inbox listener re-subscribes automatically. Callers
get the session via connection.session() and never close it (the connection
owns its lifecycle); recreated/disposed on an identity switch.

- InboxService now consumes the shared connection (subscribes on its
  sessions stream) instead of owning its own socket + connectivity code.
- createOffersCubit + the chat/market/profile screens use the shared
  connection; dead createMessagesCubit/createTrustCubit factories removed.
- DI registers SocialConnection per identity; Bootstrap starts it (after
  the inbox subscribes); switchSocialAccount disposes + recreates it.

Tests: SocialConnection lifecycle (reuse, concurrent connect, offline drop +
reconnect, unreachable retry) with a fake opener + online stream; inbox test
updated. nostr added as a dev_dependency for the channel fake.
2026-07-10 21:50:34 +02:00

134 lines
4.6 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';
/// 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();
}
}