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
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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>(),
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
|
|
|
|||
107
apps/app_seeds/lib/services/social_connection.dart
Normal file
107
apps/app_seeds/lib/services/social_connection.dart
Normal 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));
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
119
apps/app_seeds/test/services/social_connection_test.dart
Normal file
119
apps/app_seeds/test/services/social_connection_test.dart
Normal 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();
|
||||
});
|
||||
}
|
||||
|
|
@ -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,
|
||||
),
|
||||
),
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue