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 []; +}