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 <tomb@squareup.com>
Co-authored-by: Tom Brow <tomb@squareup.com>
This commit is contained in:
Tom Brow
2026-11-12 22:58:03 -08:00
committed by GitHub
parent 2b428f56b8
commit 3d3f9cf153
2 changed files with 121 additions and 0 deletions

View File

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

View File

@@ -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 = <String>[];
@override
SessionState build() => const SessionState(status: SessionStatus.connected);
@override
Future<List<NostrEvent>> fetchHistory(
NostrFilter filter, {
Duration timeout = const Duration(seconds: 8),
}) async => const [];
@override
Future<List<NostrEvent>> queryRelay(
List<NostrFilter> filters, {
Duration timeout = const Duration(seconds: 8),
}) async => const [];
@override
Future<void Function()> subscribe(
NostrFilter filter,
void Function(NostrEvent) onEvent, {
void Function(String message)? onClosed,
}) async => () {};
@override
Future<NostrEvent> 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<AuthState> build() async =>
const AuthState(status: AuthStatus.authenticated);
}
class _Allowed extends AgeSignalNotifier {
@override
AgeSignalState build() => AgeSignalState.allowed;
@override
Future<void> 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<List<Channel>> build() async => const [];
}