From bb4ee2fd89c4b39c59ae04f12dc5806a993543a1 Mon Sep 17 00:00:00 2001 From: vjrj Date: Fri, 10 Jul 2026 21:41:17 +0200 Subject: [PATCH] refactor(social): one shared relay connection per identity MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- apps/app_seeds/lib/app.dart | 28 ++-- apps/app_seeds/lib/bootstrap.dart | 12 +- apps/app_seeds/lib/di/injector.dart | 43 +++++-- .../app_seeds/lib/services/inbox_service.dart | 120 +++++++----------- .../lib/services/social_connection.dart | 107 ++++++++++++++++ apps/app_seeds/lib/state/messages_cubit.dart | 28 ---- apps/app_seeds/lib/state/offers_cubit.dart | 32 ++--- apps/app_seeds/lib/state/trust_cubit.dart | 29 ----- apps/app_seeds/lib/ui/chat_list_screen.dart | 26 ++-- apps/app_seeds/lib/ui/chat_screen.dart | 31 ++--- .../lib/ui/market_offer_detail_screen.dart | 34 ++--- apps/app_seeds/lib/ui/market_screen.dart | 12 +- apps/app_seeds/lib/ui/profile_screen.dart | 32 +++-- apps/app_seeds/pubspec.yaml | 2 + .../test/services/inbox_service_test.dart | 27 ++-- .../test/services/social_connection_test.dart | 119 +++++++++++++++++ apps/app_seeds/test/ui/chat_screen_test.dart | 4 +- .../ui/market_offer_detail_screen_test.dart | 17 +-- .../app_seeds/test/ui/market_screen_test.dart | 8 +- 19 files changed, 431 insertions(+), 280 deletions(-) create mode 100644 apps/app_seeds/lib/services/social_connection.dart create mode 100644 apps/app_seeds/test/services/social_connection_test.dart diff --git a/apps/app_seeds/lib/app.dart b/apps/app_seeds/lib/app.dart index 7519867..33b4afd 100644 --- a/apps/app_seeds/lib/app.dart +++ b/apps/app_seeds/lib/app.dart @@ -18,6 +18,7 @@ import 'services/onboarding_store.dart'; import 'services/profile_cache.dart'; import 'services/profile_store.dart'; import 'services/social_account_store.dart'; +import 'services/social_connection.dart'; import 'services/social_service.dart'; import 'services/social_settings.dart'; import 'services/trust_referents.dart'; @@ -50,6 +51,7 @@ class TaneApp extends StatelessWidget { required this.onboarding, this.social, this.socialSettings, + this.connection, this.location, this.outbox, this.messageStore, @@ -69,6 +71,7 @@ class TaneApp extends StatelessWidget { showIntro, social, socialSettings, + connection, location, outbox, messageStore, @@ -94,6 +97,9 @@ class TaneApp extends StatelessWidget { final SocialService? social; final SocialSettings? socialSettings; + /// The shared relay connection (one per identity) reused by every feature. + final SocialConnection? connection; + /// Optional device-location source for the market's "use my location". final CoarseLocationProvider? location; @@ -135,6 +141,7 @@ class TaneApp extends StatelessWidget { bool showIntro, SocialService? social, SocialSettings? socialSettings, + SocialConnection? connection, CoarseLocationProvider? location, OfferOutbox? outbox, MessageStore? messageStore, @@ -153,50 +160,49 @@ class TaneApp extends StatelessWidget { builder: (context, state) => HomeScreen(marketEnabled: social != null), ), - if (social != null && socialSettings != null) + if (social != null && socialSettings != null && connection != null) GoRoute( path: '/market', builder: (context, state) => MarketScreen( social: social, settings: socialSettings, + connection: connection, location: location, outbox: outbox, ), ), - if (social != null && socialSettings != null) + if (social != null && connection != null) GoRoute( path: '/market/offer', builder: (context, state) { final offer = state.extra as Offer; return MarketOfferDetailScreen( offer: offer, - social: social, - settings: socialSettings, + connection: connection, mine: offer.authorPubkeyHex == social.publicKeyHex, profileCache: profileCache, ); }, ), - if (social != null && socialSettings != null && messageStore != null) + if (messageStore != null) GoRoute( path: '/messages', builder: (context, state) => ChatListScreen( store: messageStore, - social: social, - settings: socialSettings, + connection: connection, profileCache: profileCache, inbox: inbox, ), ), if (social != null && - socialSettings != null && + connection != null && profileStore != null && socialAccounts != null) GoRoute( path: '/profile', builder: (context, state) => ProfileScreen( social: social, - settings: socialSettings, + connection: connection, profileStore: profileStore, accounts: socialAccounts, trustNetworkEnabled: @@ -211,12 +217,12 @@ class TaneApp extends StatelessWidget { wotSettings: wotSettings, ), ), - if (social != null && socialSettings != null) + if (social != null && connection != null) GoRoute( path: '/chat/:pubkey', builder: (context, state) => ChatScreen( social: social, - settings: socialSettings, + connection: connection, peerPubkey: state.pathParameters['pubkey']!, messageStore: messageStore, profileCache: profileCache, diff --git a/apps/app_seeds/lib/bootstrap.dart b/apps/app_seeds/lib/bootstrap.dart index 920df90..f6a68c1 100644 --- a/apps/app_seeds/lib/bootstrap.dart +++ b/apps/app_seeds/lib/bootstrap.dart @@ -19,6 +19,7 @@ import 'services/onboarding_store.dart'; import 'services/profile_cache.dart'; import 'services/profile_store.dart'; import 'services/social_account_store.dart'; +import 'services/social_connection.dart'; import 'services/social_service.dart'; import 'services/social_settings.dart'; import 'services/trust_referents.dart'; @@ -53,6 +54,9 @@ class _BootstrapState extends State { if (savedLocale != null) LocaleSettings.setLocaleSync(savedLocale); final onboarding = getIt(); + final connection = getIt.isRegistered() + ? getIt() + : null; final inbox = getIt.isRegistered() ? getIt() : null; final notifications = getIt.isRegistered() @@ -61,9 +65,10 @@ class _BootstrapState extends State { // Ask for notification permission and set up the OS channel (no-op on // unsupported platforms). Done before the inbox starts listening. if (notifications != null) await notifications.initialize(); - // Listen for incoming private messages app-wide (foreground) so they arrive - // and fill the inbox even before their chat is opened. - if (inbox != null) unawaited(inbox.start()); + // Subscribe the inbox listener BEFORE the shared connection starts + // connecting, so the first session is caught; then bring the connection up. + inbox?.start(); + connection?.start(); return TaneApp( repository: getIt(), @@ -72,6 +77,7 @@ class _BootstrapState extends State { social: getIt.isRegistered() ? getIt() : null, socialSettings: getIt(), + connection: connection, location: const GeolocatorCoarseLocation(), outbox: getIt(), messageStore: getIt(), diff --git a/apps/app_seeds/lib/di/injector.dart b/apps/app_seeds/lib/di/injector.dart index 01386e2..bd3108f 100644 --- a/apps/app_seeds/lib/di/injector.dart +++ b/apps/app_seeds/lib/di/injector.dart @@ -37,6 +37,7 @@ import '../services/offer_outbox.dart'; import '../services/profile_cache.dart'; import '../services/profile_store.dart'; import '../services/social_account_store.dart'; +import '../services/social_connection.dart'; import '../services/social_service.dart'; import '../services/social_settings.dart'; import '../services/trust_referents.dart'; @@ -199,19 +200,25 @@ Future configureDependencies() async { // Optional: absent only if the derivation above failed — then the app runs // inventory-only, by design (market/chat/profile hidden). if (socialService != null) { + // ONE shared relay connection per identity, reused by every feature. + final connection = SocialConnection( + social: socialService, + settings: getIt(), + ); getIt ..registerSingleton(socialService) + ..registerSingleton(connection) // Tracks unread private messages so the UI can badge them. ..registerSingleton( UnreadService(getIt(), secretStore), ) - // App-wide inbox listener so private messages arrive (and land in the - // inbox list) even when the specific chat isn't open. Started in `main`. - // It also updates unread counts and fires notifications. + // App-wide inbox listener over the shared connection, so private messages + // arrive (and land in the inbox list) even when the chat isn't open. + // Started in `Bootstrap`; it also updates unread and fires notifications. ..registerSingleton( InboxService( - social: socialService, - settings: getIt(), + connection: connection, + selfPubkey: socialService.publicKeyHex, store: getIt(), profileCache: getIt(), unread: getIt(), @@ -238,11 +245,18 @@ Future switchSocialAccount(int account) async { final scope = socialAccountScope(account); final rootSeedHex = await getIt().rootSeedHex(); - // Tear down the old identity's live listener/sessions before replacing. + // Tear down the old identity's listener + shared connection before replacing. if (getIt.isRegistered()) { await getIt().stop(); await getIt.unregister(); } + if (getIt.isRegistered()) { + await getIt().dispose(); + await getIt.unregister(); + } + if (getIt.isRegistered()) { + await getIt.unregister(); + } if (getIt.isRegistered()) { await getIt.unregister(); } @@ -257,20 +271,29 @@ Future switchSocialAccount(int account) async { ..registerSingleton( ProfileStore(secretStore, accountScope: scope)) ..registerSingleton( - ProfileCache(secretStore, accountScope: scope)); + ProfileCache(secretStore, accountScope: scope)) + ..registerSingleton( + UnreadService(getIt(), secretStore)); final social = await SocialService.fromRootSeedHex(rootSeedHex, account: account); + final connection = + SocialConnection(social: social, settings: getIt()); final inbox = InboxService( - social: social, - settings: getIt(), + connection: connection, + selfPubkey: social.publicKeyHex, store: getIt(), profileCache: getIt(), + unread: getIt(), + notifications: getIt(), ); getIt ..registerSingleton(social) + ..registerSingleton(connection) ..registerSingleton(inbox); - unawaited(inbox.start()); + // Subscribe the listener BEFORE the connection starts connecting. + inbox.start(); + connection.start(); } Future _backupsDir() async { diff --git a/apps/app_seeds/lib/services/inbox_service.dart b/apps/app_seeds/lib/services/inbox_service.dart index 8282c2e..274d579 100644 --- a/apps/app_seeds/lib/services/inbox_service.dart +++ b/apps/app_seeds/lib/services/inbox_service.dart @@ -1,110 +1,89 @@ import 'dart:async'; import 'package:commons_core/commons_core.dart'; -import 'package:connectivity_plus/connectivity_plus.dart'; import 'package:flutter/foundation.dart'; import '../i18n/strings.g.dart'; import 'message_store.dart'; import 'notification_service.dart'; import 'profile_cache.dart'; -import 'social_service.dart'; -import 'social_settings.dart'; +import 'social_connection.dart'; +import 'social_service.dart' show SocialSession; import 'unread_service.dart'; /// App-wide inbox listener for private messages (NIP-17). /// -/// Without this, a message only arrived while its specific chat screen was -/// open — so a first message from a new peer was invisible (nothing in local -/// storage, so nothing in the inbox list, so you never opened the chat, so you -/// never subscribed). This keeps ONE long-lived subscription for the whole app -/// while online: it persists every incoming message to the [MessageStore] and -/// fires [changes] so the inbox list refreshes. Reconnects when the network -/// returns; degrades to nothing offline (local-first). +/// Without this, a message only arrived while its specific chat screen was open +/// — so a first message from a new peer was invisible. This keeps one inbox +/// subscription over the SHARED [SocialConnection] for the whole app while +/// online: it persists every incoming message to the [MessageStore], updates +/// unread counts, fires an OS notification, and fires [changes] so the inbox +/// list refreshes. It re-subscribes automatically each time the connection +/// reconnects (and stops when it drops), because it listens to the connection's +/// session stream rather than owning a socket itself. /// /// Foreground only — background/push delivery is a later concern. class InboxService { InboxService({ - required SocialService social, - required SocialSettings settings, + required SocialConnection connection, + required String selfPubkey, required MessageStore store, ProfileCache? profileCache, UnreadService? unread, NotificationService? notifications, - }) : _social = social, - _settings = settings, + }) : _connection = connection, + _selfPubkey = selfPubkey, _store = store, _profileCache = profileCache, _unread = unread, _notifications = notifications; - final SocialService _social; - final SocialSettings _settings; + final SocialConnection _connection; + final String _selfPubkey; final MessageStore _store; final ProfileCache? _profileCache; final UnreadService? _unread; final NotificationService? _notifications; final _changes = StreamController.broadcast(); - SocialSession? _session; + StreamSubscription? _sessionsSub; StreamSubscription? _inboxSub; - StreamSubscription>? _connSub; + SocialSession? _session; bool _started = false; - bool _connecting = false; - /// Fires (with no payload) after a NEW message is persisted — the inbox list - /// listens to this to reload. Broadcast, so several screens can listen. + /// Fires (no payload) after a NEW message is persisted — the inbox list + /// listens to reload. Broadcast, so several screens can listen. Stream get changes => _changes.stream; - /// Begins listening. Idempotent. Connects now if online, and (re)connects - /// whenever the network returns. Safe to call once at app start. - Future start() async { + /// Begins listening. Subscribe to the connection's sessions BEFORE the + /// connection starts connecting, so the first session is caught too. + void start() { if (_started) return; _started = true; - try { - _connSub = Connectivity().onConnectivityChanged.listen((results) { - final offline = - results.isEmpty || results.every((r) => r == ConnectivityResult.none); - if (offline) { - _dropSession(); - } else { - unawaited(_connect()); - } - }); - } catch (_) { - // Platform without connectivity support — just try once below. - } - await _connect(); + _sessionsSub = _connection.sessions.listen(_onSession); + // In case the connection is already up (a screen connected first). + final current = _connection.current; + if (current != null) _onSession(current); } - /// Opens a session and subscribes to the inbox, if not already connected. - /// Guarded so overlapping connectivity events can't open two sessions. - Future _connect() async { - if (_session != null || _connecting) return; // already (being) connected - _connecting = true; - try { - final relays = await _settings.relayUrls(); - if (relays.isEmpty) return; // offline / unconfigured - final session = await _social.openSession(relays); - if (!_started) { - await session.close(); // stopped while connecting - return; - } - _session = session; + /// (Re)binds the inbox subscription to [session] — the connection handing us a + /// fresh session (reconnect) or null (dropped). + void _onSession(SocialSession? session) { + if (identical(session, _session) && _inboxSub != null) return; + unawaited(_inboxSub?.cancel()); + _inboxSub = null; + _session = session; + if (session != null) { _inboxSub = session.messages.inbox().listen( - ingest, - onError: (_) => _dropSession(), // relay dropped — retry on next change - ); - } catch (_) { - // No relay reachable — a later connectivity change retries. - } finally { - _connecting = false; + ingest, + onError: (_) {}, // drop handled by the connection's reconnect + ); } } - /// Persists one incoming [message] (deduped) and, when it's new, fires - /// [changes] and best-effort caches the sender's display name. Separated from - /// the transport so it can be unit-tested without a relay. + /// Persists one incoming [message] (deduped) and, when new, fires [changes], + /// updates unread, notifies, and best-effort caches the sender's name. A + /// testable seam (no relay). @visibleForTesting Future ingest(PrivateMessage message) async { final stored = await _store.append(message.fromPubkey, message); @@ -113,9 +92,8 @@ class InboxService { final peer = message.fromPubkey; // Defensive: never badge or notify for your own message. NIP-17 doesn't - // loop a sent gift wrap back to its author, so this shouldn't happen — the - // guard just keeps the seam honest. - if (peer != _social.publicKeyHex) { + // loop a sent gift wrap back to its author; the guard keeps the seam honest. + if (peer != _selfPubkey) { await _unread?.onMessageReceived(peer, message); await _maybeNotify(peer); } @@ -151,20 +129,12 @@ class InboxService { } } - void _dropSession() { - unawaited(_inboxSub?.cancel()); - _inboxSub = null; - unawaited(_session?.close()); - _session = null; - } - Future stop() async { _started = false; - await _connSub?.cancel(); - _connSub = null; + await _sessionsSub?.cancel(); + _sessionsSub = null; await _inboxSub?.cancel(); _inboxSub = null; - await _session?.close(); _session = null; if (!_changes.isClosed) await _changes.close(); } diff --git a/apps/app_seeds/lib/services/social_connection.dart b/apps/app_seeds/lib/services/social_connection.dart new file mode 100644 index 0000000..8e57949 --- /dev/null +++ b/apps/app_seeds/lib/services/social_connection.dart @@ -0,0 +1,107 @@ +import 'dart:async'; + +import 'package:connectivity_plus/connectivity_plus.dart'; + +import 'social_service.dart'; +import 'social_settings.dart'; + +/// Opens a session against the given relays. Injectable so the connection logic +/// can be unit-tested without a real relay. +typedef SessionOpener = Future Function(List relays); + +/// ONE shared relay connection per identity, reused by every social feature +/// (offers, messaging, trust, profile, the inbox listener) instead of each +/// opening its own `RelayPool`. Fewer sockets, less battery, one place to manage +/// reconnection. +/// +/// Lazily connects on first [session] call; reconnects when the network returns +/// and drops the session when it goes away, announcing each change on [sessions] +/// so long-lived consumers (the inbox listener) can re-subscribe. Callers must +/// NOT close the session they get — this owns its lifecycle. Recreated on an +/// identity switch (the old one is disposed). +class SocialConnection { + SocialConnection({ + required SocialService social, + required SocialSettings settings, + SessionOpener? open, + Stream? online, + }) : _settings = settings, + _open = open ?? social.openSession, + _online = online; + + final SocialSettings _settings; + final SessionOpener _open; + final Stream? _online; + + final _sessions = StreamController.broadcast(); + SocialSession? _current; + Future? _pending; + StreamSubscription? _onlineSub; + bool _disposed = false; + + /// Emits the live session on each (re)connect, and null when it drops. + Stream get sessions => _sessions.stream; + + /// The current shared session, or null if not connected right now. + SocialSession? get current => _current; + + /// Begins watching connectivity (reconnect on regain, drop when offline) and + /// attempts an initial connect. Idempotent-ish; call once at startup. + void start() { + _onlineSub = (_online ?? _connectivityOnline()).listen((isOnline) { + if (!isOnline) { + _drop(); + } else if (_current == null) { + unawaited(session()); + } + }); + unawaited(session()); // initial attempt (also connects if already online) + } + + /// The shared session, connecting on first use. Returns null when offline or + /// no relay is reachable; the feature then degrades gracefully. + Future session() { + if (_current != null) return Future.value(_current); + return _pending ??= _connect(); + } + + Future _connect() async { + try { + final relays = await _settings.relayUrls(); + if (relays.isEmpty) return null; + final s = await _open(relays); + if (_disposed) { + await s.close(); + return null; + } + _current = s; + _sessions.add(s); + return s; + } catch (_) { + return null; // unreachable — a later connectivity change retries + } finally { + _pending = null; + } + } + + void _drop() { + final s = _current; + _current = null; + if (s != null) { + unawaited(s.close()); + if (!_sessions.isClosed) _sessions.add(null); + } + } + + Future dispose() async { + _disposed = true; + await _onlineSub?.cancel(); + _onlineSub = null; + _drop(); + if (!_sessions.isClosed) await _sessions.close(); + } + + static Stream _connectivityOnline() => + Connectivity().onConnectivityChanged.map((results) => + results.any((r) => r != ConnectivityResult.none)); +} diff --git a/apps/app_seeds/lib/state/messages_cubit.dart b/apps/app_seeds/lib/state/messages_cubit.dart index f270516..8e6966c 100644 --- a/apps/app_seeds/lib/state/messages_cubit.dart +++ b/apps/app_seeds/lib/state/messages_cubit.dart @@ -5,8 +5,6 @@ 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. @@ -134,29 +132,3 @@ class MessagesCubit extends Cubit { 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); - } -} diff --git a/apps/app_seeds/lib/state/offers_cubit.dart b/apps/app_seeds/lib/state/offers_cubit.dart index c312571..ce6706a 100644 --- a/apps/app_seeds/lib/state/offers_cubit.dart +++ b/apps/app_seeds/lib/state/offers_cubit.dart @@ -9,8 +9,7 @@ import '../data/variety_repository.dart'; import '../services/offer_mapper.dart'; import '../services/offer_outbox.dart'; import '../services/offer_thumbnail.dart'; -import '../services/social_service.dart'; -import '../services/social_settings.dart'; +import '../services/social_connection.dart'; /// State of the offer discovery/publish screen. Transport-agnostic — it holds /// only what the UI shows, never a relay handle. @@ -323,27 +322,20 @@ class OffersCubit extends Cubit { } } -/// Opens an [OffersCubit] wired to the social layer, or an offline one that -/// degrades gracefully. Local-first: when no relay is configured (or connecting -/// fails), the cubit is created with a null transport and the screen still opens. +/// Opens an [OffersCubit] over the SHARED [SocialConnection], or an offline one +/// that degrades gracefully. Local-first: when the connection isn't up (no relay +/// configured / unreachable), the cubit gets a null transport and the screen +/// still opens. Does NOT close the session — the connection owns it. Future createOffersCubit( - SocialService social, - SocialSettings settings, { + SocialConnection connection, { VarietyRepository? repository, }) async { - final relays = await settings.relayUrls(); - if (relays.isEmpty) return OffersCubit(null); - try { - final session = await social.openSession(relays); - return OffersCubit( - session.offers, - coverPhoto: repository?.coverPhotoFor, - thumbnail: offerThumbnailDataUri, - onDispose: session.close, - ); - } catch (_) { - return OffersCubit(null); // offline / no relay reachable - } + final session = await connection.session(); + return OffersCubit( + session?.offers, + coverPhoto: repository?.coverPhotoFor, + thumbnail: offerThumbnailDataUri, + ); } /// Publishes any queued (offline-parked) lots now that we're online, then clears diff --git a/apps/app_seeds/lib/state/trust_cubit.dart b/apps/app_seeds/lib/state/trust_cubit.dart index 55f96e2..2347302 100644 --- a/apps/app_seeds/lib/state/trust_cubit.dart +++ b/apps/app_seeds/lib/state/trust_cubit.dart @@ -2,9 +2,6 @@ import 'package:commons_core/commons_core.dart'; import 'package:equatable/equatable.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; -import '../services/social_service.dart'; -import '../services/social_settings.dart'; - /// Where a peer stands, from strongest to weakest signal. enum TrustTier { /// Passes the full Duniter membership rule (sigQty certifications from members, @@ -189,29 +186,3 @@ class TrustCubit extends Cubit { return super.close(); } } - -/// Opens a [TrustCubit] wired to the social layer for [peerPubkey], or an -/// offline one. -Future createTrustCubit( - SocialService social, - SocialSettings settings, { - required String peerPubkey, -}) async { - final relays = await settings.relayUrls(); - if (relays.isEmpty) { - return TrustCubit(null, - peerPubkey: peerPubkey, selfPubkey: social.publicKeyHex); - } - try { - final session = await social.openSession(relays); - return TrustCubit( - session.trust, - peerPubkey: peerPubkey, - selfPubkey: social.publicKeyHex, - onDispose: session.close, - ); - } catch (_) { - return TrustCubit(null, - peerPubkey: peerPubkey, selfPubkey: social.publicKeyHex); - } -} diff --git a/apps/app_seeds/lib/ui/chat_list_screen.dart b/apps/app_seeds/lib/ui/chat_list_screen.dart index 46237ab..4754c3e 100644 --- a/apps/app_seeds/lib/ui/chat_list_screen.dart +++ b/apps/app_seeds/lib/ui/chat_list_screen.dart @@ -7,8 +7,7 @@ import '../i18n/strings.g.dart'; import '../services/inbox_service.dart'; import '../services/message_store.dart'; import '../services/profile_cache.dart'; -import '../services/social_service.dart'; -import '../services/social_settings.dart'; +import '../services/social_connection.dart'; import 'theme.dart'; import 'unread_badge.dart'; @@ -17,16 +16,16 @@ import 'unread_badge.dart'; class ChatListScreen extends StatefulWidget { const ChatListScreen({ required this.store, - this.social, - this.settings, + this.connection, this.profileCache, this.inbox, super.key, }); final MessageStore store; - final SocialService? social; - final SocialSettings? settings; + + /// The shared relay connection, for resolving peers' published names. + final SocialConnection? connection; final ProfileCache? profileCache; /// App-wide inbox listener; the list reloads whenever it reports a change. @@ -69,20 +68,18 @@ class _ChatListScreenState extends State { unawaited(_resolveMissingNames(items)); } - /// Fetches published names for peers we don't have cached yet, over one - /// session. Best effort; the list already shows short keys meanwhile. + /// Fetches published names for peers we don't have cached yet, over the shared + /// connection. Best effort; the list already shows short keys meanwhile. Future _resolveMissingNames(List items) async { - final social = widget.social; - final settings = widget.settings; + final connection = widget.connection; final cache = widget.profileCache; - if (social == null || settings == null || cache == null) return; + if (connection == null || cache == null) return; final missing = items.map((c) => c.peerPubkey).where((p) => !_names.containsKey(p)); if (missing.isEmpty) return; - final relays = await settings.relayUrls(); - if (relays.isEmpty) return; + final session = await connection.session(); + if (session == null) return; try { - final session = await social.openSession(relays); for (final peer in missing) { final profile = await session.profile.fetch(peer); if (profile != null && profile.name.isNotEmpty) { @@ -90,7 +87,6 @@ class _ChatListScreenState extends State { if (mounted) setState(() => _names[peer] = profile.name); } } - await session.close(); } catch (_) { // offline / unreachable — short keys stay. } diff --git a/apps/app_seeds/lib/ui/chat_screen.dart b/apps/app_seeds/lib/ui/chat_screen.dart index 74a5960..4938c89 100644 --- a/apps/app_seeds/lib/ui/chat_screen.dart +++ b/apps/app_seeds/lib/ui/chat_screen.dart @@ -10,8 +10,8 @@ import '../di/injector.dart'; import '../i18n/strings.g.dart'; import '../services/message_store.dart'; import '../services/profile_cache.dart'; +import '../services/social_connection.dart'; import '../services/social_service.dart'; -import '../services/social_settings.dart'; import '../services/trust_referents.dart'; import '../services/unread_service.dart'; import '../services/wot_settings.dart'; @@ -25,7 +25,7 @@ import 'theme.dart'; class ChatScreen extends StatefulWidget { const ChatScreen({ required this.social, - required this.settings, + required this.connection, required this.peerPubkey, this.messageStore, this.profileCache, @@ -35,7 +35,9 @@ class ChatScreen extends StatefulWidget { }); final SocialService social; - final SocialSettings settings; + + /// The shared relay connection (one per identity), carrying messaging + trust. + final SocialConnection connection; final String peerPubkey; /// Optional persistence for chat history (keystore-backed); null in tests. @@ -53,7 +55,6 @@ class ChatScreen extends StatefulWidget { } class _ChatScreenState extends State { - SocialSession? _session; MessagesCubit? _messages; TrustCubit? _trust; bool _loading = true; @@ -79,25 +80,14 @@ class _ChatScreenState extends State { } Future _init() async { - // One session for this chat carries both messaging and trust (the shared- - // connection shape). Offline (no relays / unreachable) → null transports. - final relays = await widget.settings.relayUrls(); - SocialSession? session; - if (relays.isNotEmpty) { - try { - session = await widget.social.openSession(relays); - } catch (_) { - session = null; - } - } + // The shared connection carries both messaging and trust. Offline (not + // connected / unreachable) → null transports and the screen degrades. + final session = await widget.connection.session(); final cachedName = await widget.profileCache?.name(widget.peerPubkey); final referents = await widget.trustReferents?.all() ?? const {}; final wotParams = await widget.wotSettings?.params() ?? WotParams.duniter; - if (!mounted) { - await session?.close(); - return; - } + if (!mounted) return; // shared session is owned by the connection, not us final self = widget.social.publicKeyHex; final messages = MessagesCubit( session?.messages, @@ -114,7 +104,6 @@ class _ChatScreenState extends State { ); unawaited(trust.load()); setState(() { - _session = session; _messages = messages; _trust = trust; _peerName = cachedName; @@ -184,7 +173,7 @@ class _ChatScreenState extends State { unawaited(_unread?.markRead(widget.peerPubkey)); _messages?.close(); _trust?.close(); - _session?.close(); + // The session belongs to the shared connection — don't close it here. _input.dispose(); super.dispose(); } diff --git a/apps/app_seeds/lib/ui/market_offer_detail_screen.dart b/apps/app_seeds/lib/ui/market_offer_detail_screen.dart index 0f408ed..430c68f 100644 --- a/apps/app_seeds/lib/ui/market_offer_detail_screen.dart +++ b/apps/app_seeds/lib/ui/market_offer_detail_screen.dart @@ -7,8 +7,7 @@ import 'package:go_router/go_router.dart'; import '../i18n/strings.g.dart'; import '../services/profile_cache.dart'; -import '../services/social_service.dart'; -import '../services/social_settings.dart'; +import '../services/social_connection.dart'; import 'market_widgets.dart'; import 'theme.dart'; @@ -20,16 +19,16 @@ import 'theme.dart'; class MarketOfferDetailScreen extends StatefulWidget { const MarketOfferDetailScreen({ required this.offer, - required this.social, - required this.settings, + required this.connection, this.mine = false, this.profileCache, super.key, }); final Offer offer; - final SocialService social; - final SocialSettings settings; + + /// The shared relay connection, for fetching the author's published profile. + final SocialConnection connection; /// Whether this is the current user's own listing (hide the message button). final bool mine; @@ -43,7 +42,6 @@ class MarketOfferDetailScreen extends StatefulWidget { } class _MarketOfferDetailScreenState extends State { - SocialSession? _session; String? _sellerName; String? _sellerAbout; String? _sellerG1; @@ -61,20 +59,8 @@ class _MarketOfferDetailScreenState extends State { final cachedName = await widget.profileCache?.name(widget.offer.authorPubkeyHex); if (mounted && cachedName != null) setState(() => _sellerName = cachedName); - final relays = await widget.settings.relayUrls(); - SocialSession? session; - if (relays.isNotEmpty) { - try { - session = await widget.social.openSession(relays); - } catch (_) { - session = null; - } - } - if (!mounted) { - await session?.close(); - return; - } - _session = session; + final session = await widget.connection.session(); + if (!mounted) return; if (session == null) { setState(() => _profileLoading = false); return; @@ -100,11 +86,7 @@ class _MarketOfferDetailScreenState extends State { } } - @override - void dispose() { - _session?.close(); - super.dispose(); - } + // No dispose of the session — it belongs to the shared connection. Future _copyId() async { final t = context.t; diff --git a/apps/app_seeds/lib/ui/market_screen.dart b/apps/app_seeds/lib/ui/market_screen.dart index a919286..fa1a18b 100644 --- a/apps/app_seeds/lib/ui/market_screen.dart +++ b/apps/app_seeds/lib/ui/market_screen.dart @@ -8,6 +8,7 @@ import '../i18n/strings.g.dart'; import '../services/coarse_location.dart'; import '../services/discovery_area.dart'; import '../services/offer_outbox.dart'; +import '../services/social_connection.dart'; import '../services/social_service.dart'; import '../services/social_settings.dart'; import '../state/offers_cubit.dart'; @@ -21,6 +22,7 @@ class MarketScreen extends StatefulWidget { const MarketScreen({ required this.social, required this.settings, + required this.connection, this.location, this.outbox, super.key, @@ -29,6 +31,9 @@ class MarketScreen extends StatefulWidget { final SocialService social; final SocialSettings settings; + /// The shared relay connection (one per identity), reused for discovery. + final SocialConnection connection; + /// Optional device-location source for the "use my location" shortcut; when /// null (tests, or a platform without it) the shortcut is hidden. final CoarseLocationProvider? location; @@ -58,11 +63,8 @@ class _MarketScreenState extends State { final repo = widget.outbox != null ? context.read() : null; setState(() => _loading = true); - final cubit = await createOffersCubit( - widget.social, - widget.settings, - repository: repo, - ); + final cubit = + await createOffersCubit(widget.connection, repository: repo); final area = await widget.settings.areaGeohash(); if (!mounted) { await cubit.close(); diff --git a/apps/app_seeds/lib/ui/profile_screen.dart b/apps/app_seeds/lib/ui/profile_screen.dart index 303a9ee..1eb940c 100644 --- a/apps/app_seeds/lib/ui/profile_screen.dart +++ b/apps/app_seeds/lib/ui/profile_screen.dart @@ -7,8 +7,8 @@ import '../i18n/strings.g.dart'; import '../services/profile_cache.dart' show shortPubkey; import '../services/social_account_store.dart'; import '../services/profile_store.dart'; +import '../services/social_connection.dart'; import '../services/social_service.dart'; -import '../services/social_settings.dart'; import 'qr_view.dart'; import 'restart_widget.dart'; import 'theme.dart'; @@ -19,7 +19,7 @@ import 'theme.dart'; class ProfileScreen extends StatefulWidget { const ProfileScreen({ required this.social, - required this.settings, + required this.connection, required this.profileStore, required this.accounts, this.trustNetworkEnabled = false, @@ -27,7 +27,9 @@ class ProfileScreen extends StatefulWidget { }); final SocialService social; - final SocialSettings settings; + + /// The shared relay connection, for publishing your profile. + final SocialConnection connection; final ProfileStore profileStore; final SocialAccountStore accounts; @@ -121,20 +123,16 @@ class _ProfileScreenState extends State { await widget.profileStore .save(name: _name.text, about: _about.text, g1: _g1.text); - // Best-effort publish to the network so peers see the name. - final relays = await widget.settings.relayUrls(); - if (relays.isNotEmpty) { - try { - final session = await widget.social.openSession(relays); - await session.profile.publish( - name: _name.text.trim(), - about: _about.text.trim(), - g1: _g1.text.trim(), - ); - await session.close(); - } catch (_) { - // offline / unreachable — the local copy is saved regardless. - } + // Best-effort publish over the shared connection so peers see the name. + try { + final session = await widget.connection.session(); + await session?.profile.publish( + name: _name.text.trim(), + about: _about.text.trim(), + g1: _g1.text.trim(), + ); + } catch (_) { + // offline / unreachable — the local copy is saved regardless. } if (!mounted) return; setState(() => _saving = false); diff --git a/apps/app_seeds/pubspec.yaml b/apps/app_seeds/pubspec.yaml index e5003ba..75ee26e 100644 --- a/apps/app_seeds/pubspec.yaml +++ b/apps/app_seeds/pubspec.yaml @@ -90,6 +90,8 @@ dev_dependencies: drift_dev: ^2.28.0 slang_build_runner: ^4.7.0 mocktail: ^1.0.4 + # Nostr types (Event/Filter) to fake a NostrChannel in the connection test. + nostr: ^2.0.0 flutter_launcher_icons: ^0.14.4 flutter_native_splash: ^2.4.7 diff --git a/apps/app_seeds/test/services/inbox_service_test.dart b/apps/app_seeds/test/services/inbox_service_test.dart index 5cb5c44..648d7de 100644 --- a/apps/app_seeds/test/services/inbox_service_test.dart +++ b/apps/app_seeds/test/services/inbox_service_test.dart @@ -3,6 +3,7 @@ import 'package:flutter_test/flutter_test.dart'; import 'package:tane/services/inbox_service.dart'; import 'package:tane/services/message_store.dart'; import 'package:tane/services/notification_service.dart'; +import 'package:tane/services/social_connection.dart'; import 'package:tane/services/social_service.dart'; import 'package:tane/services/social_settings.dart'; import 'package:tane/services/unread_service.dart'; @@ -26,21 +27,29 @@ class _RecordingNotifications extends NotificationService { } } -/// The app-wide inbox listener persists incoming messages and announces changes, -/// so the inbox list refreshes even when the specific chat isn't open. Driven -/// through the [InboxService.ingest] seam so no relay/network is involved. +/// The app-wide inbox listener persists incoming messages, updates unread and +/// notifies — even when the specific chat isn't open. Driven through the +/// [InboxService.ingest] seam so no relay/network is involved. void main() { const seedHex = '000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f'; + // A connection that never actually opens — ingest doesn't use it. + Future offlineConnection() async => SocialConnection( + social: await SocialService.fromRootSeedHex(seedHex), + settings: SocialSettings(InMemorySecretStore()), + open: (_) async => throw StateError('unused in ingest tests'), + online: const Stream.empty(), + ); + late MessageStore store; late InboxService inbox; setUp(() async { store = MessageStore(InMemorySecretStore()); inbox = InboxService( - social: await SocialService.fromRootSeedHex(seedHex), - settings: SocialSettings(InMemorySecretStore()), + connection: await offlineConnection(), + selfPubkey: 'me', store: store, ); }); @@ -75,16 +84,14 @@ void main() { group('unread + notification hooks', () { late UnreadService unread; late _RecordingNotifications notifications; - late String myPubkey; + const myPubkey = 'me'; setUp(() async { - final social = await SocialService.fromRootSeedHex(seedHex); - myPubkey = social.publicKeyHex; unread = UnreadService(store, InMemorySecretStore()); notifications = _RecordingNotifications(); inbox = InboxService( - social: social, - settings: SocialSettings(InMemorySecretStore()), + connection: await offlineConnection(), + selfPubkey: myPubkey, store: store, unread: unread, notifications: notifications, diff --git a/apps/app_seeds/test/services/social_connection_test.dart b/apps/app_seeds/test/services/social_connection_test.dart new file mode 100644 index 0000000..e4c7151 --- /dev/null +++ b/apps/app_seeds/test/services/social_connection_test.dart @@ -0,0 +1,119 @@ +import 'dart:async'; + +import 'package:commons_core/commons_core.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:nostr/nostr.dart'; +import 'package:tane/services/social_connection.dart'; +import 'package:tane/services/social_service.dart'; +import 'package:tane/services/social_settings.dart'; + +import '../support/test_support.dart'; + +/// A no-op [NostrChannel] that only tracks whether it was closed — enough to +/// build a [SocialSession] and assert the connection's lifecycle. +class FakeChannel implements NostrChannel { + bool closed = false; + + @override + String get privateKeyHex => '00' * 32; + @override + String get publicKeyHex => 'ab' * 32; + @override + Future<({bool accepted, String message})> publish(Event event) async => + (accepted: true, message: ''); + @override + Stream subscribe(Filter filter) => const Stream.empty(); + @override + Future> reqOnce(Filter filter) async => const []; + @override + Future close() async => closed = true; +} + +void main() { + const seedHex = + '000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f'; + + late SocialService social; + late SocialSettings settings; // relayUrls() falls back to defaults (non-empty) + + setUp(() async { + social = await SocialService.fromRootSeedHex(seedHex); + settings = SocialSettings(InMemorySecretStore()); + }); + + SocialConnection make({ + required List opened, + Stream? online, + bool Function()? fail, + }) => + SocialConnection( + social: social, + settings: settings, + online: online, + open: (_) async { + if (fail?.call() ?? false) throw StateError('unreachable'); + final ch = FakeChannel(); + opened.add(ch); + return SocialSession(ch); + }, + ); + + test('connects once and reuses the shared session', () async { + final opened = []; + final conn = make(opened: opened); + final a = await conn.session(); + final b = await conn.session(); + expect(a, isNotNull); + expect(identical(a, b), isTrue); // same shared instance + expect(opened, hasLength(1)); // only one connection opened + await conn.dispose(); + }); + + test('concurrent callers share a single connect', () async { + final opened = []; + final conn = make(opened: opened); + final results = await Future.wait([conn.session(), conn.session()]); + expect(identical(results[0], results[1]), isTrue); + expect(opened, hasLength(1)); + await conn.dispose(); + }); + + test('drops when offline and reconnects when back online', () async { + final opened = []; + final online = StreamController.broadcast(); + final conn = make(opened: opened, online: online.stream); + final emitted = []; + conn.sessions.listen(emitted.add); + + conn.start(); // watch connectivity + initial connect + final first = await conn.session(); + expect(first, isNotNull); + expect(opened, hasLength(1)); + + online.add(false); // network lost + await Future.delayed(Duration.zero); + expect(conn.current, isNull); + expect(opened.first.closed, isTrue); // old session closed + expect(emitted.last, isNull); // announced the drop + + online.add(true); // network back + await Future.delayed(Duration.zero); + expect(conn.current, isNotNull); + expect(opened, hasLength(2)); // reconnected + expect(identical(emitted.last, conn.current), isTrue); + + await conn.dispose(); + await online.close(); + }); + + test('returns null when the relay is unreachable, and retries later', + () async { + final opened = []; + var down = true; + final conn = make(opened: opened, fail: () => down); + expect(await conn.session(), isNull); // unreachable now + down = false; + expect(await conn.session(), isNotNull); // succeeds on retry + await conn.dispose(); + }); +} diff --git a/apps/app_seeds/test/ui/chat_screen_test.dart b/apps/app_seeds/test/ui/chat_screen_test.dart index 191e175..76f8bef 100644 --- a/apps/app_seeds/test/ui/chat_screen_test.dart +++ b/apps/app_seeds/test/ui/chat_screen_test.dart @@ -2,6 +2,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_localizations/flutter_localizations.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:tane/i18n/strings.g.dart'; +import 'package:tane/services/social_connection.dart'; import 'package:tane/services/social_service.dart'; import 'package:tane/services/social_settings.dart'; import 'package:tane/ui/chat_screen.dart'; @@ -14,6 +15,7 @@ void main() { final social = await SocialService.fromRootSeedHex('00' * 32); final settings = SocialSettings(InMemorySecretStore()); await settings.setRelayUrls(const []); // offline: don't hit the network + final connection = SocialConnection(social: social, settings: settings); LocaleSettings.setLocaleSync(AppLocale.en); await tester.pumpWidget( @@ -28,7 +30,7 @@ void main() { ], home: ChatScreen( social: social, - settings: settings, + connection: connection, peerPubkey: 'ab' * 32, ), ), diff --git a/apps/app_seeds/test/ui/market_offer_detail_screen_test.dart b/apps/app_seeds/test/ui/market_offer_detail_screen_test.dart index d431faa..f1d90db 100644 --- a/apps/app_seeds/test/ui/market_offer_detail_screen_test.dart +++ b/apps/app_seeds/test/ui/market_offer_detail_screen_test.dart @@ -3,6 +3,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_localizations/flutter_localizations.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:tane/i18n/strings.g.dart'; +import 'package:tane/services/social_connection.dart'; import 'package:tane/services/social_service.dart'; import 'package:tane/services/social_settings.dart'; import 'package:tane/ui/market_offer_detail_screen.dart'; @@ -43,11 +44,11 @@ void main() { final social = await SocialService.fromRootSeedHex('00' * 32); final settings = SocialSettings(InMemorySecretStore()); await settings.setRelayUrls(const []); // offline: no network in the test + final connection = SocialConnection(social: social, settings: settings); await tester.pumpWidget(_wrap(MarketOfferDetailScreen( offer: _offer(organic: true), - social: social, - settings: settings, + connection: connection, ))); await tester.pumpAndSettle(); @@ -61,11 +62,11 @@ void main() { final social = await SocialService.fromRootSeedHex('00' * 32); final settings = SocialSettings(InMemorySecretStore()); await settings.setRelayUrls(const []); + final connection = SocialConnection(social: social, settings: settings); await tester.pumpWidget(_wrap(MarketOfferDetailScreen( offer: _offer(), - social: social, - settings: settings, + connection: connection, mine: true, ))); await tester.pumpAndSettle(); @@ -77,11 +78,11 @@ void main() { final social = await SocialService.fromRootSeedHex('00' * 32); final settings = SocialSettings(InMemorySecretStore()); await settings.setRelayUrls(const []); + final connection = SocialConnection(social: social, settings: settings); await tester.pumpWidget(_wrap(MarketOfferDetailScreen( offer: _offer(imageUrl: 'https://media.example/abc.jpg'), - social: social, - settings: settings, + connection: connection, ))); await tester.pumpAndSettle(); @@ -92,11 +93,11 @@ void main() { final social = await SocialService.fromRootSeedHex('00' * 32); final settings = SocialSettings(InMemorySecretStore()); await settings.setRelayUrls(const []); + final connection = SocialConnection(social: social, settings: settings); await tester.pumpWidget(_wrap(MarketOfferDetailScreen( offer: _offer(), - social: social, - settings: settings, + connection: connection, ))); await tester.pumpAndSettle(); diff --git a/apps/app_seeds/test/ui/market_screen_test.dart b/apps/app_seeds/test/ui/market_screen_test.dart index 93dfddb..c158194 100644 --- a/apps/app_seeds/test/ui/market_screen_test.dart +++ b/apps/app_seeds/test/ui/market_screen_test.dart @@ -4,6 +4,7 @@ import 'package:flutter_localizations/flutter_localizations.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:tane/i18n/strings.g.dart'; import 'package:tane/services/coarse_location.dart'; +import 'package:tane/services/social_connection.dart'; import 'package:tane/services/social_service.dart'; import 'package:tane/services/social_settings.dart'; import 'package:tane/state/offers_cubit.dart'; @@ -60,7 +61,12 @@ Widget _wrapMarket(SocialService social, SocialSettings settings, GlobalWidgetsLocalizations.delegate, GlobalCupertinoLocalizations.delegate, ], - home: MarketScreen(social: social, settings: settings, location: location), + home: MarketScreen( + social: social, + settings: settings, + connection: SocialConnection(social: social, settings: settings), + location: location, + ), ), ); }