From 3d3f9cf1537c1901a7b7e7d0d2746218e4f89564 Mon Sep 17 00:00:00 2001 From: Tom Brow Date: Thu, 12 Nov 2026 22:58:03 -0800 Subject: [PATCH] fix(mobile): keep publishing presence while the app is open (#9441) Mobile stopped publishing presence on November 1. The heartbeat in `PresenceNotifier` only runs while something holds `presenceProvider`, and its last always-mounted watcher was the app-bar `ProfileAvatar`, deleted as dead code in [#8880 dead code cleanup](https://github.com/block/buzz/pull/8880). Since then the app sends "online" only while Settings is open, stops when Settings closes, and never sends "away" when backgrounded. Other people see a phone user as offline (or stuck on whatever Settings last sent). This keeps the provider alive from the signed-in shell, next to the other app-wide `ref.listen` keep-alives in `app.dart`. Found during a combined performance and network hunt on a physical iPhone SE running main ([bf3bdd0 GIF timing](https://github.com/block/buzz/commit/bf3bdd0eae)): a 5-minute foreground idle window on the channel list sent no websocket events at all. ### Regressions The app goes back to its pre-#8880 traffic: one small signed kind:20001 event per minute while foregrounded, plus one on each lifecycle change. The heartbeat code is unchanged. `ref.listen` doesn't rebuild the App when presence changes. ### Validation - Added tests: `presence_shell_test.dart` pumps the signed-in `App` without opening Settings and expects an "online" presence publish. It fails without the `app.dart` change (expected `['online']`, got `[]`) and passes with it. - Full mobile suite passes (4785 tests). - Not yet done: device confirmation that heartbeats resume and "away" goes out on backgrounding. Signed-off-by: Tom Brow Co-authored-by: Tom Brow --- mobile/lib/app.dart | 3 + .../features/profile/presence_shell_test.dart | 118 ++++++++++++++++++ 2 files changed, 121 insertions(+) create mode 100644 mobile/test/features/profile/presence_shell_test.dart diff --git a/mobile/lib/app.dart b/mobile/lib/app.dart index 855aa4dda2..c80d9e772d 100644 --- a/mobile/lib/app.dart +++ b/mobile/lib/app.dart @@ -38,6 +38,7 @@ import 'features/channels/agent_activity/observer_subscription.dart'; import 'features/channels/channel_detail_page.dart'; import 'shared/widgets/app_route_observer.dart'; import 'shared/widgets/root_page_route.dart'; +import 'features/profile/profile_provider.dart'; import 'features/profile/user_status_cache_provider.dart'; import 'features/profile/settings_profile_header.dart'; import 'features/profile/set_status_sheet.dart'; @@ -392,6 +393,8 @@ class App extends HookConsumerWidget { ref.listen(observerRelayProvider, (_, _) {}); ref.watch(appLifecycleProvider); ref.listen(userStatusCacheProvider, (_, _) {}); + // Presence heartbeats run only while something holds this provider. + ref.listen(presenceProvider, (_, _) {}); // Activity owns hidden-DM recovery even when Inbox is never opened. ref.listen(activityProvider, (_, _) {}); // Preserve initial read seeding even while Solo is hidden. diff --git a/mobile/test/features/profile/presence_shell_test.dart b/mobile/test/features/profile/presence_shell_test.dart new file mode 100644 index 0000000000..d52d2fecbe --- /dev/null +++ b/mobile/test/features/profile/presence_shell_test.dart @@ -0,0 +1,118 @@ +import 'package:buzz/app.dart'; +import 'package:buzz/features/age_gate/age_signal_provider.dart'; +import 'package:buzz/features/channels/channel.dart'; +import 'package:buzz/features/channels/channels_provider.dart'; +import 'package:buzz/shared/auth/auth.dart'; +import 'package:buzz/shared/relay/relay.dart'; +import 'package:buzz/shared/theme/theme_provider.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:nostr/nostr.dart' as nostr; +import 'package:shared_preferences/shared_preferences.dart'; + +void main() { + testWidgets('signed-in shell publishes presence without opening Settings', ( + tester, + ) async { + final keys = nostr.Keys.generate(); + final session = _PublishingSession(); + SharedPreferences.setMockInitialValues({}); + final prefs = await SharedPreferences.getInstance(); + final container = ProviderContainer( + overrides: [ + savedPrefsProvider.overrideWithValue(prefs), + authProvider.overrideWith(_SignedIn.new), + ageSignalProvider.overrideWith(_Allowed.new), + relayConfigProvider.overrideWith(() => _Config(keys.nsec)), + myPubkeyProvider.overrideWithValue(keys.public), + appLifecycleProvider.overrideWith(_Resumed.new), + relaySessionProvider.overrideWith(() => session), + channelsProvider.overrideWith(_NoChannels.new), + ], + ); + addTearDown(container.dispose); + + await tester.pumpWidget( + UncontrolledProviderScope(container: container, child: const App()), + ); + for (var i = 0; i < 20 && session.presence.isEmpty; i++) { + await tester.pump(); + } + + expect(session.presence, ['online']); + await tester.pumpWidget(const SizedBox.shrink()); + container.dispose(); + }); +} + +class _PublishingSession extends RelaySessionNotifier { + final presence = []; + + @override + SessionState build() => const SessionState(status: SessionStatus.connected); + + @override + Future> fetchHistory( + NostrFilter filter, { + Duration timeout = const Duration(seconds: 8), + }) async => const []; + + @override + Future> queryRelay( + List filters, { + Duration timeout = const Duration(seconds: 8), + }) async => const []; + + @override + Future subscribe( + NostrFilter filter, + void Function(NostrEvent) onEvent, { + void Function(String message)? onClosed, + }) async => () {}; + + @override + Future publish( + NostrEvent event, { + Duration timeout = const Duration(seconds: 8), + }) async { + if (event.kind == EventKind.presenceUpdate) presence.add(event.content); + return event; + } +} + +class _SignedIn extends AuthNotifier { + @override + Future build() async => + const AuthState(status: AuthStatus.authenticated); +} + +class _Allowed extends AgeSignalNotifier { + @override + AgeSignalState build() => AgeSignalState.allowed; + @override + Future request() async {} +} + +class _Config extends RelayConfigNotifier { + _Config(this.nsec); + + final String nsec; + + @override + RelayConfig build() => + RelayConfig(baseUrl: 'https://relay.example', nsec: nsec); +} + +class _Resumed extends AppLifecycleNotifier { + @override + AppLifecycleState build() => AppLifecycleState.resumed; +} + +class _NoChannels extends ChannelsNotifier { + @override + bool get hasLoaded => true; + + @override + Future> build() async => const []; +}