mirror of
https://github.com/block/buzz.git
synced 2026-11-13 21:01:53 +00:00
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:
@@ -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.
|
||||
|
||||
118
mobile/test/features/profile/presence_shell_test.dart
Normal file
118
mobile/test/features/profile/presence_shell_test.dart
Normal 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 [];
|
||||
}
|
||||
Reference in New Issue
Block a user