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.
This commit is contained in:
vjrj 2026-07-10 21:41:17 +02:00
parent e2b88b4f26
commit bb4ee2fd89
19 changed files with 431 additions and 280 deletions

View file

@ -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<void> 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<SocialSettings>(),
);
getIt
..registerSingleton<SocialService>(socialService)
..registerSingleton<SocialConnection>(connection)
// Tracks unread private messages so the UI can badge them.
..registerSingleton<UnreadService>(
UnreadService(getIt<MessageStore>(), 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>(
InboxService(
social: socialService,
settings: getIt<SocialSettings>(),
connection: connection,
selfPubkey: socialService.publicKeyHex,
store: getIt<MessageStore>(),
profileCache: getIt<ProfileCache>(),
unread: getIt<UnreadService>(),
@ -238,11 +245,18 @@ Future<void> switchSocialAccount(int account) async {
final scope = socialAccountScope(account);
final rootSeedHex = await getIt<SecureKeyStore>().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<InboxService>()) {
await getIt<InboxService>().stop();
await getIt.unregister<InboxService>();
}
if (getIt.isRegistered<SocialConnection>()) {
await getIt<SocialConnection>().dispose();
await getIt.unregister<SocialConnection>();
}
if (getIt.isRegistered<UnreadService>()) {
await getIt.unregister<UnreadService>();
}
if (getIt.isRegistered<SocialService>()) {
await getIt.unregister<SocialService>();
}
@ -257,20 +271,29 @@ Future<void> switchSocialAccount(int account) async {
..registerSingleton<ProfileStore>(
ProfileStore(secretStore, accountScope: scope))
..registerSingleton<ProfileCache>(
ProfileCache(secretStore, accountScope: scope));
ProfileCache(secretStore, accountScope: scope))
..registerSingleton<UnreadService>(
UnreadService(getIt<MessageStore>(), secretStore));
final social =
await SocialService.fromRootSeedHex(rootSeedHex, account: account);
final connection =
SocialConnection(social: social, settings: getIt<SocialSettings>());
final inbox = InboxService(
social: social,
settings: getIt<SocialSettings>(),
connection: connection,
selfPubkey: social.publicKeyHex,
store: getIt<MessageStore>(),
profileCache: getIt<ProfileCache>(),
unread: getIt<UnreadService>(),
notifications: getIt<NotificationService>(),
);
getIt
..registerSingleton<SocialService>(social)
..registerSingleton<SocialConnection>(connection)
..registerSingleton<InboxService>(inbox);
unawaited(inbox.start());
// Subscribe the listener BEFORE the connection starts connecting.
inbox.start();
connection.start();
}
Future<Directory> _backupsDir() async {