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

@ -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<ChatListScreen> {
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<void> _resolveMissingNames(List<ChatSummary> 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<ChatListScreen> {
if (mounted) setState(() => _names[peer] = profile.name);
}
}
await session.close();
} catch (_) {
// offline / unreachable short keys stay.
}

View file

@ -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<ChatScreen> {
SocialSession? _session;
MessagesCubit? _messages;
TrustCubit? _trust;
bool _loading = true;
@ -79,25 +80,14 @@ class _ChatScreenState extends State<ChatScreen> {
}
Future<void> _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 <String>{};
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<ChatScreen> {
);
unawaited(trust.load());
setState(() {
_session = session;
_messages = messages;
_trust = trust;
_peerName = cachedName;
@ -184,7 +173,7 @@ class _ChatScreenState extends State<ChatScreen> {
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();
}

View file

@ -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<MarketOfferDetailScreen> {
SocialSession? _session;
String? _sellerName;
String? _sellerAbout;
String? _sellerG1;
@ -61,20 +59,8 @@ class _MarketOfferDetailScreenState extends State<MarketOfferDetailScreen> {
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<MarketOfferDetailScreen> {
}
}
@override
void dispose() {
_session?.close();
super.dispose();
}
// No dispose of the session it belongs to the shared connection.
Future<void> _copyId() async {
final t = context.t;

View file

@ -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<MarketScreen> {
final repo =
widget.outbox != null ? context.read<VarietyRepository>() : 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();

View file

@ -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<ProfileScreen> {
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);