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_cache.dart';
|
||||||
import 'services/profile_store.dart';
|
import 'services/profile_store.dart';
|
||||||
import 'services/social_account_store.dart';
|
import 'services/social_account_store.dart';
|
||||||
|
import 'services/social_connection.dart';
|
||||||
import 'services/social_service.dart';
|
import 'services/social_service.dart';
|
||||||
import 'services/social_settings.dart';
|
import 'services/social_settings.dart';
|
||||||
import 'services/trust_referents.dart';
|
import 'services/trust_referents.dart';
|
||||||
|
|
@ -50,6 +51,7 @@ class TaneApp extends StatelessWidget {
|
||||||
required this.onboarding,
|
required this.onboarding,
|
||||||
this.social,
|
this.social,
|
||||||
this.socialSettings,
|
this.socialSettings,
|
||||||
|
this.connection,
|
||||||
this.location,
|
this.location,
|
||||||
this.outbox,
|
this.outbox,
|
||||||
this.messageStore,
|
this.messageStore,
|
||||||
|
|
@ -69,6 +71,7 @@ class TaneApp extends StatelessWidget {
|
||||||
showIntro,
|
showIntro,
|
||||||
social,
|
social,
|
||||||
socialSettings,
|
socialSettings,
|
||||||
|
connection,
|
||||||
location,
|
location,
|
||||||
outbox,
|
outbox,
|
||||||
messageStore,
|
messageStore,
|
||||||
|
|
@ -94,6 +97,9 @@ class TaneApp extends StatelessWidget {
|
||||||
final SocialService? social;
|
final SocialService? social;
|
||||||
final SocialSettings? socialSettings;
|
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".
|
/// Optional device-location source for the market's "use my location".
|
||||||
final CoarseLocationProvider? location;
|
final CoarseLocationProvider? location;
|
||||||
|
|
||||||
|
|
@ -135,6 +141,7 @@ class TaneApp extends StatelessWidget {
|
||||||
bool showIntro,
|
bool showIntro,
|
||||||
SocialService? social,
|
SocialService? social,
|
||||||
SocialSettings? socialSettings,
|
SocialSettings? socialSettings,
|
||||||
|
SocialConnection? connection,
|
||||||
CoarseLocationProvider? location,
|
CoarseLocationProvider? location,
|
||||||
OfferOutbox? outbox,
|
OfferOutbox? outbox,
|
||||||
MessageStore? messageStore,
|
MessageStore? messageStore,
|
||||||
|
|
@ -153,50 +160,49 @@ class TaneApp extends StatelessWidget {
|
||||||
builder: (context, state) =>
|
builder: (context, state) =>
|
||||||
HomeScreen(marketEnabled: social != null),
|
HomeScreen(marketEnabled: social != null),
|
||||||
),
|
),
|
||||||
if (social != null && socialSettings != null)
|
if (social != null && socialSettings != null && connection != null)
|
||||||
GoRoute(
|
GoRoute(
|
||||||
path: '/market',
|
path: '/market',
|
||||||
builder: (context, state) => MarketScreen(
|
builder: (context, state) => MarketScreen(
|
||||||
social: social,
|
social: social,
|
||||||
settings: socialSettings,
|
settings: socialSettings,
|
||||||
|
connection: connection,
|
||||||
location: location,
|
location: location,
|
||||||
outbox: outbox,
|
outbox: outbox,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
if (social != null && socialSettings != null)
|
if (social != null && connection != null)
|
||||||
GoRoute(
|
GoRoute(
|
||||||
path: '/market/offer',
|
path: '/market/offer',
|
||||||
builder: (context, state) {
|
builder: (context, state) {
|
||||||
final offer = state.extra as Offer;
|
final offer = state.extra as Offer;
|
||||||
return MarketOfferDetailScreen(
|
return MarketOfferDetailScreen(
|
||||||
offer: offer,
|
offer: offer,
|
||||||
social: social,
|
connection: connection,
|
||||||
settings: socialSettings,
|
|
||||||
mine: offer.authorPubkeyHex == social.publicKeyHex,
|
mine: offer.authorPubkeyHex == social.publicKeyHex,
|
||||||
profileCache: profileCache,
|
profileCache: profileCache,
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
if (social != null && socialSettings != null && messageStore != null)
|
if (messageStore != null)
|
||||||
GoRoute(
|
GoRoute(
|
||||||
path: '/messages',
|
path: '/messages',
|
||||||
builder: (context, state) => ChatListScreen(
|
builder: (context, state) => ChatListScreen(
|
||||||
store: messageStore,
|
store: messageStore,
|
||||||
social: social,
|
connection: connection,
|
||||||
settings: socialSettings,
|
|
||||||
profileCache: profileCache,
|
profileCache: profileCache,
|
||||||
inbox: inbox,
|
inbox: inbox,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
if (social != null &&
|
if (social != null &&
|
||||||
socialSettings != null &&
|
connection != null &&
|
||||||
profileStore != null &&
|
profileStore != null &&
|
||||||
socialAccounts != null)
|
socialAccounts != null)
|
||||||
GoRoute(
|
GoRoute(
|
||||||
path: '/profile',
|
path: '/profile',
|
||||||
builder: (context, state) => ProfileScreen(
|
builder: (context, state) => ProfileScreen(
|
||||||
social: social,
|
social: social,
|
||||||
settings: socialSettings,
|
connection: connection,
|
||||||
profileStore: profileStore,
|
profileStore: profileStore,
|
||||||
accounts: socialAccounts,
|
accounts: socialAccounts,
|
||||||
trustNetworkEnabled:
|
trustNetworkEnabled:
|
||||||
|
|
@ -211,12 +217,12 @@ class TaneApp extends StatelessWidget {
|
||||||
wotSettings: wotSettings,
|
wotSettings: wotSettings,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
if (social != null && socialSettings != null)
|
if (social != null && connection != null)
|
||||||
GoRoute(
|
GoRoute(
|
||||||
path: '/chat/:pubkey',
|
path: '/chat/:pubkey',
|
||||||
builder: (context, state) => ChatScreen(
|
builder: (context, state) => ChatScreen(
|
||||||
social: social,
|
social: social,
|
||||||
settings: socialSettings,
|
connection: connection,
|
||||||
peerPubkey: state.pathParameters['pubkey']!,
|
peerPubkey: state.pathParameters['pubkey']!,
|
||||||
messageStore: messageStore,
|
messageStore: messageStore,
|
||||||
profileCache: profileCache,
|
profileCache: profileCache,
|
||||||
|
|
|
||||||
|
|
@ -19,6 +19,7 @@ import 'services/onboarding_store.dart';
|
||||||
import 'services/profile_cache.dart';
|
import 'services/profile_cache.dart';
|
||||||
import 'services/profile_store.dart';
|
import 'services/profile_store.dart';
|
||||||
import 'services/social_account_store.dart';
|
import 'services/social_account_store.dart';
|
||||||
|
import 'services/social_connection.dart';
|
||||||
import 'services/social_service.dart';
|
import 'services/social_service.dart';
|
||||||
import 'services/social_settings.dart';
|
import 'services/social_settings.dart';
|
||||||
import 'services/trust_referents.dart';
|
import 'services/trust_referents.dart';
|
||||||
|
|
@ -53,6 +54,9 @@ class _BootstrapState extends State<Bootstrap> {
|
||||||
if (savedLocale != null) LocaleSettings.setLocaleSync(savedLocale);
|
if (savedLocale != null) LocaleSettings.setLocaleSync(savedLocale);
|
||||||
|
|
||||||
final onboarding = getIt<OnboardingStore>();
|
final onboarding = getIt<OnboardingStore>();
|
||||||
|
final connection = getIt.isRegistered<SocialConnection>()
|
||||||
|
? getIt<SocialConnection>()
|
||||||
|
: null;
|
||||||
final inbox =
|
final inbox =
|
||||||
getIt.isRegistered<InboxService>() ? getIt<InboxService>() : null;
|
getIt.isRegistered<InboxService>() ? getIt<InboxService>() : null;
|
||||||
final notifications = getIt.isRegistered<NotificationService>()
|
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
|
// Ask for notification permission and set up the OS channel (no-op on
|
||||||
// unsupported platforms). Done before the inbox starts listening.
|
// unsupported platforms). Done before the inbox starts listening.
|
||||||
if (notifications != null) await notifications.initialize();
|
if (notifications != null) await notifications.initialize();
|
||||||
// Listen for incoming private messages app-wide (foreground) so they arrive
|
// Subscribe the inbox listener BEFORE the shared connection starts
|
||||||
// and fill the inbox even before their chat is opened.
|
// connecting, so the first session is caught; then bring the connection up.
|
||||||
if (inbox != null) unawaited(inbox.start());
|
inbox?.start();
|
||||||
|
connection?.start();
|
||||||
|
|
||||||
return TaneApp(
|
return TaneApp(
|
||||||
repository: getIt<VarietyRepository>(),
|
repository: getIt<VarietyRepository>(),
|
||||||
|
|
@ -72,6 +77,7 @@ class _BootstrapState extends State<Bootstrap> {
|
||||||
social:
|
social:
|
||||||
getIt.isRegistered<SocialService>() ? getIt<SocialService>() : null,
|
getIt.isRegistered<SocialService>() ? getIt<SocialService>() : null,
|
||||||
socialSettings: getIt<SocialSettings>(),
|
socialSettings: getIt<SocialSettings>(),
|
||||||
|
connection: connection,
|
||||||
location: const GeolocatorCoarseLocation(),
|
location: const GeolocatorCoarseLocation(),
|
||||||
outbox: getIt<OfferOutbox>(),
|
outbox: getIt<OfferOutbox>(),
|
||||||
messageStore: getIt<MessageStore>(),
|
messageStore: getIt<MessageStore>(),
|
||||||
|
|
|
||||||
|
|
@ -37,6 +37,7 @@ import '../services/offer_outbox.dart';
|
||||||
import '../services/profile_cache.dart';
|
import '../services/profile_cache.dart';
|
||||||
import '../services/profile_store.dart';
|
import '../services/profile_store.dart';
|
||||||
import '../services/social_account_store.dart';
|
import '../services/social_account_store.dart';
|
||||||
|
import '../services/social_connection.dart';
|
||||||
import '../services/social_service.dart';
|
import '../services/social_service.dart';
|
||||||
import '../services/social_settings.dart';
|
import '../services/social_settings.dart';
|
||||||
import '../services/trust_referents.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
|
// Optional: absent only if the derivation above failed — then the app runs
|
||||||
// inventory-only, by design (market/chat/profile hidden).
|
// inventory-only, by design (market/chat/profile hidden).
|
||||||
if (socialService != null) {
|
if (socialService != null) {
|
||||||
|
// ONE shared relay connection per identity, reused by every feature.
|
||||||
|
final connection = SocialConnection(
|
||||||
|
social: socialService,
|
||||||
|
settings: getIt<SocialSettings>(),
|
||||||
|
);
|
||||||
getIt
|
getIt
|
||||||
..registerSingleton<SocialService>(socialService)
|
..registerSingleton<SocialService>(socialService)
|
||||||
|
..registerSingleton<SocialConnection>(connection)
|
||||||
// Tracks unread private messages so the UI can badge them.
|
// Tracks unread private messages so the UI can badge them.
|
||||||
..registerSingleton<UnreadService>(
|
..registerSingleton<UnreadService>(
|
||||||
UnreadService(getIt<MessageStore>(), secretStore),
|
UnreadService(getIt<MessageStore>(), secretStore),
|
||||||
)
|
)
|
||||||
// App-wide inbox listener so private messages arrive (and land in the
|
// App-wide inbox listener over the shared connection, so private messages
|
||||||
// inbox list) even when the specific chat isn't open. Started in `main`.
|
// arrive (and land in the inbox list) even when the chat isn't open.
|
||||||
// It also updates unread counts and fires notifications.
|
// Started in `Bootstrap`; it also updates unread and fires notifications.
|
||||||
..registerSingleton<InboxService>(
|
..registerSingleton<InboxService>(
|
||||||
InboxService(
|
InboxService(
|
||||||
social: socialService,
|
connection: connection,
|
||||||
settings: getIt<SocialSettings>(),
|
selfPubkey: socialService.publicKeyHex,
|
||||||
store: getIt<MessageStore>(),
|
store: getIt<MessageStore>(),
|
||||||
profileCache: getIt<ProfileCache>(),
|
profileCache: getIt<ProfileCache>(),
|
||||||
unread: getIt<UnreadService>(),
|
unread: getIt<UnreadService>(),
|
||||||
|
|
@ -238,11 +245,18 @@ Future<void> switchSocialAccount(int account) async {
|
||||||
final scope = socialAccountScope(account);
|
final scope = socialAccountScope(account);
|
||||||
final rootSeedHex = await getIt<SecureKeyStore>().rootSeedHex();
|
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>()) {
|
if (getIt.isRegistered<InboxService>()) {
|
||||||
await getIt<InboxService>().stop();
|
await getIt<InboxService>().stop();
|
||||||
await getIt.unregister<InboxService>();
|
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>()) {
|
if (getIt.isRegistered<SocialService>()) {
|
||||||
await getIt.unregister<SocialService>();
|
await getIt.unregister<SocialService>();
|
||||||
}
|
}
|
||||||
|
|
@ -257,20 +271,29 @@ Future<void> switchSocialAccount(int account) async {
|
||||||
..registerSingleton<ProfileStore>(
|
..registerSingleton<ProfileStore>(
|
||||||
ProfileStore(secretStore, accountScope: scope))
|
ProfileStore(secretStore, accountScope: scope))
|
||||||
..registerSingleton<ProfileCache>(
|
..registerSingleton<ProfileCache>(
|
||||||
ProfileCache(secretStore, accountScope: scope));
|
ProfileCache(secretStore, accountScope: scope))
|
||||||
|
..registerSingleton<UnreadService>(
|
||||||
|
UnreadService(getIt<MessageStore>(), secretStore));
|
||||||
|
|
||||||
final social =
|
final social =
|
||||||
await SocialService.fromRootSeedHex(rootSeedHex, account: account);
|
await SocialService.fromRootSeedHex(rootSeedHex, account: account);
|
||||||
|
final connection =
|
||||||
|
SocialConnection(social: social, settings: getIt<SocialSettings>());
|
||||||
final inbox = InboxService(
|
final inbox = InboxService(
|
||||||
social: social,
|
connection: connection,
|
||||||
settings: getIt<SocialSettings>(),
|
selfPubkey: social.publicKeyHex,
|
||||||
store: getIt<MessageStore>(),
|
store: getIt<MessageStore>(),
|
||||||
profileCache: getIt<ProfileCache>(),
|
profileCache: getIt<ProfileCache>(),
|
||||||
|
unread: getIt<UnreadService>(),
|
||||||
|
notifications: getIt<NotificationService>(),
|
||||||
);
|
);
|
||||||
getIt
|
getIt
|
||||||
..registerSingleton<SocialService>(social)
|
..registerSingleton<SocialService>(social)
|
||||||
|
..registerSingleton<SocialConnection>(connection)
|
||||||
..registerSingleton<InboxService>(inbox);
|
..registerSingleton<InboxService>(inbox);
|
||||||
unawaited(inbox.start());
|
// Subscribe the listener BEFORE the connection starts connecting.
|
||||||
|
inbox.start();
|
||||||
|
connection.start();
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<Directory> _backupsDir() async {
|
Future<Directory> _backupsDir() async {
|
||||||
|
|
|
||||||
|
|
@ -1,110 +1,89 @@
|
||||||
import 'dart:async';
|
import 'dart:async';
|
||||||
|
|
||||||
import 'package:commons_core/commons_core.dart';
|
import 'package:commons_core/commons_core.dart';
|
||||||
import 'package:connectivity_plus/connectivity_plus.dart';
|
|
||||||
import 'package:flutter/foundation.dart';
|
import 'package:flutter/foundation.dart';
|
||||||
|
|
||||||
import '../i18n/strings.g.dart';
|
import '../i18n/strings.g.dart';
|
||||||
import 'message_store.dart';
|
import 'message_store.dart';
|
||||||
import 'notification_service.dart';
|
import 'notification_service.dart';
|
||||||
import 'profile_cache.dart';
|
import 'profile_cache.dart';
|
||||||
import 'social_service.dart';
|
import 'social_connection.dart';
|
||||||
import 'social_settings.dart';
|
import 'social_service.dart' show SocialSession;
|
||||||
import 'unread_service.dart';
|
import 'unread_service.dart';
|
||||||
|
|
||||||
/// App-wide inbox listener for private messages (NIP-17).
|
/// App-wide inbox listener for private messages (NIP-17).
|
||||||
///
|
///
|
||||||
/// Without this, a message only arrived while its specific chat screen was
|
/// Without this, a message only arrived while its specific chat screen was open
|
||||||
/// open — so a first message from a new peer was invisible (nothing in local
|
/// — so a first message from a new peer was invisible. This keeps one inbox
|
||||||
/// storage, so nothing in the inbox list, so you never opened the chat, so you
|
/// subscription over the SHARED [SocialConnection] for the whole app while
|
||||||
/// never subscribed). This keeps ONE long-lived subscription for the whole app
|
/// online: it persists every incoming message to the [MessageStore], updates
|
||||||
/// while online: it persists every incoming message to the [MessageStore] and
|
/// unread counts, fires an OS notification, and fires [changes] so the inbox
|
||||||
/// fires [changes] so the inbox list refreshes. Reconnects when the network
|
/// list refreshes. It re-subscribes automatically each time the connection
|
||||||
/// returns; degrades to nothing offline (local-first).
|
/// 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.
|
/// Foreground only — background/push delivery is a later concern.
|
||||||
class InboxService {
|
class InboxService {
|
||||||
InboxService({
|
InboxService({
|
||||||
required SocialService social,
|
required SocialConnection connection,
|
||||||
required SocialSettings settings,
|
required String selfPubkey,
|
||||||
required MessageStore store,
|
required MessageStore store,
|
||||||
ProfileCache? profileCache,
|
ProfileCache? profileCache,
|
||||||
UnreadService? unread,
|
UnreadService? unread,
|
||||||
NotificationService? notifications,
|
NotificationService? notifications,
|
||||||
}) : _social = social,
|
}) : _connection = connection,
|
||||||
_settings = settings,
|
_selfPubkey = selfPubkey,
|
||||||
_store = store,
|
_store = store,
|
||||||
_profileCache = profileCache,
|
_profileCache = profileCache,
|
||||||
_unread = unread,
|
_unread = unread,
|
||||||
_notifications = notifications;
|
_notifications = notifications;
|
||||||
|
|
||||||
final SocialService _social;
|
final SocialConnection _connection;
|
||||||
final SocialSettings _settings;
|
final String _selfPubkey;
|
||||||
final MessageStore _store;
|
final MessageStore _store;
|
||||||
final ProfileCache? _profileCache;
|
final ProfileCache? _profileCache;
|
||||||
final UnreadService? _unread;
|
final UnreadService? _unread;
|
||||||
final NotificationService? _notifications;
|
final NotificationService? _notifications;
|
||||||
|
|
||||||
final _changes = StreamController<void>.broadcast();
|
final _changes = StreamController<void>.broadcast();
|
||||||
SocialSession? _session;
|
StreamSubscription<SocialSession?>? _sessionsSub;
|
||||||
StreamSubscription<PrivateMessage>? _inboxSub;
|
StreamSubscription<PrivateMessage>? _inboxSub;
|
||||||
StreamSubscription<List<ConnectivityResult>>? _connSub;
|
SocialSession? _session;
|
||||||
bool _started = false;
|
bool _started = false;
|
||||||
bool _connecting = false;
|
|
||||||
|
|
||||||
/// Fires (with no payload) after a NEW message is persisted — the inbox list
|
/// Fires (no payload) after a NEW message is persisted — the inbox list
|
||||||
/// listens to this to reload. Broadcast, so several screens can listen.
|
/// listens to reload. Broadcast, so several screens can listen.
|
||||||
Stream<void> get changes => _changes.stream;
|
Stream<void> get changes => _changes.stream;
|
||||||
|
|
||||||
/// Begins listening. Idempotent. Connects now if online, and (re)connects
|
/// Begins listening. Subscribe to the connection's sessions BEFORE the
|
||||||
/// whenever the network returns. Safe to call once at app start.
|
/// connection starts connecting, so the first session is caught too.
|
||||||
Future<void> start() async {
|
void start() {
|
||||||
if (_started) return;
|
if (_started) return;
|
||||||
_started = true;
|
_started = true;
|
||||||
try {
|
_sessionsSub = _connection.sessions.listen(_onSession);
|
||||||
_connSub = Connectivity().onConnectivityChanged.listen((results) {
|
// In case the connection is already up (a screen connected first).
|
||||||
final offline =
|
final current = _connection.current;
|
||||||
results.isEmpty || results.every((r) => r == ConnectivityResult.none);
|
if (current != null) _onSession(current);
|
||||||
if (offline) {
|
|
||||||
_dropSession();
|
|
||||||
} else {
|
|
||||||
unawaited(_connect());
|
|
||||||
}
|
|
||||||
});
|
|
||||||
} catch (_) {
|
|
||||||
// Platform without connectivity support — just try once below.
|
|
||||||
}
|
|
||||||
await _connect();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Opens a session and subscribes to the inbox, if not already connected.
|
/// (Re)binds the inbox subscription to [session] — the connection handing us a
|
||||||
/// Guarded so overlapping connectivity events can't open two sessions.
|
/// fresh session (reconnect) or null (dropped).
|
||||||
Future<void> _connect() async {
|
void _onSession(SocialSession? session) {
|
||||||
if (_session != null || _connecting) return; // already (being) connected
|
if (identical(session, _session) && _inboxSub != null) return;
|
||||||
_connecting = true;
|
unawaited(_inboxSub?.cancel());
|
||||||
try {
|
_inboxSub = null;
|
||||||
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;
|
_session = session;
|
||||||
|
if (session != null) {
|
||||||
_inboxSub = session.messages.inbox().listen(
|
_inboxSub = session.messages.inbox().listen(
|
||||||
ingest,
|
ingest,
|
||||||
onError: (_) => _dropSession(), // relay dropped — retry on next change
|
onError: (_) {}, // drop handled by the connection's reconnect
|
||||||
);
|
);
|
||||||
} catch (_) {
|
|
||||||
// No relay reachable — a later connectivity change retries.
|
|
||||||
} finally {
|
|
||||||
_connecting = false;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Persists one incoming [message] (deduped) and, when it's new, fires
|
/// Persists one incoming [message] (deduped) and, when new, fires [changes],
|
||||||
/// [changes] and best-effort caches the sender's display name. Separated from
|
/// updates unread, notifies, and best-effort caches the sender's name. A
|
||||||
/// the transport so it can be unit-tested without a relay.
|
/// testable seam (no relay).
|
||||||
@visibleForTesting
|
@visibleForTesting
|
||||||
Future<void> ingest(PrivateMessage message) async {
|
Future<void> ingest(PrivateMessage message) async {
|
||||||
final stored = await _store.append(message.fromPubkey, message);
|
final stored = await _store.append(message.fromPubkey, message);
|
||||||
|
|
@ -113,9 +92,8 @@ class InboxService {
|
||||||
|
|
||||||
final peer = message.fromPubkey;
|
final peer = message.fromPubkey;
|
||||||
// Defensive: never badge or notify for your own message. NIP-17 doesn't
|
// 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
|
// loop a sent gift wrap back to its author; the guard keeps the seam honest.
|
||||||
// guard just keeps the seam honest.
|
if (peer != _selfPubkey) {
|
||||||
if (peer != _social.publicKeyHex) {
|
|
||||||
await _unread?.onMessageReceived(peer, message);
|
await _unread?.onMessageReceived(peer, message);
|
||||||
await _maybeNotify(peer);
|
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 {
|
Future<void> stop() async {
|
||||||
_started = false;
|
_started = false;
|
||||||
await _connSub?.cancel();
|
await _sessionsSub?.cancel();
|
||||||
_connSub = null;
|
_sessionsSub = null;
|
||||||
await _inboxSub?.cancel();
|
await _inboxSub?.cancel();
|
||||||
_inboxSub = null;
|
_inboxSub = null;
|
||||||
await _session?.close();
|
|
||||||
_session = null;
|
_session = null;
|
||||||
if (!_changes.isClosed) await _changes.close();
|
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 'package:flutter_bloc/flutter_bloc.dart';
|
||||||
|
|
||||||
import '../services/message_store.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
|
/// A 1:1 conversation with one peer. Holds the running message list plus send
|
||||||
/// status. Transport-agnostic — depends on [MessageTransport], not the relay.
|
/// status. Transport-agnostic — depends on [MessageTransport], not the relay.
|
||||||
|
|
@ -134,29 +132,3 @@ class MessagesCubit extends Cubit<ChatState> {
|
||||||
return super.close();
|
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_mapper.dart';
|
||||||
import '../services/offer_outbox.dart';
|
import '../services/offer_outbox.dart';
|
||||||
import '../services/offer_thumbnail.dart';
|
import '../services/offer_thumbnail.dart';
|
||||||
import '../services/social_service.dart';
|
import '../services/social_connection.dart';
|
||||||
import '../services/social_settings.dart';
|
|
||||||
|
|
||||||
/// State of the offer discovery/publish screen. Transport-agnostic — it holds
|
/// State of the offer discovery/publish screen. Transport-agnostic — it holds
|
||||||
/// only what the UI shows, never a relay handle.
|
/// 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
|
/// Opens an [OffersCubit] over the SHARED [SocialConnection], or an offline one
|
||||||
/// degrades gracefully. Local-first: when no relay is configured (or connecting
|
/// that degrades gracefully. Local-first: when the connection isn't up (no relay
|
||||||
/// fails), the cubit is created with a null transport and the screen still opens.
|
/// 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(
|
Future<OffersCubit> createOffersCubit(
|
||||||
SocialService social,
|
SocialConnection connection, {
|
||||||
SocialSettings settings, {
|
|
||||||
VarietyRepository? repository,
|
VarietyRepository? repository,
|
||||||
}) async {
|
}) async {
|
||||||
final relays = await settings.relayUrls();
|
final session = await connection.session();
|
||||||
if (relays.isEmpty) return OffersCubit(null);
|
|
||||||
try {
|
|
||||||
final session = await social.openSession(relays);
|
|
||||||
return OffersCubit(
|
return OffersCubit(
|
||||||
session.offers,
|
session?.offers,
|
||||||
coverPhoto: repository?.coverPhotoFor,
|
coverPhoto: repository?.coverPhotoFor,
|
||||||
thumbnail: offerThumbnailDataUri,
|
thumbnail: offerThumbnailDataUri,
|
||||||
onDispose: session.close,
|
|
||||||
);
|
);
|
||||||
} catch (_) {
|
|
||||||
return OffersCubit(null); // offline / no relay reachable
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Publishes any queued (offline-parked) lots now that we're online, then clears
|
/// 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:equatable/equatable.dart';
|
||||||
import 'package:flutter_bloc/flutter_bloc.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.
|
/// Where a peer stands, from strongest to weakest signal.
|
||||||
enum TrustTier {
|
enum TrustTier {
|
||||||
/// Passes the full Duniter membership rule (sigQty certifications from members,
|
/// Passes the full Duniter membership rule (sigQty certifications from members,
|
||||||
|
|
@ -189,29 +186,3 @@ class TrustCubit extends Cubit<TrustState> {
|
||||||
return super.close();
|
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/inbox_service.dart';
|
||||||
import '../services/message_store.dart';
|
import '../services/message_store.dart';
|
||||||
import '../services/profile_cache.dart';
|
import '../services/profile_cache.dart';
|
||||||
import '../services/social_service.dart';
|
import '../services/social_connection.dart';
|
||||||
import '../services/social_settings.dart';
|
|
||||||
import 'theme.dart';
|
import 'theme.dart';
|
||||||
import 'unread_badge.dart';
|
import 'unread_badge.dart';
|
||||||
|
|
||||||
|
|
@ -17,16 +16,16 @@ import 'unread_badge.dart';
|
||||||
class ChatListScreen extends StatefulWidget {
|
class ChatListScreen extends StatefulWidget {
|
||||||
const ChatListScreen({
|
const ChatListScreen({
|
||||||
required this.store,
|
required this.store,
|
||||||
this.social,
|
this.connection,
|
||||||
this.settings,
|
|
||||||
this.profileCache,
|
this.profileCache,
|
||||||
this.inbox,
|
this.inbox,
|
||||||
super.key,
|
super.key,
|
||||||
});
|
});
|
||||||
|
|
||||||
final MessageStore store;
|
final MessageStore store;
|
||||||
final SocialService? social;
|
|
||||||
final SocialSettings? settings;
|
/// The shared relay connection, for resolving peers' published names.
|
||||||
|
final SocialConnection? connection;
|
||||||
final ProfileCache? profileCache;
|
final ProfileCache? profileCache;
|
||||||
|
|
||||||
/// App-wide inbox listener; the list reloads whenever it reports a change.
|
/// App-wide inbox listener; the list reloads whenever it reports a change.
|
||||||
|
|
@ -69,20 +68,18 @@ class _ChatListScreenState extends State<ChatListScreen> {
|
||||||
unawaited(_resolveMissingNames(items));
|
unawaited(_resolveMissingNames(items));
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Fetches published names for peers we don't have cached yet, over one
|
/// Fetches published names for peers we don't have cached yet, over the shared
|
||||||
/// session. Best effort; the list already shows short keys meanwhile.
|
/// connection. Best effort; the list already shows short keys meanwhile.
|
||||||
Future<void> _resolveMissingNames(List<ChatSummary> items) async {
|
Future<void> _resolveMissingNames(List<ChatSummary> items) async {
|
||||||
final social = widget.social;
|
final connection = widget.connection;
|
||||||
final settings = widget.settings;
|
|
||||||
final cache = widget.profileCache;
|
final cache = widget.profileCache;
|
||||||
if (social == null || settings == null || cache == null) return;
|
if (connection == null || cache == null) return;
|
||||||
final missing =
|
final missing =
|
||||||
items.map((c) => c.peerPubkey).where((p) => !_names.containsKey(p));
|
items.map((c) => c.peerPubkey).where((p) => !_names.containsKey(p));
|
||||||
if (missing.isEmpty) return;
|
if (missing.isEmpty) return;
|
||||||
final relays = await settings.relayUrls();
|
final session = await connection.session();
|
||||||
if (relays.isEmpty) return;
|
if (session == null) return;
|
||||||
try {
|
try {
|
||||||
final session = await social.openSession(relays);
|
|
||||||
for (final peer in missing) {
|
for (final peer in missing) {
|
||||||
final profile = await session.profile.fetch(peer);
|
final profile = await session.profile.fetch(peer);
|
||||||
if (profile != null && profile.name.isNotEmpty) {
|
if (profile != null && profile.name.isNotEmpty) {
|
||||||
|
|
@ -90,7 +87,6 @@ class _ChatListScreenState extends State<ChatListScreen> {
|
||||||
if (mounted) setState(() => _names[peer] = profile.name);
|
if (mounted) setState(() => _names[peer] = profile.name);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
await session.close();
|
|
||||||
} catch (_) {
|
} catch (_) {
|
||||||
// offline / unreachable — short keys stay.
|
// offline / unreachable — short keys stay.
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -10,8 +10,8 @@ import '../di/injector.dart';
|
||||||
import '../i18n/strings.g.dart';
|
import '../i18n/strings.g.dart';
|
||||||
import '../services/message_store.dart';
|
import '../services/message_store.dart';
|
||||||
import '../services/profile_cache.dart';
|
import '../services/profile_cache.dart';
|
||||||
|
import '../services/social_connection.dart';
|
||||||
import '../services/social_service.dart';
|
import '../services/social_service.dart';
|
||||||
import '../services/social_settings.dart';
|
|
||||||
import '../services/trust_referents.dart';
|
import '../services/trust_referents.dart';
|
||||||
import '../services/unread_service.dart';
|
import '../services/unread_service.dart';
|
||||||
import '../services/wot_settings.dart';
|
import '../services/wot_settings.dart';
|
||||||
|
|
@ -25,7 +25,7 @@ import 'theme.dart';
|
||||||
class ChatScreen extends StatefulWidget {
|
class ChatScreen extends StatefulWidget {
|
||||||
const ChatScreen({
|
const ChatScreen({
|
||||||
required this.social,
|
required this.social,
|
||||||
required this.settings,
|
required this.connection,
|
||||||
required this.peerPubkey,
|
required this.peerPubkey,
|
||||||
this.messageStore,
|
this.messageStore,
|
||||||
this.profileCache,
|
this.profileCache,
|
||||||
|
|
@ -35,7 +35,9 @@ class ChatScreen extends StatefulWidget {
|
||||||
});
|
});
|
||||||
|
|
||||||
final SocialService social;
|
final SocialService social;
|
||||||
final SocialSettings settings;
|
|
||||||
|
/// The shared relay connection (one per identity), carrying messaging + trust.
|
||||||
|
final SocialConnection connection;
|
||||||
final String peerPubkey;
|
final String peerPubkey;
|
||||||
|
|
||||||
/// Optional persistence for chat history (keystore-backed); null in tests.
|
/// Optional persistence for chat history (keystore-backed); null in tests.
|
||||||
|
|
@ -53,7 +55,6 @@ class ChatScreen extends StatefulWidget {
|
||||||
}
|
}
|
||||||
|
|
||||||
class _ChatScreenState extends State<ChatScreen> {
|
class _ChatScreenState extends State<ChatScreen> {
|
||||||
SocialSession? _session;
|
|
||||||
MessagesCubit? _messages;
|
MessagesCubit? _messages;
|
||||||
TrustCubit? _trust;
|
TrustCubit? _trust;
|
||||||
bool _loading = true;
|
bool _loading = true;
|
||||||
|
|
@ -79,25 +80,14 @@ class _ChatScreenState extends State<ChatScreen> {
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _init() async {
|
Future<void> _init() async {
|
||||||
// One session for this chat carries both messaging and trust (the shared-
|
// The shared connection carries both messaging and trust. Offline (not
|
||||||
// connection shape). Offline (no relays / unreachable) → null transports.
|
// connected / unreachable) → null transports and the screen degrades.
|
||||||
final relays = await widget.settings.relayUrls();
|
final session = await widget.connection.session();
|
||||||
SocialSession? session;
|
|
||||||
if (relays.isNotEmpty) {
|
|
||||||
try {
|
|
||||||
session = await widget.social.openSession(relays);
|
|
||||||
} catch (_) {
|
|
||||||
session = null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
final cachedName = await widget.profileCache?.name(widget.peerPubkey);
|
final cachedName = await widget.profileCache?.name(widget.peerPubkey);
|
||||||
final referents = await widget.trustReferents?.all() ?? const <String>{};
|
final referents = await widget.trustReferents?.all() ?? const <String>{};
|
||||||
final wotParams =
|
final wotParams =
|
||||||
await widget.wotSettings?.params() ?? WotParams.duniter;
|
await widget.wotSettings?.params() ?? WotParams.duniter;
|
||||||
if (!mounted) {
|
if (!mounted) return; // shared session is owned by the connection, not us
|
||||||
await session?.close();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
final self = widget.social.publicKeyHex;
|
final self = widget.social.publicKeyHex;
|
||||||
final messages = MessagesCubit(
|
final messages = MessagesCubit(
|
||||||
session?.messages,
|
session?.messages,
|
||||||
|
|
@ -114,7 +104,6 @@ class _ChatScreenState extends State<ChatScreen> {
|
||||||
);
|
);
|
||||||
unawaited(trust.load());
|
unawaited(trust.load());
|
||||||
setState(() {
|
setState(() {
|
||||||
_session = session;
|
|
||||||
_messages = messages;
|
_messages = messages;
|
||||||
_trust = trust;
|
_trust = trust;
|
||||||
_peerName = cachedName;
|
_peerName = cachedName;
|
||||||
|
|
@ -184,7 +173,7 @@ class _ChatScreenState extends State<ChatScreen> {
|
||||||
unawaited(_unread?.markRead(widget.peerPubkey));
|
unawaited(_unread?.markRead(widget.peerPubkey));
|
||||||
_messages?.close();
|
_messages?.close();
|
||||||
_trust?.close();
|
_trust?.close();
|
||||||
_session?.close();
|
// The session belongs to the shared connection — don't close it here.
|
||||||
_input.dispose();
|
_input.dispose();
|
||||||
super.dispose();
|
super.dispose();
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -7,8 +7,7 @@ import 'package:go_router/go_router.dart';
|
||||||
|
|
||||||
import '../i18n/strings.g.dart';
|
import '../i18n/strings.g.dart';
|
||||||
import '../services/profile_cache.dart';
|
import '../services/profile_cache.dart';
|
||||||
import '../services/social_service.dart';
|
import '../services/social_connection.dart';
|
||||||
import '../services/social_settings.dart';
|
|
||||||
import 'market_widgets.dart';
|
import 'market_widgets.dart';
|
||||||
import 'theme.dart';
|
import 'theme.dart';
|
||||||
|
|
||||||
|
|
@ -20,16 +19,16 @@ import 'theme.dart';
|
||||||
class MarketOfferDetailScreen extends StatefulWidget {
|
class MarketOfferDetailScreen extends StatefulWidget {
|
||||||
const MarketOfferDetailScreen({
|
const MarketOfferDetailScreen({
|
||||||
required this.offer,
|
required this.offer,
|
||||||
required this.social,
|
required this.connection,
|
||||||
required this.settings,
|
|
||||||
this.mine = false,
|
this.mine = false,
|
||||||
this.profileCache,
|
this.profileCache,
|
||||||
super.key,
|
super.key,
|
||||||
});
|
});
|
||||||
|
|
||||||
final Offer offer;
|
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).
|
/// Whether this is the current user's own listing (hide the message button).
|
||||||
final bool mine;
|
final bool mine;
|
||||||
|
|
@ -43,7 +42,6 @@ class MarketOfferDetailScreen extends StatefulWidget {
|
||||||
}
|
}
|
||||||
|
|
||||||
class _MarketOfferDetailScreenState extends State<MarketOfferDetailScreen> {
|
class _MarketOfferDetailScreenState extends State<MarketOfferDetailScreen> {
|
||||||
SocialSession? _session;
|
|
||||||
String? _sellerName;
|
String? _sellerName;
|
||||||
String? _sellerAbout;
|
String? _sellerAbout;
|
||||||
String? _sellerG1;
|
String? _sellerG1;
|
||||||
|
|
@ -61,20 +59,8 @@ class _MarketOfferDetailScreenState extends State<MarketOfferDetailScreen> {
|
||||||
final cachedName = await widget.profileCache?.name(widget.offer.authorPubkeyHex);
|
final cachedName = await widget.profileCache?.name(widget.offer.authorPubkeyHex);
|
||||||
if (mounted && cachedName != null) setState(() => _sellerName = cachedName);
|
if (mounted && cachedName != null) setState(() => _sellerName = cachedName);
|
||||||
|
|
||||||
final relays = await widget.settings.relayUrls();
|
final session = await widget.connection.session();
|
||||||
SocialSession? session;
|
if (!mounted) return;
|
||||||
if (relays.isNotEmpty) {
|
|
||||||
try {
|
|
||||||
session = await widget.social.openSession(relays);
|
|
||||||
} catch (_) {
|
|
||||||
session = null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (!mounted) {
|
|
||||||
await session?.close();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
_session = session;
|
|
||||||
if (session == null) {
|
if (session == null) {
|
||||||
setState(() => _profileLoading = false);
|
setState(() => _profileLoading = false);
|
||||||
return;
|
return;
|
||||||
|
|
@ -100,11 +86,7 @@ class _MarketOfferDetailScreenState extends State<MarketOfferDetailScreen> {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
// No dispose of the session — it belongs to the shared connection.
|
||||||
void dispose() {
|
|
||||||
_session?.close();
|
|
||||||
super.dispose();
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<void> _copyId() async {
|
Future<void> _copyId() async {
|
||||||
final t = context.t;
|
final t = context.t;
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,7 @@ import '../i18n/strings.g.dart';
|
||||||
import '../services/coarse_location.dart';
|
import '../services/coarse_location.dart';
|
||||||
import '../services/discovery_area.dart';
|
import '../services/discovery_area.dart';
|
||||||
import '../services/offer_outbox.dart';
|
import '../services/offer_outbox.dart';
|
||||||
|
import '../services/social_connection.dart';
|
||||||
import '../services/social_service.dart';
|
import '../services/social_service.dart';
|
||||||
import '../services/social_settings.dart';
|
import '../services/social_settings.dart';
|
||||||
import '../state/offers_cubit.dart';
|
import '../state/offers_cubit.dart';
|
||||||
|
|
@ -21,6 +22,7 @@ class MarketScreen extends StatefulWidget {
|
||||||
const MarketScreen({
|
const MarketScreen({
|
||||||
required this.social,
|
required this.social,
|
||||||
required this.settings,
|
required this.settings,
|
||||||
|
required this.connection,
|
||||||
this.location,
|
this.location,
|
||||||
this.outbox,
|
this.outbox,
|
||||||
super.key,
|
super.key,
|
||||||
|
|
@ -29,6 +31,9 @@ class MarketScreen extends StatefulWidget {
|
||||||
final SocialService social;
|
final SocialService social;
|
||||||
final SocialSettings settings;
|
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
|
/// Optional device-location source for the "use my location" shortcut; when
|
||||||
/// null (tests, or a platform without it) the shortcut is hidden.
|
/// null (tests, or a platform without it) the shortcut is hidden.
|
||||||
final CoarseLocationProvider? location;
|
final CoarseLocationProvider? location;
|
||||||
|
|
@ -58,11 +63,8 @@ class _MarketScreenState extends State<MarketScreen> {
|
||||||
final repo =
|
final repo =
|
||||||
widget.outbox != null ? context.read<VarietyRepository>() : null;
|
widget.outbox != null ? context.read<VarietyRepository>() : null;
|
||||||
setState(() => _loading = true);
|
setState(() => _loading = true);
|
||||||
final cubit = await createOffersCubit(
|
final cubit =
|
||||||
widget.social,
|
await createOffersCubit(widget.connection, repository: repo);
|
||||||
widget.settings,
|
|
||||||
repository: repo,
|
|
||||||
);
|
|
||||||
final area = await widget.settings.areaGeohash();
|
final area = await widget.settings.areaGeohash();
|
||||||
if (!mounted) {
|
if (!mounted) {
|
||||||
await cubit.close();
|
await cubit.close();
|
||||||
|
|
|
||||||
|
|
@ -7,8 +7,8 @@ import '../i18n/strings.g.dart';
|
||||||
import '../services/profile_cache.dart' show shortPubkey;
|
import '../services/profile_cache.dart' show shortPubkey;
|
||||||
import '../services/social_account_store.dart';
|
import '../services/social_account_store.dart';
|
||||||
import '../services/profile_store.dart';
|
import '../services/profile_store.dart';
|
||||||
|
import '../services/social_connection.dart';
|
||||||
import '../services/social_service.dart';
|
import '../services/social_service.dart';
|
||||||
import '../services/social_settings.dart';
|
|
||||||
import 'qr_view.dart';
|
import 'qr_view.dart';
|
||||||
import 'restart_widget.dart';
|
import 'restart_widget.dart';
|
||||||
import 'theme.dart';
|
import 'theme.dart';
|
||||||
|
|
@ -19,7 +19,7 @@ import 'theme.dart';
|
||||||
class ProfileScreen extends StatefulWidget {
|
class ProfileScreen extends StatefulWidget {
|
||||||
const ProfileScreen({
|
const ProfileScreen({
|
||||||
required this.social,
|
required this.social,
|
||||||
required this.settings,
|
required this.connection,
|
||||||
required this.profileStore,
|
required this.profileStore,
|
||||||
required this.accounts,
|
required this.accounts,
|
||||||
this.trustNetworkEnabled = false,
|
this.trustNetworkEnabled = false,
|
||||||
|
|
@ -27,7 +27,9 @@ class ProfileScreen extends StatefulWidget {
|
||||||
});
|
});
|
||||||
|
|
||||||
final SocialService social;
|
final SocialService social;
|
||||||
final SocialSettings settings;
|
|
||||||
|
/// The shared relay connection, for publishing your profile.
|
||||||
|
final SocialConnection connection;
|
||||||
final ProfileStore profileStore;
|
final ProfileStore profileStore;
|
||||||
final SocialAccountStore accounts;
|
final SocialAccountStore accounts;
|
||||||
|
|
||||||
|
|
@ -121,21 +123,17 @@ class _ProfileScreenState extends State<ProfileScreen> {
|
||||||
await widget.profileStore
|
await widget.profileStore
|
||||||
.save(name: _name.text, about: _about.text, g1: _g1.text);
|
.save(name: _name.text, about: _about.text, g1: _g1.text);
|
||||||
|
|
||||||
// Best-effort publish to the network so peers see the name.
|
// Best-effort publish over the shared connection so peers see the name.
|
||||||
final relays = await widget.settings.relayUrls();
|
|
||||||
if (relays.isNotEmpty) {
|
|
||||||
try {
|
try {
|
||||||
final session = await widget.social.openSession(relays);
|
final session = await widget.connection.session();
|
||||||
await session.profile.publish(
|
await session?.profile.publish(
|
||||||
name: _name.text.trim(),
|
name: _name.text.trim(),
|
||||||
about: _about.text.trim(),
|
about: _about.text.trim(),
|
||||||
g1: _g1.text.trim(),
|
g1: _g1.text.trim(),
|
||||||
);
|
);
|
||||||
await session.close();
|
|
||||||
} catch (_) {
|
} catch (_) {
|
||||||
// offline / unreachable — the local copy is saved regardless.
|
// offline / unreachable — the local copy is saved regardless.
|
||||||
}
|
}
|
||||||
}
|
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
setState(() => _saving = false);
|
setState(() => _saving = false);
|
||||||
messenger.showSnackBar(SnackBar(content: Text(t.profile.saved)));
|
messenger.showSnackBar(SnackBar(content: Text(t.profile.saved)));
|
||||||
|
|
|
||||||
|
|
@ -90,6 +90,8 @@ dev_dependencies:
|
||||||
drift_dev: ^2.28.0
|
drift_dev: ^2.28.0
|
||||||
slang_build_runner: ^4.7.0
|
slang_build_runner: ^4.7.0
|
||||||
mocktail: ^1.0.4
|
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_launcher_icons: ^0.14.4
|
||||||
flutter_native_splash: ^2.4.7
|
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/inbox_service.dart';
|
||||||
import 'package:tane/services/message_store.dart';
|
import 'package:tane/services/message_store.dart';
|
||||||
import 'package:tane/services/notification_service.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_service.dart';
|
||||||
import 'package:tane/services/social_settings.dart';
|
import 'package:tane/services/social_settings.dart';
|
||||||
import 'package:tane/services/unread_service.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,
|
/// The app-wide inbox listener persists incoming messages, updates unread and
|
||||||
/// so the inbox list refreshes even when the specific chat isn't open. Driven
|
/// notifies — even when the specific chat isn't open. Driven through the
|
||||||
/// through the [InboxService.ingest] seam so no relay/network is involved.
|
/// [InboxService.ingest] seam so no relay/network is involved.
|
||||||
void main() {
|
void main() {
|
||||||
const seedHex =
|
const seedHex =
|
||||||
'000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f';
|
'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 MessageStore store;
|
||||||
late InboxService inbox;
|
late InboxService inbox;
|
||||||
|
|
||||||
setUp(() async {
|
setUp(() async {
|
||||||
store = MessageStore(InMemorySecretStore());
|
store = MessageStore(InMemorySecretStore());
|
||||||
inbox = InboxService(
|
inbox = InboxService(
|
||||||
social: await SocialService.fromRootSeedHex(seedHex),
|
connection: await offlineConnection(),
|
||||||
settings: SocialSettings(InMemorySecretStore()),
|
selfPubkey: 'me',
|
||||||
store: store,
|
store: store,
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
@ -75,16 +84,14 @@ void main() {
|
||||||
group('unread + notification hooks', () {
|
group('unread + notification hooks', () {
|
||||||
late UnreadService unread;
|
late UnreadService unread;
|
||||||
late _RecordingNotifications notifications;
|
late _RecordingNotifications notifications;
|
||||||
late String myPubkey;
|
const myPubkey = 'me';
|
||||||
|
|
||||||
setUp(() async {
|
setUp(() async {
|
||||||
final social = await SocialService.fromRootSeedHex(seedHex);
|
|
||||||
myPubkey = social.publicKeyHex;
|
|
||||||
unread = UnreadService(store, InMemorySecretStore());
|
unread = UnreadService(store, InMemorySecretStore());
|
||||||
notifications = _RecordingNotifications();
|
notifications = _RecordingNotifications();
|
||||||
inbox = InboxService(
|
inbox = InboxService(
|
||||||
social: social,
|
connection: await offlineConnection(),
|
||||||
settings: SocialSettings(InMemorySecretStore()),
|
selfPubkey: myPubkey,
|
||||||
store: store,
|
store: store,
|
||||||
unread: unread,
|
unread: unread,
|
||||||
notifications: notifications,
|
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_localizations/flutter_localizations.dart';
|
||||||
import 'package:flutter_test/flutter_test.dart';
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
import 'package:tane/i18n/strings.g.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_service.dart';
|
||||||
import 'package:tane/services/social_settings.dart';
|
import 'package:tane/services/social_settings.dart';
|
||||||
import 'package:tane/ui/chat_screen.dart';
|
import 'package:tane/ui/chat_screen.dart';
|
||||||
|
|
@ -14,6 +15,7 @@ void main() {
|
||||||
final social = await SocialService.fromRootSeedHex('00' * 32);
|
final social = await SocialService.fromRootSeedHex('00' * 32);
|
||||||
final settings = SocialSettings(InMemorySecretStore());
|
final settings = SocialSettings(InMemorySecretStore());
|
||||||
await settings.setRelayUrls(const []); // offline: don't hit the network
|
await settings.setRelayUrls(const []); // offline: don't hit the network
|
||||||
|
final connection = SocialConnection(social: social, settings: settings);
|
||||||
LocaleSettings.setLocaleSync(AppLocale.en);
|
LocaleSettings.setLocaleSync(AppLocale.en);
|
||||||
|
|
||||||
await tester.pumpWidget(
|
await tester.pumpWidget(
|
||||||
|
|
@ -28,7 +30,7 @@ void main() {
|
||||||
],
|
],
|
||||||
home: ChatScreen(
|
home: ChatScreen(
|
||||||
social: social,
|
social: social,
|
||||||
settings: settings,
|
connection: connection,
|
||||||
peerPubkey: 'ab' * 32,
|
peerPubkey: 'ab' * 32,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@ import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_localizations/flutter_localizations.dart';
|
import 'package:flutter_localizations/flutter_localizations.dart';
|
||||||
import 'package:flutter_test/flutter_test.dart';
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
import 'package:tane/i18n/strings.g.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_service.dart';
|
||||||
import 'package:tane/services/social_settings.dart';
|
import 'package:tane/services/social_settings.dart';
|
||||||
import 'package:tane/ui/market_offer_detail_screen.dart';
|
import 'package:tane/ui/market_offer_detail_screen.dart';
|
||||||
|
|
@ -43,11 +44,11 @@ void main() {
|
||||||
final social = await SocialService.fromRootSeedHex('00' * 32);
|
final social = await SocialService.fromRootSeedHex('00' * 32);
|
||||||
final settings = SocialSettings(InMemorySecretStore());
|
final settings = SocialSettings(InMemorySecretStore());
|
||||||
await settings.setRelayUrls(const []); // offline: no network in the test
|
await settings.setRelayUrls(const []); // offline: no network in the test
|
||||||
|
final connection = SocialConnection(social: social, settings: settings);
|
||||||
|
|
||||||
await tester.pumpWidget(_wrap(MarketOfferDetailScreen(
|
await tester.pumpWidget(_wrap(MarketOfferDetailScreen(
|
||||||
offer: _offer(organic: true),
|
offer: _offer(organic: true),
|
||||||
social: social,
|
connection: connection,
|
||||||
settings: settings,
|
|
||||||
)));
|
)));
|
||||||
await tester.pumpAndSettle();
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
|
@ -61,11 +62,11 @@ void main() {
|
||||||
final social = await SocialService.fromRootSeedHex('00' * 32);
|
final social = await SocialService.fromRootSeedHex('00' * 32);
|
||||||
final settings = SocialSettings(InMemorySecretStore());
|
final settings = SocialSettings(InMemorySecretStore());
|
||||||
await settings.setRelayUrls(const []);
|
await settings.setRelayUrls(const []);
|
||||||
|
final connection = SocialConnection(social: social, settings: settings);
|
||||||
|
|
||||||
await tester.pumpWidget(_wrap(MarketOfferDetailScreen(
|
await tester.pumpWidget(_wrap(MarketOfferDetailScreen(
|
||||||
offer: _offer(),
|
offer: _offer(),
|
||||||
social: social,
|
connection: connection,
|
||||||
settings: settings,
|
|
||||||
mine: true,
|
mine: true,
|
||||||
)));
|
)));
|
||||||
await tester.pumpAndSettle();
|
await tester.pumpAndSettle();
|
||||||
|
|
@ -77,11 +78,11 @@ void main() {
|
||||||
final social = await SocialService.fromRootSeedHex('00' * 32);
|
final social = await SocialService.fromRootSeedHex('00' * 32);
|
||||||
final settings = SocialSettings(InMemorySecretStore());
|
final settings = SocialSettings(InMemorySecretStore());
|
||||||
await settings.setRelayUrls(const []);
|
await settings.setRelayUrls(const []);
|
||||||
|
final connection = SocialConnection(social: social, settings: settings);
|
||||||
|
|
||||||
await tester.pumpWidget(_wrap(MarketOfferDetailScreen(
|
await tester.pumpWidget(_wrap(MarketOfferDetailScreen(
|
||||||
offer: _offer(imageUrl: 'https://media.example/abc.jpg'),
|
offer: _offer(imageUrl: 'https://media.example/abc.jpg'),
|
||||||
social: social,
|
connection: connection,
|
||||||
settings: settings,
|
|
||||||
)));
|
)));
|
||||||
await tester.pumpAndSettle();
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
|
@ -92,11 +93,11 @@ void main() {
|
||||||
final social = await SocialService.fromRootSeedHex('00' * 32);
|
final social = await SocialService.fromRootSeedHex('00' * 32);
|
||||||
final settings = SocialSettings(InMemorySecretStore());
|
final settings = SocialSettings(InMemorySecretStore());
|
||||||
await settings.setRelayUrls(const []);
|
await settings.setRelayUrls(const []);
|
||||||
|
final connection = SocialConnection(social: social, settings: settings);
|
||||||
|
|
||||||
await tester.pumpWidget(_wrap(MarketOfferDetailScreen(
|
await tester.pumpWidget(_wrap(MarketOfferDetailScreen(
|
||||||
offer: _offer(),
|
offer: _offer(),
|
||||||
social: social,
|
connection: connection,
|
||||||
settings: settings,
|
|
||||||
)));
|
)));
|
||||||
await tester.pumpAndSettle();
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,7 @@ import 'package:flutter_localizations/flutter_localizations.dart';
|
||||||
import 'package:flutter_test/flutter_test.dart';
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
import 'package:tane/i18n/strings.g.dart';
|
import 'package:tane/i18n/strings.g.dart';
|
||||||
import 'package:tane/services/coarse_location.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_service.dart';
|
||||||
import 'package:tane/services/social_settings.dart';
|
import 'package:tane/services/social_settings.dart';
|
||||||
import 'package:tane/state/offers_cubit.dart';
|
import 'package:tane/state/offers_cubit.dart';
|
||||||
|
|
@ -60,7 +61,12 @@ Widget _wrapMarket(SocialService social, SocialSettings settings,
|
||||||
GlobalWidgetsLocalizations.delegate,
|
GlobalWidgetsLocalizations.delegate,
|
||||||
GlobalCupertinoLocalizations.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