diff --git a/mobile/lib/features/channels/channels_page.dart b/mobile/lib/features/channels/channels_page.dart index 3fbd0db665..db4074e7dd 100644 --- a/mobile/lib/features/channels/channels_page.dart +++ b/mobile/lib/features/channels/channels_page.dart @@ -685,7 +685,11 @@ class ChannelsPage extends HookConsumerWidget { topSectionHeight: topSectionHeight, usesPinnedGradient: usesPinnedGradient, scrollController: channelsScrollController, - onRefresh: () => ref.read(channelsProvider.notifier).refresh(), + onRefresh: () { + // Solo has no banner, so pulling must also restart its reader. + if (soloSliver != null) ref.invalidate(meGroupsProvider); + return ref.read(channelsProvider.notifier).refresh(); + }, onSelectChannel: openChannel, ), ); diff --git a/mobile/lib/features/channels/me/me_page.dart b/mobile/lib/features/channels/me/me_page.dart index 1105247243..f649e28a30 100644 --- a/mobile/lib/features/channels/me/me_page.dart +++ b/mobile/lib/features/channels/me/me_page.dart @@ -83,7 +83,6 @@ class MePage extends HookConsumerWidget { final viewer = ref.watch(myPubkeyProvider); final offlineSaveFailed = ref.watch(meOfflineSaveWarningProvider).value ?? false; - final syncClosed = ref.watch(meSyncWarningProvider).value ?? false; final channels = ref.watch(channelsProvider); final unreadIds = ref.watch(meUnreadChannelIdsProvider); final busy = useState(false); @@ -199,21 +198,6 @@ class MePage extends HookConsumerWidget { ), sliver: SliverList.list( children: [ - if (syncClosed) - Semantics( - liveRegion: true, - child: Column( - children: [ - const Text( - 'Live updates stopped. Your saved chats are still available.', - ), - TextButton( - onPressed: () => ref.invalidate(meGroupsProvider), - child: const Text('Retry live updates'), - ), - ], - ), - ), if (offlineSaveFailed) Semantics( liveRegion: true, diff --git a/mobile/lib/shared/me/me_sync.dart b/mobile/lib/shared/me/me_sync.dart index 177c6cf761..47c38bf674 100644 --- a/mobile/lib/shared/me/me_sync.dart +++ b/mobile/lib/shared/me/me_sync.dart @@ -51,7 +51,6 @@ final meGroupsProvider = StreamProvider((ref) { } catch (error, stack) { if (!disposed) { syncFailed = true; - service.syncClosed.value = true; if (!hasSnapshot) controller.addError(error, stack); } } @@ -78,7 +77,6 @@ final meGroupsProvider = StreamProvider((ref) { } catch (error, stack) { if (!disposed) { syncFailed = true; - service.syncClosed.value = true; if (!hasSnapshot) controller.addError(error, stack); } } @@ -106,7 +104,6 @@ final meGroupsProvider = StreamProvider((ref) { onClosed: (_) { if (disposed) return; subscriptionClosed = true; - service.syncClosed.value = true; if (!hasSnapshot) { controller.addError( StateError('Couldn’t sync Solo. Reconnect and try again.'), @@ -119,20 +116,23 @@ final meGroupsProvider = StreamProvider((ref) { return; } await refresh(); - if (!disposed && !subscriptionClosed && !syncFailed) { - service.syncClosed.value = false; - } } catch (error, stack) { if (!disposed) { syncFailed = true; - service.syncClosed.value = true; if (!hasSnapshot) controller.addError(error, stack); } } } ref.listen(appLifecycleProvider, (_, next) { - if (next == AppLifecycleState.resumed) unawaited(refresh()); + if (next != AppLifecycleState.resumed) return; + // Recover an interrupted reader by resubscribing instead of asking the user. + // Before reconnecting, leave recovery to the reconnect listener above. + if (subscriptionClosed || syncFailed) { + if (service.connected) ref.invalidateSelf(); + } else { + unawaited(refresh()); + } }); ref.onDispose(() { disposed = true; @@ -157,20 +157,6 @@ final meOfflineSaveWarningProvider = StreamProvider((ref) { return controller.stream; }); -/// Retryable live-sync interruption without hiding cached organization. -final meSyncWarningProvider = StreamProvider((ref) { - final service = ref.watch(meSyncProvider); - final controller = StreamController(); - void changed() => controller.add(service.syncClosed.value); - service.syncClosed.addListener(changed); - changed(); - ref.onDispose(() { - service.syncClosed.removeListener(changed); - unawaited(controller.close()); - }); - return controller.stream; -}); - /// NIP-01 replacement ordering: smallest ID wins an equal timestamp. bool meEventIsNewer(NostrEvent a, NostrEvent b) => a.createdAt > b.createdAt || @@ -247,9 +233,6 @@ class MeSync { /// False only after the newest candidate is durably written. final cacheWriteFailed = ValueNotifier(false); - /// A terminal close or invalid relay head requires explicit reader recovery. - final syncClosed = ValueNotifier(false); - /// Serialize writes and report failures so callers can expose a retry. Future cache(NostrEvent? event) { if (event != null) decode(event); diff --git a/mobile/test/features/channels/channels_page_test.dart b/mobile/test/features/channels/channels_page_test.dart index 6896d14b09..e1b8034425 100644 --- a/mobile/test/features/channels/channels_page_test.dart +++ b/mobile/test/features/channels/channels_page_test.dart @@ -54,6 +54,8 @@ import 'package:buzz/shared/utils/string_utils.dart'; import 'package:buzz/shared/auth/auth.dart'; import 'package:buzz/shared/community/community_icon_provider.dart'; import 'package:buzz/shared/relay/relay.dart'; +import 'package:buzz/shared/me/me_groups.dart'; +import 'package:buzz/shared/me/me_sync.dart'; import 'package:buzz/shared/theme/theme.dart'; import 'package:buzz/shared/widgets/avatar_image.dart'; import 'package:buzz/shared/emoji/emoji_avatar.dart'; @@ -98,6 +100,7 @@ void main() { ValueListenable? tabReselection, _FakeProfileNotifier? profile, bool realHome = false, + Widget? soloSliver, }) { return ProviderScope( overrides: [ @@ -151,6 +154,7 @@ void main() { onSettingsTransitionProgress: onSettingsTransitionProgress ?? (_) {}, tabReselection: tabReselection, + soloSliver: soloSliver, ), const Positioned.fill( child: ChannelQuickActionsLauncher( @@ -702,6 +706,33 @@ void main() { await interruptingDrag.up(); }); + testWidgets('pull to refresh restarts the Solo reader', (tester) async { + var soloReads = 0; + await tester.pumpWidget( + buildTestable( + soloSliver: const SliverToBoxAdapter(child: Text('Solo inventory')), + overrides: [ + channelsProvider.overrideWith(() => _FakeNotifier(testChannels)), + meGroupsProvider.overrideWith((ref) { + soloReads++; + return Stream.value(const MeGroups()); + }), + ], + ), + ); + await tester.pumpAndSettle(); + final before = soloReads; + + await tester.timedDrag( + find.byType(CustomScrollView), + const Offset(0, 320), + const Duration(milliseconds: 500), + ); + await tester.pumpAndSettle(); + + expect(soloReads, before + 1); + }); + testWidgets('keeps the Buzz background fixed behind the channels list', ( tester, ) async { diff --git a/mobile/test/features/channels/me/offline_save_page_test.dart b/mobile/test/features/channels/me/offline_save_page_test.dart index d36ea2aa88..9ea9e6a647 100644 --- a/mobile/test/features/channels/me/offline_save_page_test.dart +++ b/mobile/test/features/channels/me/offline_save_page_test.dart @@ -51,6 +51,11 @@ class _Store extends InMemorySharedPreferencesStore { } } +class _Lifecycle extends AppLifecycleNotifier { + @override + AppLifecycleState build() => AppLifecycleState.resumed; +} + class _Navigation extends NavigatorObserver { int pushes = 0; @override @@ -109,6 +114,7 @@ void main() { meSyncProvider.overrideWithValue(service), meAssistantsProvider.overrideWith((ref) async => []), channelsProvider.overrideWith(_Channels.new), + appLifecycleProvider.overrideWith(_Lifecycle.new), ], child: MaterialApp( theme: AppTheme.light(), @@ -144,40 +150,33 @@ void main() { expect(find.text('Retry offline save'), findsNothing); expect(find.text('Saved chat'), findsOneWidget); expect(service.cachedSnapshot()?.event?.id, fresh.id); + + // A closed reader resubscribes on resume without user action. + final scope = ProviderScope.containerOf( + tester.element(find.byType(MePage)), + ); + final lifecycle = scope.read(appLifecycleProvider.notifier); + final rejected = relay.closeSubscription; + relay.closeSubscription!('terminal CLOSED'); + lifecycle.state = AppLifecycleState.paused; + lifecycle.state = AppLifecycleState.resumed; + await tester.pumpAndSettle(); + expect(relay.closeSubscription, isNot(same(rejected))); + final newer = head(3); + relay.emit!(newer); + await tester.pumpAndSettle(); + expect(service.cachedSnapshot()?.event?.id, newer.id); + expect(find.text('Saved chat'), findsOneWidget); + expect(find.textContaining('Live updates'), findsNothing); + final invalid = NostrEvent.fromJson({ - ...head(3).toJson(), + ...head(4).toJson(), 'sig': '0' * 128, }); relay.emit!(invalid); await tester.pumpAndSettle(); expect(find.text('Saved chat'), findsOneWidget); - expect(find.text('Retry live updates'), findsOneWidget); - expect(service.cachedSnapshot()?.event?.id, fresh.id); - relay.events = [invalid]; - await tester.tap(find.text('Retry live updates')); - await tester.pumpAndSettle(); - expect(find.text('Retry live updates'), findsOneWidget); - expect(find.text('Saved chat'), findsOneWidget); - relay.events = [fresh]; - await tester.tap(find.text('Retry live updates')); - await tester.pumpAndSettle(); - expect(find.text('Retry live updates'), findsNothing); - relay.closeSubscription!('terminal CLOSED'); - await tester.pumpAndSettle(); - expect(find.text('Saved chat'), findsOneWidget); - expect(find.text('Retry live updates'), findsOneWidget); - final pendingRefresh = Completer>(); - relay.pendingRead = pendingRefresh.future; - await tester.tap(find.text('Retry live updates')); - await tester.pumpAndSettle(); - expect(find.text('Saved chat'), findsOneWidget); - expect(find.text('Retry live updates'), findsOneWidget); - pendingRefresh.complete([fresh]); - await tester.pumpAndSettle(); - expect(find.text('Retry live updates'), findsNothing); - relay.closeSubscription!('closed again'); - await tester.pumpAndSettle(); - expect(find.text('Retry live updates'), findsOneWidget); + expect(service.cachedSnapshot()?.event?.id, newer.id); }, ); } diff --git a/mobile/test/shared/me/me_sync_test.dart b/mobile/test/shared/me/me_sync_test.dart index 4d028b9c66..718a8666af 100644 --- a/mobile/test/shared/me/me_sync_test.dart +++ b/mobile/test/shared/me/me_sync_test.dart @@ -627,7 +627,6 @@ void main() { expect(service.cachedSnapshot()?.event?.id, fresh.id); relay.closeSubscription!('offline'); await Future.delayed(Duration.zero); - expect(service.syncClosed.value, isTrue); expect(container.read(meGroupsProvider).hasError, isFalse); expect( container.read(meGroupsProvider).value?.groups.single['name'],