From da0b82240484dcc32c9aed73ea632ee7947258ee Mon Sep 17 00:00:00 2001 From: kenny lopez Date: Thu, 15 Oct 2026 07:51:34 -0400 Subject: [PATCH] Resolve Solo routing, offline organization and abandoned chat recovery Signed-off-by: kenny lopez --- .../channels/channel_detail_page.dart | 36 ++-- .../channel_detail_page/placement_route.dart | 46 +++++ mobile/lib/features/channels/me/me_page.dart | 4 +- .../features/channels/me/me_page_helpers.dart | 2 +- .../channels/me/native_new_me_chat.dart | 33 ++++ .../lib/features/channels/me/new_me_chat.dart | 2 +- mobile/lib/features/home/home_page.dart | 2 +- mobile/lib/shared/me/me_chat_creation.dart | 13 ++ mobile/lib/shared/me/me_groups.dart | 6 +- mobile/lib/shared/me/me_recovery.dart | 2 +- mobile/lib/shared/me/me_sync.dart | 89 ++++++++-- .../channels/channel_detail_page_test.dart | 76 +++++++- .../features/channels/me/me_page_test.dart | 162 ++++++++++-------- mobile/test/features/home/home_page_test.dart | 4 +- .../test/shared/me/me_chat_creation_test.dart | 32 ++++ mobile/test/shared/me/me_sync_test.dart | 158 ++++++++++++++++- 16 files changed, 551 insertions(+), 116 deletions(-) create mode 100644 mobile/lib/features/channels/channel_detail_page/placement_route.dart diff --git a/mobile/lib/features/channels/channel_detail_page.dart b/mobile/lib/features/channels/channel_detail_page.dart index 8a133b418b..60f2fd76f3 100644 --- a/mobile/lib/features/channels/channel_detail_page.dart +++ b/mobile/lib/features/channels/channel_detail_page.dart @@ -17,6 +17,7 @@ import 'package:scrollable_positioned_list/scrollable_positioned_list.dart'; import '../../shared/emoji/emoji_burst.dart'; import '../../shared/me/me_sync.dart'; +import '../../shared/me/me_search_placement.dart'; import 'me/me_assistant_picker.dart'; import 'me/me_chat_assistant.dart'; import 'me/me_messages_provider.dart'; @@ -89,6 +90,7 @@ import 'reading_marks.dart'; import 'timeline_message.dart'; part 'channel_detail_page/message_list.dart'; +part 'channel_detail_page/placement_route.dart'; part 'channel_detail_page/empty_messages.dart'; part 'channel_detail_page/oldest_navigation.dart'; part 'channel_detail_page/system_rows.dart'; @@ -107,14 +109,11 @@ part 'channel_detail_page/app_bar.dart'; /// Fetch deep-link targets that may be outside the loaded channel window. Future _loadDeepLinkEvents( - WidgetRef ref, - String channelId, + ChannelMessagesNotifier notifier, Set eventIds, ) async { try { - await ref - .read(channelMessagesProvider(channelId).notifier) - .loadEventsById(eventIds); + await notifier.loadEventsById(eventIds); } catch (error) { debugPrint('deep-link: failed to load target messages: $error'); } @@ -253,11 +252,12 @@ enum InitialThreadRouteBehavior { replaceCurrentRoute, } -class ChannelDetailPage extends HookConsumerWidget { +class _ResolvedChannelDetailPage extends HookConsumerWidget { final Channel channel; /// Uses personal assistant routing for a chat placed in Me. final bool personalChat; + final bool placementKnown; final String? initialAssistant; final String? initialMessageId; final String? initialThreadRootId; @@ -265,10 +265,11 @@ class ChannelDetailPage extends HookConsumerWidget { /// How the automatically opened initial thread affects the route stack. final InitialThreadRouteBehavior initialThreadRouteBehavior; - const ChannelDetailPage({ + const _ResolvedChannelDetailPage({ super.key, required this.channel, - this.personalChat = false, + required this.personalChat, + required this.placementKnown, this.initialAssistant, this.initialMessageId, this.initialThreadRootId, @@ -561,7 +562,7 @@ class ChannelDetailPage extends HookConsumerWidget { }; if (eventIds.isEmpty) return null; final notifier = ref.read(messagesProvider.notifier); - unawaited(_loadDeepLinkEvents(ref, channel.id, eventIds)); + unawaited(_loadDeepLinkEvents(notifier, eventIds)); return () => notifier.releaseDeepLinkEvents(eventIds); }, [ @@ -813,8 +814,13 @@ class ChannelDetailPage extends HookConsumerWidget { stickyDateHeaderState: stickyDateHeaderState, entries: timelineEntries, allMessages: timelineMessages, - initialMessageId: initialMessageId, - initialThreadRootId: initialThreadRootId, + initialMessageId: personalChat + ? initialMessageId ?? initialThreadRootId + : initialMessageId, + initialThreadRootId: + personalChat || !placementKnown + ? null + : initialThreadRootId, initialThreadRouteBehavior: initialThreadRouteBehavior, initialOrdinaryUnreadMessageIds: @@ -912,13 +918,19 @@ class ChannelDetailPage extends HookConsumerWidget { mentionPubkeys, { mediaTags = const >[], }) async { + if (!placementKnown) { + ref.invalidate(meGroupsProvider); + throw StateError( + 'Reconnect to verify this chat before sending.', + ); + } var recipients = mentionPubkeys; if (personalChat) { if (mePlacement?.asData?.value.channels .contains(channel.id) != true) { throw StateError( - 'Refresh Me before sending to this chat.', + 'Refresh Solo before sending to this chat.', ); } recipients = await meRecipients( diff --git a/mobile/lib/features/channels/channel_detail_page/placement_route.dart b/mobile/lib/features/channels/channel_detail_page/placement_route.dart new file mode 100644 index 0000000000..051809edf4 --- /dev/null +++ b/mobile/lib/features/channels/channel_detail_page/placement_route.dart @@ -0,0 +1,46 @@ +part of '../channel_detail_page.dart'; + +/// Resolves Solo placement at the destination, including Activity/deep links. +class ChannelDetailPage extends ConsumerWidget { + final Channel channel; + final bool personalChat; + final String? initialAssistant; + final String? initialMessageId; + final String? initialThreadRootId; + final InitialThreadRouteBehavior initialThreadRouteBehavior; + + const ChannelDetailPage({ + super.key, + required this.channel, + this.personalChat = false, + this.initialAssistant, + this.initialMessageId, + this.initialThreadRootId, + this.initialThreadRouteBehavior = InitialThreadRouteBehavior.push, + }); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final groups = ref.watch(meGroupsProvider).value; + final fallback = ref.watch(meSearchPlacementProvider(channel.id)); + final placed = groups?.channels.contains(channel.id) ?? personalChat; + // Bounded waiting keeps ordinary search results readable during an outage. + // Unknown placement may be read, but cannot send via an unverified path. + if (!placed && groups == null && fallback.isLoading) { + return Scaffold( + appBar: AppBar(title: Text(channel.name)), + body: const Center(child: BuzzLoadingIndicator()), + ); + } + return _ResolvedChannelDetailPage( + key: ValueKey((channel.id, placed)), + channel: channel, + personalChat: placed, + placementKnown: groups != null || personalChat, + initialAssistant: initialAssistant, + initialMessageId: initialMessageId, + initialThreadRootId: initialThreadRootId, + initialThreadRouteBehavior: initialThreadRouteBehavior, + ); + } +} diff --git a/mobile/lib/features/channels/me/me_page.dart b/mobile/lib/features/channels/me/me_page.dart index 50d9413b11..83eb67959a 100644 --- a/mobile/lib/features/channels/me/me_page.dart +++ b/mobile/lib/features/channels/me/me_page.dart @@ -120,7 +120,7 @@ class MePage extends HookConsumerWidget { return FrostedScaffold( backgroundColor: context.colors.surface, appBar: FrostedAppBar( - nativeTitle: 'Me', + nativeTitle: 'Solo', nativeLargeTitle: true, automaticallyImplyLeading: false, nativeActions: [ @@ -140,7 +140,7 @@ class MePage extends HookConsumerWidget { horizontalInset: Grid.gutter, showBottomDivider: true, bottomDividerOpacity: 0.07, - title: Text('Me', style: headerTitleStyle), + title: Text('Solo', style: headerTitleStyle), titleStyle: headerTitleStyle, actions: [ ClipRRect( diff --git a/mobile/lib/features/channels/me/me_page_helpers.dart b/mobile/lib/features/channels/me/me_page_helpers.dart index d6b241806b..47df2cdcff 100644 --- a/mobile/lib/features/channels/me/me_page_helpers.dart +++ b/mobile/lib/features/channels/me/me_page_helpers.dart @@ -132,7 +132,7 @@ Future _offerMeRecovery(BuildContext context, MeSync sync) async { final discard = await showDialog( context: context, builder: (context) => AlertDialog( - title: const Text('Conflicting Me changes'), + title: const Text('Conflicting Solo changes'), content: const Text( 'Desktop and this phone changed the same project or chat. Keep the pending phone change to retry later, or discard it and use the current desktop organization. Your chat messages will be kept.', ), diff --git a/mobile/lib/features/channels/me/native_new_me_chat.dart b/mobile/lib/features/channels/me/native_new_me_chat.dart index c252fe2de2..65d6dbd43a 100644 --- a/mobile/lib/features/channels/me/native_new_me_chat.dart +++ b/mobile/lib/features/channels/me/native_new_me_chat.dart @@ -4,6 +4,39 @@ Future<(Channel, String)?> _showNewMeChat( BuildContext context, WidgetRef ref, ) async { + final initialSync = ref.read(meSyncProvider); + final unfinished = MeChatCreation.pending(initialSync); + if (unfinished != null) { + final resume = await showDialog( + context: context, + builder: (context) => AlertDialog( + title: const Text('Unfinished chat'), + content: Text( + 'Resume “${unfinished.name}”, or discard its setup to start another chat. Any channel and messages already created are kept in Home or Solo.', + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context), + child: const Text('Later'), + ), + TextButton( + onPressed: () => Navigator.pop(context, false), + child: const Text('Discard setup'), + ), + TextButton( + onPressed: () => Navigator.pop(context, true), + child: const Text('Resume'), + ), + ], + ), + ); + if (resume == null || !context.mounted) return null; + if (!resume) { + await unfinished.discard(initialSync); + ref.invalidate(channelsProvider); + } + } + if (!context.mounted) return null; if (defaultTargetPlatform == TargetPlatform.iOS) { final sync = ref.read(meSyncProvider); final pending = MeChatCreation.pending(sync); diff --git a/mobile/lib/features/channels/me/new_me_chat.dart b/mobile/lib/features/channels/me/new_me_chat.dart index 71712819cc..6b5f9d80b9 100644 --- a/mobile/lib/features/channels/me/new_me_chat.dart +++ b/mobile/lib/features/channels/me/new_me_chat.dart @@ -21,7 +21,7 @@ class _NewMeChat extends HookConsumerWidget { return const Padding( padding: EdgeInsets.all(Grid.gutter), child: Text( - 'Your account or community changed. Return to Me to start a chat.', + 'Your account or community changed. Return to Solo to start a chat.', ), ); } diff --git a/mobile/lib/features/home/home_page.dart b/mobile/lib/features/home/home_page.dart index c1b3e7395e..8aab0c4a72 100644 --- a/mobile/lib/features/home/home_page.dart +++ b/mobile/lib/features/home/home_page.dart @@ -71,7 +71,7 @@ class HomePage extends HookConsumerWidget { _HomeDestination( icon: Icons.chat_bubble_outline, selectedIcon: Icons.chat_bubble, - label: 'Me', + label: 'Solo', ), ]; diff --git a/mobile/lib/shared/me/me_chat_creation.dart b/mobile/lib/shared/me/me_chat_creation.dart index c66a33ed8d..56ee30f39b 100644 --- a/mobile/lib/shared/me/me_chat_creation.dart +++ b/mobile/lib/shared/me/me_chat_creation.dart @@ -56,6 +56,19 @@ class MeChatCreation { sync.check(); } + /// Abandon only this reviewed intent, preserving any relay channel/messages. + /// Partial channels remain accessible from Home (or Solo if already placed). + Future discard(MeSync sync) async { + if (sync.disposed) throw StateError('Your community changed. Try again.'); + final current = pending(sync); + if (current?.id != id || current?.name != name || current?.agent != agent) { + throw StateError('The unfinished chat changed. Review it again.'); + } + if (!await sync.prefs!.remove(storageKey(sync))) { + throw StateError('Couldn’t discard this setup. Try again.'); + } + } + /// Keep the journal through every relay step, including Me placement. Future complete( MeSync sync, { diff --git a/mobile/lib/shared/me/me_groups.dart b/mobile/lib/shared/me/me_groups.dart index 4776aa9bb6..fc176275b7 100644 --- a/mobile/lib/shared/me/me_groups.dart +++ b/mobile/lib/shared/me/me_groups.dart @@ -18,7 +18,7 @@ class MeGroups { factory MeGroups.decode(String plaintext, String community) { if (utf8.encode(plaintext).length > 16384) { - throw const FormatException('Me storage is full'); + throw const FormatException('Solo storage is full'); } final raw = jsonDecode(plaintext) as Map; final value = raw['value'] as Map; @@ -27,7 +27,7 @@ class MeGroups { raw['deleted'] is! bool || value['type'] != 'groups' || value['id'] != 'me') { - throw const FormatException('Unsupported Me record'); + throw const FormatException('Unsupported Solo record'); } final groups = (value['groups'] as List) .map((g) => Map.from(g as Map)) @@ -59,7 +59,7 @@ class MeGroups { channels.length > 1000 || channels.toSet().length != channels.length || channels.any((id) => !channelId.hasMatch(id))) { - throw const FormatException('Invalid Me projects'); + throw const FormatException('Invalid Solo projects'); } if (raw['deleted'] == true) return const MeGroups(); return MeGroups( diff --git a/mobile/lib/shared/me/me_recovery.dart b/mobile/lib/shared/me/me_recovery.dart index 1c1a00c131..81392258a5 100644 --- a/mobile/lib/shared/me/me_recovery.dart +++ b/mobile/lib/shared/me/me_recovery.dart @@ -5,7 +5,7 @@ import 'me_groups.dart'; class MeRecoveryConflict implements Exception { @override String toString() => - 'Me has conflicting edits on another device. Your pending change is still saved on this phone.'; + 'Solo has conflicting edits on another device. Your pending change is still saved on this phone.'; } /// Reapply only local edits to a newer desktop record. Concurrent edits to the diff --git a/mobile/lib/shared/me/me_sync.dart b/mobile/lib/shared/me/me_sync.dart index f787dad7a9..a3e2cdb3f8 100644 --- a/mobile/lib/shared/me/me_sync.dart +++ b/mobile/lib/shared/me/me_sync.dart @@ -12,13 +12,14 @@ import '../relay/relay.dart'; import 'me_groups.dart'; import 'me_recovery.dart'; -/// A live, account/community-scoped reader for desktop's encrypted Me record. +/// A live, account/community-scoped reader for desktop's encrypted Solo record. final meGroupsProvider = StreamProvider((ref) { final service = ref.watch(meSyncProvider); final controller = StreamController(); var disposed = false; void Function()? unsubscribe; NostrEvent? latest; + var hasSnapshot = false; void accept(NostrEvent event) { if (disposed || (latest != null && @@ -29,9 +30,11 @@ final meGroupsProvider = StreamProvider((ref) { try { final value = service.decode(event); latest = event; + hasSnapshot = true; controller.add(value); + unawaited(service.cache(event).catchError((Object _) {})); } catch (error, stack) { - controller.addError(error, stack); + if (!hasSnapshot) controller.addError(error, stack); } } @@ -42,23 +45,35 @@ final meGroupsProvider = StreamProvider((ref) { if (event != null) { accept(event); } else if (latest == null) { + hasSnapshot = true; controller.add(const MeGroups()); + await service.cache(null); } } catch (error, stack) { - if (!disposed) controller.addError(error, stack); + if (!disposed && !hasSnapshot) controller.addError(error, stack); } } Future start() async { + try { + final cached = service.cachedSnapshot(); + if (cached != null) { + latest = cached.event; + hasSnapshot = true; + controller.add(cached.groups); + } + } catch (_) { + // A corrupt cache must not prevent a fresh relay read. + } try { service.check(); unsubscribe = await service.session.subscribe( service.filter, accept, onClosed: (_) { - if (!disposed) { + if (!disposed && !hasSnapshot) { controller.addError( - StateError('Couldn’t sync Me. Reconnect and try again.'), + StateError('Couldn’t sync Solo. Reconnect and try again.'), ); } }, @@ -69,7 +84,7 @@ final meGroupsProvider = StreamProvider((ref) { } await refresh(); } catch (error, stack) { - if (!disposed) controller.addError(error, stack); + if (!disposed && !hasSnapshot) controller.addError(error, stack); } } @@ -119,6 +134,48 @@ class MeSync { MeSync(this.session, this.config, {this.connected = true, this.prefs}); + /// Encrypted last verified organization, isolated by account and community. + String get cacheKey => 'me-cache:$community:$pubkey'; + + /// Read while offline. A signed event preserves replacement ordering. + ({NostrEvent? event, MeGroups groups})? cachedSnapshot() { + if (disposed || config.nsec == null) return null; + final saved = prefs?.getString(cacheKey); + if (saved == null) return null; + final value = jsonDecode(nip44Decrypt(key, saved)) as Map; + if (value['community'] != community) { + throw const FormatException('Invalid cached organization'); + } + final json = value['event'] as Map?; + final event = json == null ? null : NostrEvent.fromJson(json); + return ( + event: event, + groups: event == null ? const MeGroups() : decode(event), + ); + } + + /// Store only verified data; never overwrite a newer persisted head. + Future cache(NostrEvent? event) async { + if (disposed || config.nsec == null || prefs == null) return; + if (event != null) decode(event); + try { + final previous = cachedSnapshot()?.event; + if (previous != null && + (event == null || !meEventIsNewer(event, previous))) { + return; + } + } catch (_) { + // Replace corrupt local storage with verified relay data. + } + await prefs!.setString( + cacheKey, + nip44Encrypt( + key, + jsonEncode({'community': community, 'event': event?.toJson()}), + ), + ); + } + NostrFilter get filter => NostrFilter( kinds: const [30078], authors: [pubkey], @@ -130,9 +187,9 @@ class MeSync { void check() { if (!connected || config.nsec == null) { - throw StateError('Connect to your community to load Me.'); + throw StateError('Connect to your community to load Solo.'); } - if (disposed) throw StateError('Your community changed. Open Me again.'); + if (disposed) throw StateError('Your community changed. Open Solo again.'); } MeGroups decode(NostrEvent event) { @@ -148,7 +205,7 @@ class MeSync { !event.tags.any( (t) => t.length == 2 && t[0] == 't' && t[1] == MeGroups.tag, )) { - throw const FormatException('Invalid Me record'); + throw const FormatException('Invalid Solo record'); } nostr.Event.fromJson(jsonEncode(event.toJson())); return MeGroups.decode(nip44Decrypt(key, event.content), community); @@ -178,7 +235,7 @@ class MeSync { /// Refresh before applying an intent, then confirm the selected signed event. Future update(MeGroups Function(MeGroups) change) async { check(); - if (_saving) throw StateError('A Me change is still being saved.'); + if (_saving) throw StateError('A Solo change is still being saved.'); _saving = true; try { MeGroups? recovered; @@ -213,20 +270,20 @@ class MeSync { await session.publish(event); if ((await head())?.id != event.id) { throw StateError( - 'A previous Me save is still unconfirmed. Try again.', + 'A previous Solo save is still unconfirmed. Try again.', ); } } await prefs?.remove(pendingKey); throw StateError( - 'Your previous Me save is confirmed. Review your projects before making another change.', + 'Your previous Solo save is confirmed. Review your projects before making another change.', ); } } final before = await head(); if (recovered != null && before?.id != recoveredHead) { throw StateError( - 'Me changed again. Your pending change is saved; try again.', + 'Solo changed again. Your pending change is saved; try again.', ); } final value = @@ -243,7 +300,7 @@ class MeSync { final current = await head(); if (before?.id != current?.id) { throw StateError( - 'Me changed on another device. Refresh and try again.', + 'Solo changed on another device. Refresh and try again.', ); } if (current != null && @@ -275,13 +332,13 @@ class MeSync { check(); if ((await head())?.id != signed.id) { throw StateError( - 'Another Me change is selected. Refresh to see the latest projects.', + 'Another Solo change is selected. Refresh to see the latest projects.', ); } await prefs?.remove(pendingKey); if (recovered != null) { throw StateError( - 'Your previous Me save was recovered alongside the desktop changes. Review your projects before making another change.', + 'Your previous Solo save was recovered alongside the desktop changes. Review your projects before making another change.', ); } } finally { diff --git a/mobile/test/features/channels/channel_detail_page_test.dart b/mobile/test/features/channels/channel_detail_page_test.dart index 9b9992d623..a06f0eccd2 100644 --- a/mobile/test/features/channels/channel_detail_page_test.dart +++ b/mobile/test/features/channels/channel_detail_page_test.dart @@ -1,3 +1,4 @@ +import 'package:buzz/features/channels/compose_bar.dart'; import 'package:buzz/features/channels/agent_reply_handoff.dart'; import 'package:buzz/features/channels/me/me_messages_provider.dart'; import 'package:buzz/shared/me/me_assistants.dart'; @@ -281,6 +282,7 @@ Widget _buildTestable({ http.Client? mediaClient, VideoPreviewFrameLoader? videoPreviewLoader, Widget? home, + Stream? organization, }) { final resolvedChannel = channel ?? _testChannel; final navigatorKey = GlobalKey(); @@ -291,6 +293,13 @@ Widget _buildTestable({ return ProviderScope( retry: providerRetry ?? (disableRetries ? (_, _) => null : null), overrides: [ + meGroupsProvider.overrideWith( + (ref) => + organization ?? + Stream.value( + MeGroups(channels: personalChat ? [resolvedChannel.id] : []), + ), + ), if (personalChat) ...[ meSyncProvider.overrideWithValue( MeSync( @@ -307,7 +316,6 @@ Widget _buildTestable({ ), meMessagesProvider(_channelId).overrideWith(() => fakeMessagesNotifier), meAssistantsProvider.overrideWith((ref) async => []), - meGroupsProvider.overrideWith((ref) => Stream.value(const MeGroups())), ], if (videoPreviewLoader != null) videoPreviewFrameLoaderProvider.overrideWithValue(videoPreviewLoader), @@ -10233,6 +10241,65 @@ void main() { }); }); + testWidgets('unknown Solo placement remains readable but cannot send', ( + tester, + ) async { + await tester.pumpWidget( + _buildTestable( + disableRetries: true, + organization: Stream.error(StateError('offline')), + messages: [ + _textMsg(id: 'visible', pubkey: 'alice', content: 'Cached message'), + ], + ), + ); + await tester.pumpAndSettle(); + expect(findRichText('Cached message'), findsOneWidget); + final compose = tester.widget(find.byType(ComposeBar)); + await expectLater( + compose.onSend('Do not route without placement', []), + throwsStateError, + ); + }); + + testWidgets( + 'default Activity/deep-link entry resolves Solo and keeps replies inline', + (tester) async { + await tester.pumpWidget( + _buildTestable( + personalChat: true, + messages: [ + _textMsg(id: threadIdRoot, pubkey: 'self', content: 'Question'), + _textMsg( + id: threadIdReplyZero, + pubkey: 'alice', + content: 'Agent answer', + extraTags: [ + ['e', threadIdRoot, '', 'reply'], + ], + ), + ], + home: ChannelDetailPage( + channel: _testChannel, + initialMessageId: threadIdReplyZero, + initialThreadRootId: threadIdRoot, + ), + ), + ); + await tester.pumpAndSettle(); + expect(find.byKey(const ValueKey('me-assistant-button')), findsOneWidget); + expect(findRichText('Agent answer'), findsOneWidget); + expect(find.byType(ThreadDetailPage), findsNothing); + final container = ProviderScope.containerOf( + tester.element(find.byType(ChannelDetailPage)), + ); + final notifier = + container.read(meMessagesProvider(_channelId).notifier) + as _FakeMessagesNotifier; + expect(notifier.loadedEventIds, contains(threadIdReplyZero)); + }, + ); + group('Typing indicator', () { testWidgets('Me reply consumes typing and grows its pending bubble', ( tester, @@ -16257,6 +16324,13 @@ class _FakeMessagesNotifier extends ChannelMessagesNotifier { final List> _olderPages; final bool failOlderFetch; final Map summaries; + final loadedEventIds = {}; + @override + Future loadEventsById(Iterable eventIds) async { + loadedEventIds.addAll(eventIds); + await super.loadEventsById(eventIds); + } + int fetchOlderCalls = 0; _FakeMessagesNotifier( diff --git a/mobile/test/features/channels/me/me_page_test.dart b/mobile/test/features/channels/me/me_page_test.dart index f3dcd5616d..fef20fe362 100644 --- a/mobile/test/features/channels/me/me_page_test.dart +++ b/mobile/test/features/channels/me/me_page_test.dart @@ -227,85 +227,97 @@ void main() { debugDefaultTargetPlatformOverride = null; }); - testWidgets('iOS new chat restores a durable creation after restart', ( - tester, - ) async { - debugDefaultTargetPlatformOverride = TargetPlatform.iOS; - addTearDown(() => debugDefaultTargetPlatformOverride = null); - const channel = MethodChannel('buzz/profile_text_editor'); - final calls = []; - var directoryLoads = 0; - tester.binding.defaultBinaryMessenger.setMockMethodCallHandler(channel, ( - call, + for (final resume in [true, false]) { + testWidgets('iOS new chat recovers a durable creation: resume=$resume', ( + tester, ) async { - calls.add(call); - return null; - }); - addTearDown( - () => tester.binding.defaultBinaryMessenger.setMockMethodCallHandler( - channel, - null, - ), - ); - SharedPreferences.setMockInitialValues({}); - final prefs = await SharedPreferences.getInstance(); - final sync = MeSync( - RelaySessionNotifier(), - RelayConfig( - baseUrl: 'https://relay.test', - nsec: nostr.Nip19.encode( - prefix: nostr.Nip19Prefix.nsec, - data: - '0000000000000000000000000000000000000000000000000000000000000001', + debugDefaultTargetPlatformOverride = TargetPlatform.iOS; + addTearDown(() => debugDefaultTargetPlatformOverride = null); + const channel = MethodChannel('buzz/profile_text_editor'); + final calls = []; + var directoryLoads = 0; + tester.binding.defaultBinaryMessenger.setMockMethodCallHandler(channel, ( + call, + ) async { + calls.add(call); + return null; + }); + addTearDown( + () => tester.binding.defaultBinaryMessenger.setMockMethodCallHandler( + channel, + null, ), - ), - prefs: prefs, - ); - await const MeChatCreation( - 'same-id', - 'Unfinished chat', - 'agent', - ).save(sync); - final restarted = MeSync(RelaySessionNotifier(), sync.config, prefs: prefs); - await tester.pumpWidget( - ProviderScope( - overrides: [ - meGroupsProvider.overrideWith( - (ref) => Stream.value(const MeGroups()), + ); + SharedPreferences.setMockInitialValues({}); + final prefs = await SharedPreferences.getInstance(); + final sync = MeSync( + RelaySessionNotifier(), + RelayConfig( + baseUrl: 'https://relay.test', + nsec: nostr.Nip19.encode( + prefix: nostr.Nip19Prefix.nsec, + data: + '0000000000000000000000000000000000000000000000000000000000000001', ), - meSyncProvider.overrideWithValue(restarted), - channelsProvider.overrideWith(FakeChannels.new), - meAssistantsProvider.overrideWith((ref) async { - directoryLoads++; - return []; - }), - ], - child: MaterialApp(theme: AppTheme.light(), home: const MePage()), - ), - ); - await tester.pumpAndSettle(); + ), + prefs: prefs, + ); + await const MeChatCreation( + 'same-id', + 'Unfinished chat', + 'agent', + ).save(sync); + final restarted = MeSync( + RelaySessionNotifier(), + sync.config, + prefs: prefs, + ); + await tester.pumpWidget( + ProviderScope( + overrides: [ + meGroupsProvider.overrideWith( + (ref) => Stream.value(const MeGroups()), + ), + meSyncProvider.overrideWithValue(restarted), + channelsProvider.overrideWith(FakeChannels.new), + meAssistantsProvider.overrideWith((ref) async { + directoryLoads++; + return []; + }), + ], + child: MaterialApp(theme: AppTheme.light(), home: const MePage()), + ), + ); + await tester.pumpAndSettle(); - final bar = tester.widget(find.byType(FrostedAppBar)); - bar.nativeActions! - .firstWhere((action) => action.label == 'New chat') - .onPressed!(); - await tester.pumpAndSettle(); - expect(directoryLoads, 1); - expect(calls, hasLength(1)); - expect(calls.single.method, 'presentChat'); - final args = calls.single.arguments as Map; - expect(args['title'], 'New chat'); - expect(args['initialValue'], 'Unfinished chat'); - expect(args['selectedAssistant'], 'agent'); - expect(args['fieldsLocked'], isTrue); - expect(args['error'], contains('Resume')); - expect(args['placeholder'], 'Chat name'); - expect(args['assistants'], isEmpty); - expect(args['containerCornerRadius'], Radii.container); - expect(find.byType(BottomSheet), findsNothing); - await tester.pumpWidget(const SizedBox.shrink()); - debugDefaultTargetPlatformOverride = null; - }); + final bar = tester.widget(find.byType(FrostedAppBar)); + bar.nativeActions! + .firstWhere((action) => action.label == 'New chat') + .onPressed!(); + await tester.pumpAndSettle(); + await tester.tap(find.text(resume ? 'Resume' : 'Discard setup')); + await tester.pumpAndSettle(); + expect(directoryLoads, 1); + expect(calls, hasLength(1)); + expect(calls.single.method, 'presentChat'); + final args = calls.single.arguments as Map; + expect(args['title'], 'New chat'); + expect(args['initialValue'], resume ? 'Unfinished chat' : ''); + expect(args['selectedAssistant'], resume ? 'agent' : isNull); + expect(args['fieldsLocked'], resume); + expect(args['error'], resume ? contains('Resume') : isNull); + expect( + MeChatCreation.pending(restarted)?.id, + resume ? 'same-id' : isNull, + ); + expect(args['placeholder'], 'Chat name'); + expect(args['assistants'], isEmpty); + expect(args['containerCornerRadius'], Radii.container); + expect(find.byType(BottomSheet), findsNothing); + await tester.pumpWidget(const SizedBox.shrink()); + debugDefaultTargetPlatformOverride = null; + }); + } testWidgets('unavailable preferences show retry instead of empty state', ( tester, diff --git a/mobile/test/features/home/home_page_test.dart b/mobile/test/features/home/home_page_test.dart index 14f7a35a6a..d8cea410f9 100644 --- a/mobile/test/features/home/home_page_test.dart +++ b/mobile/test/features/home/home_page_test.dart @@ -132,7 +132,7 @@ void main() { findsOneWidget, ); expect(find.bySemanticsLabel('Activity, unread'), findsNothing); - await tester.tap(find.byTooltip('Me')); + await tester.tap(find.byTooltip('Solo')); await tester.pumpAndSettle(); expect(find.text('Personal workspace'), findsOneWidget); expect(find.byType(SearchPage), findsNothing); @@ -336,7 +336,7 @@ void main() { await tester.pump(); expect(hapticCalls, hasLength(1)); - await tester.tap(find.byTooltip('Me')); + await tester.tap(find.byTooltip('Solo')); await tester.pump(); expect(hapticCalls, hasLength(2)); }); diff --git a/mobile/test/shared/me/me_chat_creation_test.dart b/mobile/test/shared/me/me_chat_creation_test.dart index 021177e86e..a9752817a0 100644 --- a/mobile/test/shared/me/me_chat_creation_test.dart +++ b/mobile/test/shared/me/me_chat_creation_test.dart @@ -35,6 +35,38 @@ void main() { ); expect(touchedRelay, isFalse); }); + test( + 'discard preserves partial relay state and permits a different intent offline', + () async { + SharedPreferences.setMockInitialValues({}); + final prefs = await SharedPreferences.getInstance(); + final config = RelayConfig( + baseUrl: 'https://relay.test', + nsec: nostr.Nip19.encode( + prefix: nostr.Nip19Prefix.nsec, + data: '0' * 63 + '1', + ), + ); + final online = MeSync(RelaySessionNotifier(), config, prefs: prefs); + const intent = MeChatCreation('old', 'Old', 'revoked-agent'); + await intent.save(online); + final offline = MeSync( + RelaySessionNotifier(), + config, + prefs: prefs, + connected: false, + ); + await expectLater( + const MeChatCreation('wrong', 'Old', 'revoked-agent').discard(offline), + throwsStateError, + ); + expect(MeChatCreation.pending(offline)?.id, 'old'); + await intent.discard(offline); + expect(MeChatCreation.pending(offline), isNull); + await const MeChatCreation('new', 'New', 'other-agent').save(online); + expect(MeChatCreation.pending(online)?.id, 'new'); + }, + ); for (final crashAfter in [0, 1, 2, 3]) { test('restart resumes the same chat after boundary $crashAfter', () async { SharedPreferences.setMockInitialValues({}); diff --git a/mobile/test/shared/me/me_sync_test.dart b/mobile/test/shared/me/me_sync_test.dart index 5939d39d20..b95029da75 100644 --- a/mobile/test/shared/me/me_sync_test.dart +++ b/mobile/test/shared/me/me_sync_test.dart @@ -1,4 +1,5 @@ import 'dart:async'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'dart:convert'; import 'package:flutter_test/flutter_test.dart'; import 'package:nostr/nostr.dart' as nostr; @@ -11,6 +12,18 @@ import 'package:buzz/shared/relay/relay.dart'; class FakeRelay extends RelaySessionNotifier { List events = []; + Future>? pendingRead; + void Function(String)? closeSubscription; + @override + Future subscribe( + NostrFilter filter, + void Function(NostrEvent) onEvent, { + void Function(String message)? onClosed, + }) async { + closeSubscription = onClosed; + return () {}; + } + int writes = 0; bool loseAcknowledgment = false; void Function()? beforeRead; @@ -20,7 +33,7 @@ class FakeRelay extends RelaySessionNotifier { Duration timeout = const Duration(seconds: 8), }) async { beforeRead?.call(); - return events; + return pendingRead ?? events; } @override @@ -62,6 +75,149 @@ void main() { verify: false, ).toMap(), ); + test( + 'offline relaunch emits encrypted cached organization and isolates accounts', + () async { + SharedPreferences.setMockInitialValues({}); + final prefs = await SharedPreferences.getInstance(); + final config = RelayConfig(baseUrl: origin, nsec: nsec); + final online = MeSync(FakeRelay(), config, prefs: prefs); + const groups = MeGroups( + groups: [ + {'id': 'project', 'name': 'Private project', 'defaultTemplateId': ''}, + ], + ); + await online.cache(signed(groups: groups, timestamp: 3)); + await online.cache(signed(timestamp: 2)); + await online.cache(null); + expect( + prefs.getString(online.cacheKey), + isNot(contains('Private project')), + ); + final offline = MeSync( + FakeRelay(), + config, + prefs: prefs, + connected: false, + ); + final container = ProviderContainer( + overrides: [meSyncProvider.overrideWithValue(offline)], + retry: (_, _) => null, + ); + addTearDown(container.dispose); + container.listen(meGroupsProvider, (_, _) {}); + expect( + (await container.read(meGroupsProvider.future)).groups.single['name'], + 'Private project', + ); + await Future.delayed(Duration.zero); + expect(container.read(meGroupsProvider).hasError, isFalse); + for (final other in [ + RelayConfig(baseUrl: 'https://other.test', nsec: nsec), + RelayConfig( + baseUrl: origin, + nsec: nostr.Nip19.encode( + prefix: nostr.Nip19Prefix.nsec, + data: '0' * 63 + '2', + ), + ), + ]) { + expect( + MeSync( + FakeRelay(), + other, + prefs: prefs, + connected: false, + ).cachedSnapshot(), + isNull, + ); + } + }, + ); + test( + 'corrupt cache refreshes online and persists the verified replacement', + () async { + SharedPreferences.setMockInitialValues({}); + final prefs = await SharedPreferences.getInstance(); + final relay = FakeRelay()..events = [signed(timestamp: 5)]; + final service = MeSync( + relay, + RelayConfig(baseUrl: origin, nsec: nsec), + prefs: prefs, + ); + await prefs.setString(service.cacheKey, 'corrupt'); + final container = ProviderContainer( + overrides: [meSyncProvider.overrideWithValue(service)], + retry: (_, _) => null, + ); + addTearDown(container.dispose); + container.listen(meGroupsProvider, (_, _) {}); + expect((await container.read(meGroupsProvider.future)).channels, isEmpty); + await Future.delayed(Duration.zero); + expect(service.cachedSnapshot()?.event?.id, relay.events.single.id); + }, + ); + test( + 'empty organization survives relaunch without relay connectivity', + () async { + SharedPreferences.setMockInitialValues({}); + final prefs = await SharedPreferences.getInstance(); + final config = RelayConfig(baseUrl: origin, nsec: nsec); + await MeSync(FakeRelay(), config, prefs: prefs).cache(null); + final offline = MeSync( + FakeRelay(), + config, + prefs: prefs, + connected: false, + ); + expect(offline.cachedSnapshot()?.groups.channels, isEmpty); + }, + ); + test( + 'cached organization emits before refresh and survives a closed subscription', + () async { + SharedPreferences.setMockInitialValues({}); + final prefs = await SharedPreferences.getInstance(); + final response = Completer>(); + final relay = FakeRelay()..pendingRead = response.future; + final service = MeSync( + relay, + RelayConfig(baseUrl: origin, nsec: nsec), + prefs: prefs, + ); + final old = signed(timestamp: 1); + await service.cache(old); + final container = ProviderContainer( + overrides: [meSyncProvider.overrideWithValue(service)], + retry: (_, _) => null, + ); + addTearDown(container.dispose); + container.listen(meGroupsProvider, (_, _) {}); + expect((await container.read(meGroupsProvider.future)).groups, isEmpty); + final fresh = signed( + timestamp: 2, + groups: const MeGroups( + groups: [ + {'id': 'new', 'name': 'From desktop', 'defaultTemplateId': ''}, + ], + ), + ); + response.complete([fresh]); + await Future.delayed(Duration.zero); + expect( + container.read(meGroupsProvider).value?.groups.single['name'], + 'From desktop', + ); + expect(service.cachedSnapshot()?.event?.id, fresh.id); + relay.closeSubscription!('offline'); + await Future.delayed(Duration.zero); + expect(container.read(meGroupsProvider).hasError, isFalse); + expect( + container.read(meGroupsProvider).value?.groups.single['name'], + 'From desktop', + ); + }, + ); test('verifies signature, account and encrypted community envelope', () { final sync = MeSync(FakeRelay(), RelayConfig(baseUrl: origin, nsec: nsec)); expect(sync.decode(signed()).channels, isEmpty);