mirror of
https://github.com/block/buzz.git
synced 2026-11-05 17:01:35 +00:00
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:
@@ -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,
|
||||
),
|
||||
);
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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'],
|
||||
|
||||
Reference in New Issue
Block a user