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:
parent
e2b88b4f26
commit
bb4ee2fd89
19 changed files with 431 additions and 280 deletions
|
|
@ -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.
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue