import 'dart:async'; import 'hosted_app_gate.dart'; import 'onboarding_app_entry.dart'; import 'shared/widgets/ios_navigation_metrics.dart'; import 'shared/community/paired_community_landing.dart'; import 'shared/community/community_loading_surface.dart'; import 'package:app_badge_plus/app_badge_plus.dart'; import 'package:flutter/material.dart'; import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:uuid/uuid.dart'; import 'features/age_gate/age_restriction_page.dart'; import 'features/age_gate/age_signal_provider.dart'; import 'features/activity/activity_provider.dart'; import 'features/activity/inbox_local_state_provider.dart'; import 'features/activity/inbox_read_state.dart'; import 'features/channels/channel.dart'; import 'features/channels/channel_management_provider.dart'; import 'features/channels/channels_provider.dart'; import 'features/channels/unread_badge/unread_badge_provider.dart'; import 'features/home/home_page.dart'; import 'features/channels/me/me_page.dart'; import 'features/channels/me/me_unread_provider.dart'; import 'features/invites/invite_create_page.dart'; import 'features/invites/invite_join_provider.dart'; import 'features/pairing/pairing_page.dart'; import 'features/pairing/pairing_provider.dart'; import 'features/channels/agent_activity/observer_subscription.dart'; import 'features/channels/channel_detail_page.dart'; import 'shared/widgets/app_route_observer.dart'; import 'features/profile/user_status_cache_provider.dart'; import 'features/profile/settings_profile_header.dart'; import 'features/profile/set_status_sheet.dart'; import 'features/profile/user_status_provider.dart'; import 'features/profile/profile_edit_page.dart'; import 'features/profile/profile_text_editor.dart'; import 'features/settings/settings_page.dart'; import 'features/settings/theme_picker_page.dart'; import 'shared/auth/auth.dart'; import 'shared/deeplink/pending_deep_link_provider.dart'; import 'shared/emoji/emoji_burst.dart'; import 'shared/relay/relay.dart'; import 'shared/read_state/read_state_provider.dart'; import 'shared/theme/theme.dart'; import 'shared/widgets/buzz_loading_indicator.dart'; const _starterChannelNamespace = '3ce33bea-8f09-5f1b-9c85-8a7d2659e6b0'; const _starterChannels = [ (slug: 'general', description: 'General conversation and community updates.'), ( slug: 'welcome-everyone', description: 'Say hi, ask a question, or share what brought you here.', ), ]; final _inviteRelayConnectedProvider = FutureProvider.family(( ref, expectedRelayUrl, ) async { final currentConfig = ref.read(relayConfigProvider); if (currentConfig.baseUrl != expectedRelayUrl) { throw StateError('Active community changed before invite recovery'); } if (ref.read(relaySessionProvider).status == SessionStatus.connected) return; final connected = Completer(); ref.listen(relaySessionProvider, (_, next) { if (connected.isCompleted) return; if (ref.read(relayConfigProvider).baseUrl != expectedRelayUrl) { connected.completeError( StateError('Active community changed during invite recovery'), ); } else if (next.status == SessionStatus.connected) { connected.complete(); } }); await connected.future; }); /// App-level bridge from invite joining to the channels feature. class MobileInviteJoinRecovery implements InviteJoinRecovery { final Future> Function() _loadChannels; final Future Function({ required String channelId, required String name, required String channelType, required String visibility, String? description, int? ttlSeconds, }) _createChannel; final Future Function(String channelId) _joinChannel; final String _relayHttpOrigin; final bool Function() _isScopeCurrent; /// Creates recovery from channel-loading, creation, and join operations. MobileInviteJoinRecovery({ required Future> Function() loadChannels, required Future Function({ required String channelId, required String name, required String channelType, required String visibility, String? description, int? ttlSeconds, }) createChannel, required Future Function(String channelId) joinChannel, required String relayHttpOrigin, bool Function()? isScopeCurrent, }) : _loadChannels = loadChannels, _createChannel = createChannel, _joinChannel = joinChannel, _relayHttpOrigin = relayHttpOrigin, _isScopeCurrent = isScopeCurrent ?? _alwaysCurrent; /// Ensures memberships in the same public starter channels as desktop. /// /// All starter work is fenced to the community and identity that started it. /// A partial setup remains recoverable, so a failed operation deliberately /// propagates to the invite flow instead of being reported as success. @override Future ensureStarterChannels() async { _ensureScopeCurrent(); var channels = await _loadChannels(); _ensureScopeCurrent(); String? welcomeEveryoneId; for (final starter in _starterChannels) { _ensureScopeCurrent(); var channel = _findStarterChannel(channels, starter.slug); if (channel == null) { final channelId = desktopStarterChannelId( relayHttpOrigin: _relayHttpOrigin, slug: starter.slug, ); try { channel = await _createChannel( channelId: channelId, name: starter.slug, channelType: 'stream', visibility: 'open', description: starter.description, ); _ensureScopeCurrent(); } catch (error) { if (!_isDuplicateChannelError(error)) rethrow; _ensureScopeCurrent(); channels = await _loadChannels(); _ensureScopeCurrent(); channel = _findStarterChannel(channels, starter.slug) ?? channels .where((candidate) => candidate.id == channelId) .firstOrNull; if (channel == null) rethrow; } } _ensureScopeCurrent(); if (!channel.isMember) { await _joinChannel(channel.id); _ensureScopeCurrent(); } if (starter.slug == 'welcome-everyone') { welcomeEveryoneId = channel.id; } } return welcomeEveryoneId; } static bool _alwaysCurrent() => true; void _ensureScopeCurrent() { if (!_isScopeCurrent()) { throw StateError('Active community changed during invite recovery'); } } } Channel? _findStarterChannel(List channels, String name) { final normalizedName = name.trim().toLowerCase(); return channels .where( (channel) => channel.name.trim().toLowerCase() == normalizedName && channel.isStream && channel.visibility == 'open' && !channel.isArchived, ) .firstOrNull; } bool _isDuplicateChannelError(Object error) => error.toString().contains('duplicate: channel already exists'); /// Returns desktop's deterministic per-relay UUID for a public starter. @visibleForTesting String desktopStarterChannelId({ required String relayHttpOrigin, required String slug, }) { final scope = relayHttpOrigin.trim().replaceFirst(RegExp(r'/+$'), ''); return const Uuid().v5( _starterChannelNamespace, 'starter-channel:v1:$scope:$slug', ); } /// Builds one fresh, identity-scoped invite recovery against the app container. InviteJoinRecovery buildMobileInviteJoinRecovery( Ref ref, InviteJoinRecoveryScope scope, ) { final expectedConfig = ref.read(relayConfigProvider); if (expectedConfig.baseUrl != scope.relayHttpOrigin || expectedConfig.nsec != scope.nsec) { throw StateError('Active community changed before invite recovery'); } final channelActions = ref.read(channelActionsProvider); bool isScopeCurrent() { final currentConfig = ref.read(relayConfigProvider); return currentConfig.baseUrl == scope.relayHttpOrigin && currentConfig.nsec == scope.nsec; } void ensureScopeCurrent() { if (!isScopeCurrent()) { throw StateError('Active community changed during invite recovery'); } } return MobileInviteJoinRecovery( loadChannels: () async { ensureScopeCurrent(); await ref.read(activeCommunityProvider.future); ensureScopeCurrent(); await ref .read(_inviteRelayConnectedProvider(scope.relayHttpOrigin).future) .timeout(const Duration(seconds: 15)); ensureScopeCurrent(); final channels = await ref .read(channelsProvider.notifier) .fetchFresh(fetchDirectory: true); ensureScopeCurrent(); return channels; }, createChannel: ({ required channelId, required name, required channelType, required visibility, description, ttlSeconds, }) => channelActions.createChannel( channelId: channelId, name: name, channelType: channelType, visibility: visibility, description: description, ttlSeconds: ttlSeconds, ), joinChannel: channelActions.joinChannel, relayHttpOrigin: scope.relayHttpOrigin, isScopeCurrent: isScopeCurrent, ); } /// App-shell projection that joins Activity state for the Home navigation. /// /// This belongs at the composition root because it deliberately aggregates /// Activity feature providers for a sibling navigation surface. final _unreadInboxItemCountProvider = Provider((ref) { final readState = ref.watch(readStateProvider); if (!readState.isReady) return 0; final localState = ref.watch(inboxLocalStateProvider); final items = ref.watch(inboxItemsProvider); return items .where( (item) => !isInboxItemDone( item, markerOf: readState.effectiveTimestamp, localUnreadOverrides: localState.unreadIds, localDoneSet: localState.doneIds, ), ) .length; }); final _mobileRootNavigatorKey = GlobalKey(); class App extends HookConsumerWidget { const App({super.key}); @override Widget build(BuildContext context, WidgetRef ref) { final ageSignalState = ref.watch(ageSignalProvider); ref.listen(ageSignalProvider, (_, next) { if (next == AgeSignalState.restricted) { ref.read(pairingProvider.notifier).reset(); } }); final communityTheme = ageSignalState != AgeSignalState.restricted ? ref.watch(communityThemeProvider) : defaultCommunityTheme; final themeMode = communityTheme.mode; final authState = ref.watch(authProvider); final pairedCommunity = ref.watch(pairedCommunityLandingProvider); useEffect(() { WidgetsBinding.instance.addPostFrameCallback((_) { unawaited(ref.read(ageSignalProvider.notifier).request()); }); return null; }, const []); // Eagerly initialize websocket session and lifecycle observer when // authenticated. These providers connect and manage the websocket. var hasUnreadInbox = false; var hasUnreadSolo = false; if (ageSignalState != AgeSignalState.restricted && authState.value?.status == AuthStatus.authenticated) { ref.watch(relaySessionProvider); // Listen, not watch: these publish often, and an App rebuild rebuilds // every route, including an open chat. ref.listen(observerRelayProvider, (_, _) {}); ref.watch(appLifecycleProvider); ref.listen(userStatusCacheProvider, (_, _) {}); hasUnreadInbox = ref.watch(_unreadInboxItemCountProvider) > 0; hasUnreadSolo = ref.watch( meUnreadChannelIdsProvider.select((ids) => ids.isNotEmpty), ); } // Start listening for buzz:// links immediately (even pre-auth) so a // cold-start link survives until the authenticated UI can dispatch it. ref.watch(pendingDeepLinkProvider); void applyBadge(UnreadBadgeState state) { if (state.highPriorityCount > 0) { AppBadgePlus.updateBadge(state.highPriorityCount); } else if (state.generalUnreadCount > 0) { AppBadgePlus.updateBadge(1); } else { AppBadgePlus.updateBadge(0); } } useEffect(() { if (ageSignalState != AgeSignalState.restricted) { applyBadge(ref.read(unreadBadgeProvider)); } else { AppBadgePlus.updateBadge(0); } return null; }, [ageSignalState]); if (ageSignalState != AgeSignalState.restricted) { ref.listen(unreadBadgeProvider, (_, next) { applyBadge(next); }); } return MaterialApp( navigatorKey: _mobileRootNavigatorKey, navigatorObservers: [appRouteObserver], title: 'Buzz', theme: mobileAppearanceTheme(Brightness.light, communityTheme.accent), darkTheme: mobileAppearanceTheme(Brightness.dark, communityTheme.accent), themeMode: themeMode, // Above the navigator, so an age restriction cannot be bypassed by a // route that was pushed while the store signal request was in flight. builder: (context, child) => switch (ageSignalState) { AgeSignalState.restricted => const AgeRestrictionPage(), _ => IosNavigationMetricsHost( child: AppMarkdownTheme( child: MobileHuddleShell( navigatorKey: _mobileRootNavigatorKey, child: EmojiBurstOverlay( child: Stack( fit: StackFit.expand, children: [ ExcludeSemantics( excluding: pairedCommunity != null, child: AbsorbPointer( absorbing: pairedCommunity != null, child: child ?? const SizedBox.shrink(), ), ), // Cover the navigator while onboarding is removed and the // destination installs the matching avatar-flight route. if (pairedCommunity != null) CommunityLoadingSurface( name: pairedCommunity.name, relayUrl: pairedCommunity.relayUrl, ), ], ), ), ), ), ), }, home: authState.when( loading: () => const _SplashScreen(), error: (_, _) => const PairingPage(), data: (state) => switch (state.status) { AuthStatus.authenticated => HostedAppGate( authenticated: true, child: HomePage( mePageBuilder: (_, action) => MePage(newChatAction: action), settingsPageBuilder: _buildSettingsPage, communityInvitePageBuilder: (_) => const CommunityInvitePage(), communityAppearancePageBuilder: (_) => const ThemePickerPage(), hasUnreadInbox: hasUnreadInbox, hasUnreadSolo: hasUnreadSolo, ), ), _ => const HostedAppGate( authenticated: false, child: OnboardingAppEntry(), ), }, ), ); } } Widget _buildSettingsPage(BuildContext context) => const _SettingsPageContent(); class _SettingsPageContent extends ConsumerWidget { const _SettingsPageContent(); @override Widget build(BuildContext context, WidgetRef ref) { return SettingsPage( profileHeader: const SettingsProfileHeader(), profileEditPageBuilder: (_) => const ProfileEditPage(startInPhotoEditor: true), onSetStatus: (context) => showSetStatusSheet( context, currentStatus: ref.read(userStatusProvider).asData?.value, ), onEditDisplayName: showProfileDisplayNameEditor, onEditProfileDescription: showProfileDescriptionEditor, identityRecoveryPageBuilder: (_) => const PairingPage(addingCommunity: true, identityRecoveryOnly: true), ); } } class _SplashScreen extends StatelessWidget { const _SplashScreen(); @override Widget build(BuildContext context) { return const Scaffold( body: Center( child: BuzzLoadingIndicator(size: 56, semanticLabel: 'Starting Buzz'), ), ); } }