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 2c8869ea0d
commit b9492cb65a
19 changed files with 431 additions and 280 deletions

View file

@ -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,

View file

@ -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<Bootstrap> {
if (savedLocale != null) LocaleSettings.setLocaleSync(savedLocale);
final onboarding = getIt<OnboardingStore>();
final connection = getIt.isRegistered<SocialConnection>()
? getIt<SocialConnection>()
: null;
final inbox =
getIt.isRegistered<InboxService>() ? getIt<InboxService>() : null;
final notifications = getIt.isRegistered<NotificationService>()
@ -61,9 +65,10 @@ class _BootstrapState extends State<Bootstrap> {
// 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<VarietyRepository>(),
@ -72,6 +77,7 @@ class _BootstrapState extends State<Bootstrap> {
social:
getIt.isRegistered<SocialService>() ? getIt<SocialService>() : null,
socialSettings: getIt<SocialSettings>(),
connection: connection,
location: const GeolocatorCoarseLocation(),
outbox: getIt<OfferOutbox>(),
messageStore: getIt<MessageStore>(),

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 {

View file

@ -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<void>.broadcast();
SocialSession? _session;
StreamSubscription<SocialSession?>? _sessionsSub;
StreamSubscription<PrivateMessage>? _inboxSub;
StreamSubscription<List<ConnectivityResult>>? _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<void> 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<void> 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<void> _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<void> 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<void> 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();
}

View file

@ -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<SocialSession> Function(List<String> 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<bool>? online,
}) : _settings = settings,
_open = open ?? social.openSession,
_online = online;
final SocialSettings _settings;
final SessionOpener _open;
final Stream<bool>? _online;
final _sessions = StreamController<SocialSession?>.broadcast();
SocialSession? _current;
Future<SocialSession?>? _pending;
StreamSubscription<bool>? _onlineSub;
bool _disposed = false;
/// Emits the live session on each (re)connect, and null when it drops.
Stream<SocialSession?> 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<SocialSession?> session() {
if (_current != null) return Future.value(_current);
return _pending ??= _connect();
}
Future<SocialSession?> _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<void> dispose() async {
_disposed = true;
await _onlineSub?.cancel();
_onlineSub = null;
_drop();
if (!_sessions.isClosed) await _sessions.close();
}
static Stream<bool> _connectivityOnline() =>
Connectivity().onConnectivityChanged.map((results) =>
results.any((r) => r != ConnectivityResult.none));
}

View file

@ -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<ChatState> {
return super.close();
}
}
/// Opens a [MessagesCubit] wired to the social layer for a chat with
/// [peerPubkey], or an offline one that degrades gracefully.
Future<MessagesCubit> 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);
}
}

View file

@ -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<OffersState> {
}
}
/// 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<OffersCubit> 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

View file

@ -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<TrustState> {
return super.close();
}
}
/// Opens a [TrustCubit] wired to the social layer for [peerPubkey], or an
/// offline one.
Future<TrustCubit> 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);
}
}

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);

View file

@ -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

View file

@ -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<SocialConnection> 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,

View file

@ -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<Event> subscribe(Filter filter) => const Stream.empty();
@override
Future<List<Event>> reqOnce(Filter filter) async => const [];
@override
Future<void> 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<FakeChannel> opened,
Stream<bool>? 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 = <FakeChannel>[];
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 = <FakeChannel>[];
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 = <FakeChannel>[];
final online = StreamController<bool>.broadcast();
final conn = make(opened: opened, online: online.stream);
final emitted = <SocialSession?>[];
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<void>.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<void>.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 = <FakeChannel>[];
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();
});
}

View file

@ -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,
),
),

View file

@ -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();

View file

@ -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,
),
),
);
}