fix(mobile): recover Solo sync silently instead of showing a retry banner (#8701)

## 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 <wesbillman@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Carl <32a2e2c9d428ee08902cab75d956da2c1d235a22d4766b0dd4138bf6e2e5db1d@buzz.block.builderlab.xyz>
This commit is contained in:
Wes
2026-10-29 13:33:35 -07:00
committed by GitHub
parent ccfc1490e4
commit fffd8ca957
6 changed files with 71 additions and 71 deletions

View File

@@ -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,
),
);

View File

@@ -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,

View File

@@ -51,7 +51,6 @@ final meGroupsProvider = StreamProvider<MeGroups>((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<MeGroups>((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<MeGroups>((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<MeGroups>((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<bool>((ref) {
return controller.stream;
});
/// Retryable live-sync interruption without hiding cached organization.
final meSyncWarningProvider = StreamProvider<bool>((ref) {
final service = ref.watch(meSyncProvider);
final controller = StreamController<bool>();
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<void> cache(NostrEvent? event) {
if (event != null) decode(event);

View File

@@ -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<int>? 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 {

View File

@@ -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<List<NostrEvent>>();
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);
},
);
}

View File

@@ -627,7 +627,6 @@ void main() {
expect(service.cachedSnapshot()?.event?.id, fresh.id);
relay.closeSubscription!('offline');
await Future<void>.delayed(Duration.zero);
expect(service.syncClosed.value, isTrue);
expect(container.read(meGroupsProvider).hasError, isFalse);
expect(
container.read(meGroupsProvider).value?.groups.single['name'],