From fffd8ca95784af5942927690f5390927f4754a63 Mon Sep 17 00:00:00 2001 From: Wes Date: Thu, 29 Oct 2026 13:33:35 -0700 Subject: [PATCH] fix(mobile): recover Solo sync silently instead of showing a retry banner (#8701) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Problem The mobile Solo section could display "Live updates stopped. Your saved chats are still available." with a **Retry live updates** button. Any failed Solo head refresh raised this banner. That includes the refresh that runs on app resume before the relay has reconnected, and an 8s history timeout. In those cases the live subscription itself was still healthy. A later successful refresh did not clear the banner, so it stayed up until someone tapped Retry. ## Change - Remove the banner along with `MeSync.syncClosed` and `meSyncWarningProvider`. Saved chats stay visible the whole time. - `meGroupsProvider`: on app resume, an interrupted reader (terminal CLOSED or a failed read) resubscribes on its own while the relay is connected. Before the relay reconnects, the existing rebuild-on-reconnect still owns recovery. A healthy reader keeps doing a plain head refresh. - Home's pull-to-refresh (`ChannelsPage` with the Solo sliver) now also restarts the Solo reader. Before this change, that path refreshed channels only, so in the shipped layout the banner was the only manual recovery. - The no-snapshot `_MeRetry` state and the offline-save warning are unchanged. ## Validation - `flutter analyze` is clean. - The pre-push `mobile-checks` passed. - The journey step in `offline_save_page_test.dart` closes the subscription, cycles background and foreground, asserts a new subscription, and checks that a newer event delivered on it is saved. Removing the `subscriptionClosed` recovery branch fails it. - The new `channels_page_test.dart` case drags to refresh with a Solo sliver and asserts the Solo reader restarts. Removing the invalidation fails it. - Not yet exercised in the running app. That is checklist steps 2 and 3. Reviewed by the Royal Court agents (provider lifecycle, recovery/UX, independent challenge); the one blocker (Home pull-to-refresh) is fixed here. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Signed-off-by: Wes Co-authored-by: Claude Opus 5.5 Co-authored-by: Carl <32a2e2c9d428ee08902cab75d956da2c1d235a22d4766b0dd4138bf6e2e5db1d@buzz.block.builderlab.xyz> --- .../lib/features/channels/channels_page.dart | 6 +- mobile/lib/features/channels/me/me_page.dart | 16 ------ mobile/lib/shared/me/me_sync.dart | 33 +++-------- .../features/channels/channels_page_test.dart | 31 +++++++++++ .../channels/me/offline_save_page_test.dart | 55 +++++++++---------- mobile/test/shared/me/me_sync_test.dart | 1 - 6 files changed, 71 insertions(+), 71 deletions(-) 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'],