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

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