import { type ComponentProps, type ComponentType, type JSX, useCallback, useRef, useState, } from 'react' import * as Linking from 'expo-linking' import * as Notifications from 'expo-notifications' import {i18n, type MessageDescriptor} from '@lingui/core' import {msg} from '@lingui/core/macro' import { type BottomTabBarProps, createBottomTabNavigator, } from '@react-navigation/bottom-tabs' import { CommonActions, createNavigationContainerRef, DarkTheme, DefaultTheme, type LinkingOptions, NavigationContainer, StackActions, } from '@react-navigation/native' import {timeout} from '#/lib/async/timeout' import {useAccountSwitcher} from '#/lib/hooks/useAccountSwitcher' import {useColorSchemeStyle} from '#/lib/hooks/useColorSchemeStyle' import {useNonReactiveCallback} from '#/lib/hooks/useNonReactiveCallback' import { type ChatNotificationPayload, getNotificationPayload, isChatNotificationPayload, notificationToURL, storePayloadForAccountSwitch, } from '#/lib/hooks/useNotificationHandler' import {useTitleUnreadCountLabel} from '#/lib/hooks/useTitleUnreadCountLabel' import {useWebScrollRestoration} from '#/lib/hooks/useWebScrollRestoration' import {useCallOnce} from '#/lib/once' import {buildStateObject, getCurrentRoute} from '#/lib/routes/helpers' import { type AllNavigatorParams, type BottomTabNavigatorParams, type FlatNavigatorParams, type HomeTabNavigatorParams, type MessagesTabNavigatorParams, type MyProfileTabNavigatorParams, type NotificationsTabNavigatorParams, type RouteParams, type SearchTabNavigatorParams, type State, } from '#/lib/routes/types' import {bskyTitle} from '#/lib/strings/headings' import {CHAT_INVITE_CODE_REGEX} from '#/lib/strings/url-helpers' import {useDisableVerifyEmailReminder} from '#/state/preferences/disable-verify-email-reminder' import {useSession} from '#/state/session' import {useLoggedOutViewControls} from '#/state/shell/logged-out' import { shouldRequestEmailConfirmation, snoozeEmailConfirmationPrompt, } from '#/state/shell/reminders' import {useCloseAllActiveElements} from '#/state/util' import {CommunityGuidelinesScreen} from '#/view/screens/CommunityGuidelines' import {CopyrightPolicyScreen} from '#/view/screens/CopyrightPolicy' import {DebugModScreen} from '#/view/screens/DebugMod' import {FeedsScreen} from '#/view/screens/Feeds' import {HomeScreen} from '#/view/screens/Home' import {ListsScreen} from '#/view/screens/Lists' import {ModerationBlockedAccounts} from '#/view/screens/ModerationBlockedAccounts' import {ModerationModlistsScreen} from '#/view/screens/ModerationModlists' import {ModerationMutedAccounts} from '#/view/screens/ModerationMutedAccounts' import {NotFoundScreen} from '#/view/screens/NotFound' import {NotificationsScreen} from '#/view/screens/Notifications' import {PostThreadScreen} from '#/view/screens/PostThread' import {PrivacyPolicyScreen} from '#/view/screens/PrivacyPolicy' import {ProfileScreen} from '#/view/screens/Profile' import {StorybookScreen} from '#/view/screens/Storybook' import {SupportScreen} from '#/view/screens/Support' import {TermsOfServiceScreen} from '#/view/screens/TermsOfService' import {BottomBar} from '#/view/shell/bottom-bar/BottomBar' import {createNativeStackNavigatorWithAuth} from '#/view/shell/createNativeStackNavigatorWithAuth' import {CustomFeedScreen} from '#/screens/CustomFeed' import {CustomFeedLikedByScreen} from '#/screens/CustomFeed/CustomFeedLikedBy' import HashtagScreen from '#/screens/Hashtag' import {AuthCallback} from '#/screens/Login/AuthCallback' import {MessagesScreen} from '#/screens/Messages/ChatList' import {renderMessagesSplitViewLayout} from '#/screens/Messages/components/splitView/MessagesSplitViewLayout' import {MessagesConversationScreen} from '#/screens/Messages/Conversation' import {MessagesConversationSettingsScreen} from '#/screens/Messages/ConversationSettings' import {MessagesInboxScreen} from '#/screens/Messages/Inbox' import {MessagesJoinRequestsScreen} from '#/screens/Messages/JoinRequests' import {MessagesSettingsScreen} from '#/screens/Messages/Settings' import {ModerationScreen} from '#/screens/Moderation' import {Screen as ModerationVerificationSettings} from '#/screens/Moderation/VerificationSettings' import {ModerationInboxScreen} from '#/screens/ModerationInbox' import {ModerationInboxReportDetailsScreen} from '#/screens/ModerationInbox/Report' import {ModerationInboxSettingsScreen} from '#/screens/ModerationInbox/Settings' import {ModerationInboxSubjectDetailsScreen} from '#/screens/ModerationInbox/Subject' import {Screen as ModerationInteractionSettings} from '#/screens/ModerationInteractionSettings' import {NotificationsActivityListScreen} from '#/screens/Notifications/ActivityList' import {PostLikedByScreen} from '#/screens/Post/PostLikedBy' import {PostQuotesScreen} from '#/screens/Post/PostQuotes' import {PostRepostedByScreen} from '#/screens/Post/PostRepostedBy' import {PostQuoteChainScreen} from '#/screens/Post/QuoteChain' import {ProfileKnownFollowersScreen} from '#/screens/Profile/KnownFollowers' import {ProfileFollowersScreen} from '#/screens/Profile/ProfileFollowers' import {ProfileFollowsScreen} from '#/screens/Profile/ProfileFollows' import {ProfileLabelerLikedByScreen} from '#/screens/Profile/ProfileLabelerLikedBy' import {ProfileSearchScreen} from '#/screens/Profile/ProfileSearch' import {ProfileListScreen} from '#/screens/ProfileList' import {SavedFeeds} from '#/screens/SavedFeeds' import {SearchScreen} from '#/screens/Search' import TopicScreen from '#/screens/Topic' import {type Theme, useTheme} from '#/alf' import { EmailDialogScreenID, useEmailDialogControl, } from '#/components/dialogs/EmailDialog' import {EphemeralLoginHost} from '#/components/EphemeralLoginHost' import {useAnalytics} from '#/analytics' import {setNavigationMetadata} from '#/analytics/metadata' import {IS_LIQUID_GLASS, IS_NATIVE, IS_WEB} from '#/env' import {router} from '#/routes' import {Referrer} from '../modules/expo-bluesky-swiss-army' function deferredNamedScreen< Name extends string, // Screen route props are intentionally heterogeneous. // eslint-disable-next-line @typescript-eslint/no-explicit-any Module extends Record>, >(load: () => Module, exportName: Name) { type Screen = Module[Name] let Screen: Screen | undefined return function DeferredScreen(props: ComponentProps) { Screen ??= load()[exportName] return } } const BookmarksScreen = deferredNamedScreen( () => require('#/screens/Bookmarks'), 'BookmarksScreen', ) const SharedPreferencesTesterScreen = deferredNamedScreen( () => require('#/screens/E2E/SharedPreferencesTesterScreen'), 'SharedPreferencesTesterScreen', ) const FindContactsFlowScreen = deferredNamedScreen( () => require('#/screens/FindContactsFlowScreen'), 'FindContactsFlowScreen', ) const LogScreen = deferredNamedScreen( () => require('#/screens/Log'), 'LogScreen', ) const AboutSettingsScreen = deferredNamedScreen( () => require('#/screens/Settings/AboutSettings'), 'AboutSettingsScreen', ) const AccessibilitySettingsScreen = deferredNamedScreen( () => require('#/screens/Settings/AccessibilitySettings'), 'AccessibilitySettingsScreen', ) const AltTextAiSettingsScreen = deferredNamedScreen( () => require('#/screens/Settings/AltTextAiSettings'), 'AltTextAiSettingsScreen', ) const AccountSettingsScreen = deferredNamedScreen( () => require('#/screens/Settings/AccountSettings'), 'AccountSettingsScreen', ) const ActivityPrivacySettingsScreen = deferredNamedScreen( () => require('#/screens/Settings/ActivityPrivacySettings'), 'ActivityPrivacySettingsScreen', ) const ActivityNotificationSettingsScreen = deferredNamedScreen( () => require('#/screens/Settings/NotificationSettings/ActivityNotificationSettings'), 'ActivityNotificationSettingsScreen', ) const AppearanceSettingsScreen = deferredNamedScreen( () => require('#/screens/Settings/AppearanceSettings'), 'AppearanceSettingsScreen', ) const AppearanceColorThemeSettingsScreen = deferredNamedScreen( () => require('#/screens/Settings/AppearanceSettings/ColorThemeSettings'), 'AppearanceColorThemeSettingsScreen', ) const ThemeGalleryScreen = deferredNamedScreen( () => require('#/screens/Settings/AppearanceSettings/ThemeGallery'), 'ThemeGalleryScreen', ) const ThemeEditorScreen = deferredNamedScreen( () => require('#/screens/Settings/AppearanceSettings/ThemeEditor'), 'ThemeEditorScreen', ) const ThemeScreen = deferredNamedScreen( () => require('#/screens/Theme'), 'ThemeScreen', ) const AppIconSettingsScreen = deferredNamedScreen( () => require('#/screens/Settings/AppIconSettings'), 'AppIconSettingsScreen', ) const AppPasswordsScreen = deferredNamedScreen( () => require('#/screens/Settings/AppPasswords'), 'AppPasswordsScreen', ) const AutomationLabelSettingsScreen = deferredNamedScreen( () => require('#/screens/Settings/AutomationLabelSettings'), 'AutomationLabelSettingsScreen', ) const BetaFeaturesSettingsScreen = deferredNamedScreen( () => require('#/screens/Settings/BetaFeaturesSettings'), 'BetaFeaturesSettingsScreen', ) const ContentAndMediaSettingsScreen = deferredNamedScreen( () => require('#/screens/Settings/ContentAndMediaSettings'), 'ContentAndMediaSettingsScreen', ) const ExternalMediaPreferencesScreen = deferredNamedScreen( () => require('#/screens/Settings/ExternalMediaPreferences'), 'ExternalMediaPreferencesScreen', ) const FindContactsSettingsScreen = deferredNamedScreen( () => require('#/screens/Settings/FindContactsSettings'), 'FindContactsSettingsScreen', ) const FollowingFeedPreferencesScreen = deferredNamedScreen( () => require('#/screens/Settings/FollowingFeedPreferences'), 'FollowingFeedPreferencesScreen', ) const InterestsSettingsScreen = deferredNamedScreen( () => require('#/screens/Settings/InterestsSettings'), 'InterestsSettingsScreen', ) const LanguageSettingsScreen = deferredNamedScreen( () => require('#/screens/Settings/LanguageSettings'), 'LanguageSettingsScreen', ) const LegacyNotificationSettingsScreen = deferredNamedScreen( () => require('#/screens/Settings/LegacyNotificationSettings'), 'LegacyNotificationSettingsScreen', ) const NotificationSettingsScreen = deferredNamedScreen( () => require('#/screens/Settings/NotificationSettings'), 'NotificationSettingsScreen', ) const PetLabelSettingsScreen = deferredNamedScreen( () => require('#/screens/Settings/PetLabelSettings'), 'PetLabelSettingsScreen', ) const PrivacyAndSecuritySettingsScreen = deferredNamedScreen( () => require('#/screens/Settings/PrivacyAndSecuritySettings'), 'PrivacyAndSecuritySettingsScreen', ) const RunesSettingsScreen = deferredNamedScreen( () => require('#/screens/Settings/RunesSettings'), 'RunesSettingsScreen', ) const RunesDisplayAlsoLikedSettingsScreen = deferredNamedScreen( () => require('#/screens/Settings/RunesSettings/AlsoLikedSettings'), 'RunesDisplayAlsoLikedSettingsScreen', ) const RunesBadgesSettingsScreen = deferredNamedScreen( () => require('#/screens/Settings/RunesSettings/BadgesSettings'), 'RunesBadgesSettingsScreen', ) const RunesDisplaySettingsScreen = deferredNamedScreen( () => require('#/screens/Settings/RunesSettings/DisplaySettings'), 'RunesDisplaySettingsScreen', ) const RunesExtraSettingsScreen = deferredNamedScreen( () => require('#/screens/Settings/RunesSettings/ExtraSettings'), 'RunesExtraSettingsScreen', ) const RunesExtraFeatureGatesSettingsScreen = deferredNamedScreen( () => require('#/screens/Settings/RunesSettings/FeatureGatesSettings'), 'RunesExtraFeatureGatesSettingsScreen', ) const RunesUsabilityFeedSettingsScreen = deferredNamedScreen( () => require('#/screens/Settings/RunesSettings/FeedSettings'), 'RunesUsabilityFeedSettingsScreen', ) const RunesImpressionsSettingsScreen = deferredNamedScreen( () => require('#/screens/Settings/RunesSettings/ImpressionsSettings'), 'RunesImpressionsSettingsScreen', ) const RunesInfrastructureSettingsScreen = deferredNamedScreen( () => require('#/screens/Settings/RunesSettings/InfrastructureSettings'), 'RunesInfrastructureSettingsScreen', ) const RunesMenusSettingsScreen = deferredNamedScreen( () => require('#/screens/Settings/RunesSettings/MenusSettings'), 'RunesMenusSettingsScreen', ) const RunesUsabilityProfileSettingsScreen = deferredNamedScreen( () => require('#/screens/Settings/RunesSettings/ProfileSettings'), 'RunesUsabilityProfileSettingsScreen', ) const RunesSettingsSyncSettingsScreen = deferredNamedScreen( () => require('#/screens/Settings/RunesSettings/SettingsSyncSettings'), 'RunesSettingsSyncSettingsScreen', ) const RunesUsabilitySettingsScreen = deferredNamedScreen( () => require('#/screens/Settings/RunesSettings/UsabilitySettings'), 'RunesUsabilitySettingsScreen', ) const SettingsScreen = deferredNamedScreen( () => require('#/screens/Settings/Settings'), 'SettingsScreen', ) const ThreadPreferencesScreen = deferredNamedScreen( () => require('#/screens/Settings/ThreadPreferences'), 'ThreadPreferencesScreen', ) const StarterPackScreen = deferredNamedScreen( () => require('#/screens/StarterPack/StarterPackScreen'), 'StarterPackScreen', ) const StarterPackScreenShort = deferredNamedScreen( () => require('#/screens/StarterPack/StarterPackScreen'), 'StarterPackScreenShort', ) const Wizard = deferredNamedScreen( () => require('#/screens/StarterPack/Wizard'), 'Wizard', ) const VideoFeed = deferredNamedScreen( () => require('#/screens/VideoFeed'), 'VideoFeed', ) const InviteScannerScreen = deferredNamedScreen( () => require('#/features/inviteFriends'), 'InviteScannerScreen', ) const navigationRef = createNavigationContainerRef() /** * Stores the navigation state before account switch on native. * This allows the user to stay on the same page when switching accounts. */ let storedNavigationStateForAccountSwitch: State | undefined export function storeNavigationStateForAccountSwitch() { if (IS_NATIVE && navigationRef.isReady()) { storedNavigationStateForAccountSwitch = navigationRef.getRootState() } } function consumeStoredNavigationState(): State | undefined { const state = storedNavigationStateForAccountSwitch storedNavigationStateForAccountSwitch = undefined return state } const HomeTab = createNativeStackNavigatorWithAuth() const SearchTab = createNativeStackNavigatorWithAuth() const NotificationsTab = createNativeStackNavigatorWithAuth() const MyProfileTab = createNativeStackNavigatorWithAuth() const MessagesTab = createNativeStackNavigatorWithAuth() const Flat = createNativeStackNavigatorWithAuth() const Tab = createBottomTabNavigator() /** * These "common screens" are reused across stacks. */ function commonScreens(Stack: typeof Flat, unreadCountLabel?: string) { const title = (page: MessageDescriptor) => bskyTitle(i18n._(page), unreadCountLabel) return ( <> NotFoundScreen} options={{title: title(msg`Not Found`)}} /> AuthCallback} options={{title: title(msg`Signing in...`)}} /> ModerationScreen} options={{title: title(msg`Moderation`), requireAuth: true}} /> ModerationInboxScreen} options={{title: title(msg`Moderation inbox`), requireAuth: true}} /> ModerationInboxSettingsScreen} options={{title: title(msg`Mod inbox settings`), requireAuth: true}} /> ModerationInboxReportDetailsScreen} options={{title: title(msg`Your report`), requireAuth: true}} /> ModerationInboxSubjectDetailsScreen} options={{title: title(msg`Notice`), requireAuth: true}} /> ModerationModlistsScreen} options={{title: title(msg`Moderation Lists`), requireAuth: true}} /> ModerationMutedAccounts} options={{title: title(msg`Muted Accounts`), requireAuth: true}} /> ModerationBlockedAccounts} options={{title: title(msg`Blocked Accounts`), requireAuth: true}} /> ModerationInteractionSettings} options={{ title: title(msg`Post Interaction Settings`), requireAuth: true, }} /> ModerationVerificationSettings} options={{ title: title(msg`Verification Settings`), requireAuth: true, }} /> SettingsScreen} options={{title: title(msg`Settings`), requireAuth: true}} /> LanguageSettingsScreen} options={{title: title(msg`Language Settings`), requireAuth: true}} /> ProfileScreen} options={({route}) => ({ title: bskyTitle(`@${route.params.name}`, unreadCountLabel), })} /> ProfileFollowersScreen} options={({route}) => ({ title: title(msg`People following @${route.params.name}`), })} /> ProfileFollowsScreen} options={({route}) => ({ title: title(msg`People followed by @${route.params.name}`), })} /> ProfileKnownFollowersScreen} options={({route}) => ({ title: title(msg`Followers of @${route.params.name} that you know`), })} /> ProfileListScreen} options={{title: title(msg`List`), requireAuth: true}} /> ProfileSearchScreen} options={({route}) => ({ title: title(msg`Search @${route.params.name}'s posts`), })} /> PostThreadScreen} options={({route}) => ({ title: title(msg`Post by @${route.params.name}`), })} /> PostLikedByScreen} options={({route}) => ({ title: title(msg`Post by @${route.params.name}`), })} /> PostRepostedByScreen} options={({route}) => ({ title: title(msg`Post by @${route.params.name}`), })} /> PostQuoteChainScreen} options={{title: title(msg`Quote chain`)}} /> PostQuotesScreen} options={({route}) => ({ title: title(msg`Post by @${route.params.name}`), })} /> CustomFeedScreen} options={{title: title(msg`Feed`)}} /> CustomFeedLikedByScreen} options={{title: title(msg`Liked by`)}} /> ProfileLabelerLikedByScreen} options={{title: title(msg`Liked by`)}} /> StorybookScreen} options={{title: title(msg`Storybook`), requireAuth: true}} /> DebugModScreen} options={{title: title(msg`Moderation states`), requireAuth: true}} /> InviteScannerScreen} options={{title: title(msg`Scan QR code`), requireAuth: true}} /> SharedPreferencesTesterScreen} options={{title: title(msg`Shared Preferences Tester`)}} /> LogScreen} options={{title: title(msg`Log`), requireAuth: true}} /> SupportScreen} options={{title: title(msg`Support`)}} /> PrivacyPolicyScreen} options={{title: title(msg`Privacy Policy`)}} /> TermsOfServiceScreen} options={{title: title(msg`Terms of Service`)}} /> CommunityGuidelinesScreen} options={{title: title(msg`Community Guidelines`)}} /> CopyrightPolicyScreen} options={{title: title(msg`Copyright Policy`)}} /> AppPasswordsScreen} options={{title: title(msg`App Passwords`), requireAuth: true}} /> SavedFeeds} options={{title: title(msg`Edit My Feeds`), requireAuth: true}} /> FollowingFeedPreferencesScreen} options={{ title: title(msg`Following Feed Preferences`), requireAuth: true, }} /> ThreadPreferencesScreen} options={{title: title(msg`Threads Preferences`), requireAuth: true}} /> ExternalMediaPreferencesScreen} options={{ title: title(msg`External Media Preferences`), requireAuth: true, }} /> AccessibilitySettingsScreen} options={{ title: title(msg`Accessibility Settings`), requireAuth: true, }} /> AltTextAiSettingsScreen} options={{ title: title(msg`Automatic alt text generation`), requireAuth: true, }} /> RunesSettingsScreen} options={{ title: title(msg`Runes`), requireAuth: true, }} /> RunesMenusSettingsScreen} options={{title: title(msg`Menus`), requireAuth: true}} /> RunesBadgesSettingsScreen} options={{title: title(msg`Badges`), requireAuth: true}} /> RunesImpressionsSettingsScreen} options={{title: title(msg`Impressions`), requireAuth: true}} /> RunesUsabilitySettingsScreen} options={{title: title(msg`Usability`), requireAuth: true}} /> RunesUsabilityFeedSettingsScreen} options={{title: title(msg`Feeds`), requireAuth: true}} /> RunesUsabilityProfileSettingsScreen} options={{title: title(msg`Profiles`), requireAuth: true}} /> RunesDisplaySettingsScreen} options={{title: title(msg`Display`), requireAuth: true}} /> RunesDisplayAlsoLikedSettingsScreen} options={{title: title(msg`Also liked`), requireAuth: true}} /> RunesInfrastructureSettingsScreen} options={{title: title(msg`Infrastructure`)}} /> RunesExtraSettingsScreen} options={{title: title(msg`Extra settings`), requireAuth: true}} /> RunesExtraFeatureGatesSettingsScreen} options={{title: title(msg`Feature gates`), requireAuth: true}} /> RunesSettingsSyncSettingsScreen} options={{title: title(msg`Settings sync`), requireAuth: true}} /> AppearanceSettingsScreen} options={{ title: title(msg`Appearance`), requireAuth: true, }} /> AppearanceColorThemeSettingsScreen} options={{ title: title(msg`Color Theme`), requireAuth: true, }} /> ThemeGalleryScreen} options={{title: title(msg`Themes`), requireAuth: true}} /> ThemeEditorScreen} options={{title: title(msg`Theme editor`), requireAuth: true}} /> ThemeScreen} options={{title: title(msg`Theme`)}} /> AccountSettingsScreen} options={{ title: title(msg`Account`), requireAuth: true, }} /> BetaFeaturesSettingsScreen} options={{ title: title(msg`Beta features`), requireAuth: true, }} /> AutomationLabelSettingsScreen} options={{ title: title(msg`Automation Label`), requireAuth: true, }} /> PetLabelSettingsScreen} options={{ title: title(msg`Pet Label`), requireAuth: true, }} /> PrivacyAndSecuritySettingsScreen} options={{ title: title(msg`Privacy and Security`), requireAuth: true, }} /> ActivityPrivacySettingsScreen} options={{ title: title(msg`Privacy and Security`), requireAuth: true, }} /> FindContactsSettingsScreen} options={{ title: title(msg`Find Contacts`), requireAuth: true, }} /> NotificationSettingsScreen} options={{title: title(msg`Notification settings`), requireAuth: true}} /> ActivityNotificationSettingsScreen} options={{ title: title(msg`Activity notifications`), requireAuth: true, }} /> ContentAndMediaSettingsScreen} options={{ title: title(msg`Content and Media`), requireAuth: true, }} /> InterestsSettingsScreen} options={{ title: title(msg`Your interests`), requireAuth: true, }} /> AboutSettingsScreen} options={{ title: title(msg`About`), requireAuth: true, }} /> AppIconSettingsScreen} options={{ title: title(msg`App Icon`), requireAuth: true, }} /> HashtagScreen} options={{title: title(msg`Hashtag`)}} /> TopicScreen} options={{title: title(msg`Topic`)}} /> MessagesConversationScreen} options={{title: title(msg`Chat`), requireAuth: true}} /> MessagesConversationSettingsScreen} options={{title: title(msg`Group chat settings`), requireAuth: true}} /> MessagesJoinRequestsScreen} options={{title: title(msg`Requests to join`), requireAuth: true}} /> MessagesSettingsScreen} options={{title: title(msg`Chat settings`), requireAuth: true}} /> MessagesInboxScreen} options={{title: title(msg`Chat request inbox`), requireAuth: true}} /> NotificationsActivityListScreen} options={{title: title(msg`Notifications`), requireAuth: true}} /> LegacyNotificationSettingsScreen} options={{title: title(msg`Notification settings`), requireAuth: true}} /> FeedsScreen} options={{title: title(msg`Feeds`)}} /> StarterPackScreen} options={{title: title(msg`Starter Pack`)}} /> StarterPackScreenShort} options={{title: title(msg`Starter Pack`)}} /> Wizard} options={{title: title(msg`Create a Starter Pack`), requireAuth: true}} /> Wizard} options={{title: title(msg`Edit your Starter Pack`), requireAuth: true}} /> VideoFeed} options={{ title: title(msg`Video Feed`), requireAuth: true, }} /> BookmarksScreen} options={{ title: title(msg`Saved Posts`), requireAuth: true, }} /> FindContactsFlowScreen} options={{ title: title(msg`Find Contacts`), requireAuth: true, gestureEnabled: false, }} /> ) } /** * The TabsNavigator is used by native mobile to represent the routes * in 3 distinct tab-stacks with a different root screen on each. */ function TabsNavigator({ layout, }: { layout: React.ComponentProps['layout'] }) { const tabBar = useCallback( (props: JSX.IntrinsicAttributes & BottomTabBarProps) => ( ), [], ) return ( HomeTabNavigator} /> SearchTabNavigator} /> MessagesTabNavigator} /> NotificationsTabNavigator} /> MyProfileTabNavigator} /> ) } function screenOptions(t: Theme) { return { fullScreenGestureEnabled: true, headerShown: false, contentStyle: t.atoms.bg, } as const } function HomeTabNavigator() { const t = useTheme() const BLURRED_SCROLL_EDGE_EFFECT = IS_LIQUID_GLASS ? ({ headerShown: true, headerTransparent: true, headerTitle: '', headerBackVisible: false, scrollEdgeEffects: { top: 'soft', }, } as const) : {} return ( HomeScreen} options={BLURRED_SCROLL_EDGE_EFFECT} /> HomeScreen} options={BLURRED_SCROLL_EDGE_EFFECT} /> {commonScreens(HomeTab as typeof Flat)} ) } function SearchTabNavigator() { const t = useTheme() return ( SearchScreen} /> {commonScreens(SearchTab as typeof Flat)} ) } function NotificationsTabNavigator() { const t = useTheme() return ( NotificationsScreen} options={{requireAuth: true}} /> {commonScreens(NotificationsTab as typeof Flat)} ) } function MyProfileTabNavigator() { const t = useTheme() return ( ProfileScreen} initialParams={{name: 'me', hideBackButton: true}} /> {commonScreens(MyProfileTab as unknown as typeof Flat)} ) } function MessagesTabNavigator() { const t = useTheme() return ( MessagesScreen} options={({route}) => ({ requireAuth: true, animationTypeForReplace: route.params?.animation ?? 'push', })} /> {commonScreens(MessagesTab as typeof Flat)} ) } /** * The FlatNavigator is used by Web to represent the routes * in a single ("flat") stack. */ const FlatNavigator = ({ layout, }: { layout: React.ComponentProps['layout'] }) => { const t = useTheme() const unreadCountLabel = useTitleUnreadCountLabel() const screenListeners = useWebScrollRestoration() const title = (page: MessageDescriptor) => bskyTitle(i18n._(page), unreadCountLabel) return ( HomeScreen} options={{title: title(msg`Home`)}} /> SearchScreen} options={{title: title(msg`Explore`)}} /> NotificationsScreen} options={{title: title(msg`Notifications`), requireAuth: true}} /> MessagesScreen} options={{title: title(msg`Messages`), requireAuth: true}} layout={renderMessagesSplitViewLayout} /> HomeScreen} options={{title: title(msg`Home`)}} /> {commonScreens(Flat, unreadCountLabel)} ) } /** * The RoutesContainer should wrap all components which need access * to the navigation context. */ const LINKING = { // TODO figure out what we are going to use // note: `bluesky://` and `witchsky://` and `deersocial://` is what is used in app.config.js prefixes: [ 'bsky://', 'bluesky://', 'witchsky://', 'deersocial://', 'https://bsky.app', 'https://witchsky.app', 'https://deer.social', 'https://mu.social', 'https://blacksky.community', 'https://northsky.app', ], getPathFromState(state: State) { // find the current node in the navigation tree const node = getCurrentRoute(state) // build the path const route = router.matchName(node.name) if (typeof route === 'undefined') { return '/' // default to home } return route.build((node.params || {}) as RouteParams) }, getStateFromPath(path: string) { const [name, params] = router.matchPath(path) // Any time we receive a url that starts with `intent/` we want to ignore it here. It will be handled in the // intent handler hook. We should check for the trailing slash, because if there isn't one then it isn't a valid // intent // On web, there is no route state that's created by default, so we should initialize it as the home route. On // native, since the home tab and the home screen are defined as initial routes, we don't need to return a state // since it will be created by react-navigation. if (path.includes('intent/')) { if (IS_NATIVE) return return buildStateObject('Flat', 'Home', params) } // Chat invite URLs (`/chat/:code`) are handled by `useIntentHandler`, which // opens the GroupChatJoinDialog (or the logged-out join flow). Route the // path to Home so the dialog overlays Home instead of NotFound. On native, // react-navigation strips the `bluesky://` prefix and passes the path // without a leading slash, so normalize before matching. const normalizedPath = path.startsWith('/') ? path : `/${path}` if (CHAT_INVITE_CODE_REGEX.test(normalizedPath.split('?')[0])) { if (IS_NATIVE) { return buildStateObject('HomeTab', 'Home', params) } return buildStateObject('Flat', 'Home', params) } if (IS_NATIVE) { if (name === 'Search') { return buildStateObject('SearchTab', 'Search', params) } if (name === 'Notifications') { return buildStateObject('NotificationsTab', 'Notifications', params) } if (name === 'Home') { return buildStateObject('HomeTab', 'Home', params) } if (name === 'Messages') { return buildStateObject('MessagesTab', 'Messages', params) } // if the path is something else, like a post, profile, or even settings, we need to initialize the home tab as pre-existing state otherwise the back button will not work return buildStateObject('HomeTab', name, params, [ { name: 'Home', params: {}, }, ]) } else { const res = buildStateObject('Flat', name, params) return res } }, } satisfies LinkingOptions let didHandlePushNotificationEntry = false function RoutesContainer({children}: React.PropsWithChildren<{}>) { const ax = useAnalytics() // eslint-disable-next-line react-compiler/react-compiler const notyLogger = ax.logger.useChild(ax.logger.Context.Notifications) const theme = useColorSchemeStyle(DefaultTheme, DarkTheme) const {currentAccount, accounts} = useSession() const {onPressSwitchAccount} = useAccountSwitcher() const {setShowLoggedOut} = useLoggedOutViewControls() const previousScreen = useRef(undefined) const emailDialogControl = useEmailDialogControl() const closeAllActiveElements = useCloseAllActiveElements() const linkingUrl = Linking.useLinkingURL() const disableVerifyEmailReminder = useDisableVerifyEmailReminder() // Consume stored navigation state exactly once for this mount. // Account switches trigger session updates before the keyed shell subtree // remounts, so consuming during render can clear the stored back stack too // early and leave React Navigation to reconstruct only the active route. const [initialState, setInitialState] = useState(() => IS_NATIVE ? consumeStoredNavigationState() : undefined, ) void setInitialState /** * Handle navigation to the messages tab, or prepares for account switch. * * Non-reactive because we need the latest data from some hooks * after an async call - sfn */ const handleChatNotification = useNonReactiveCallback( (payload: ChatNotificationPayload) => { notyLogger.debug(`handleChatNotification`, {payload}) if (payload.recipientDid !== currentAccount?.did) { // handled in useNotificationHandler after account switch finishes storePayloadForAccountSwitch(payload) closeAllActiveElements() const account = accounts.find(a => a.did === payload.recipientDid) if (account) { void onPressSwitchAccount(account, 'Notification') } else { setShowLoggedOut(true) } } else if ( payload.reason === 'chat-message' || payload.reason === 'chat-reaction' || payload.reason === 'chat-added-to-group' ) { // chat-added-to-group routes to the convo because the recipient was // just added and now has access. // @ts-expect-error nested navigators aren't typed -sfn void navigate('MessagesTab', { screen: 'Messages', params: { pushToConversation: payload.convoId, }, }) } else { // chat-removed-from-group, chat-join-request-rejected: the convo is // no longer accessible to the recipient, so just open the list. // @ts-expect-error nested navigators aren't typed -sfn void navigate('MessagesTab', {screen: 'Messages'}) } }, ) function handlePushNotificationEntry() { if (!IS_NATIVE) return // Only consume a launching notification once per JS runtime. Account // switches remount the entire tree (see `key={currentAccount?.did}` in // `App.native.tsx`), which re-fires `onNavigationReady` and would // otherwise re-process whatever `getLastNotificationResponse` still has // cached natively (APP-2338). if (didHandlePushNotificationEntry) return didHandlePushNotificationEntry = true // intent urls are handled by `useIntentHandler` if (linkingUrl) return const notificationResponse = Notifications.getLastNotificationResponse() if (notificationResponse) { notyLogger.debug(`handlePushNotificationEntry: response`, { response: notificationResponse, }) // Clear the last notification response to ensure it's not used again try { Notifications.clearLastNotificationResponse() } catch (error) { notyLogger.error( `handlePushNotificationEntry: error clearing notification response`, {error}, ) } const payload = getNotificationPayload(notificationResponse.notification) if (payload) { ax.metric('notifications:openApp', { reason: payload.reason, causedBoot: true, }) if (isChatNotificationPayload(payload)) { handleChatNotification(payload) } else { const path = notificationToURL(payload) if (path === '/notifications') { resetToTab('NotificationsTab') notyLogger.debug(`handlePushNotificationEntry: default navigate`) } else if (path) { const [screen, params] = router.matchPath(path) // @ts-expect-error nested navigators aren't typed -sfn void navigate('HomeTab', {screen, params}) notyLogger.debug(`handlePushNotificationEntry: navigate`, { screen, params, }) } } } } } const onNavigationReady = useCallOnce(() => { const currentScreen = getCurrentRouteName() setNavigationMetadata({ previousScreen: currentScreen, currentScreen, }) previousScreen.current = currentScreen handlePushNotificationEntry() ax.metric('router:navigate', {}) if ( currentAccount && shouldRequestEmailConfirmation(currentAccount) && !disableVerifyEmailReminder ) { emailDialogControl.open({ id: EmailDialogScreenID.VerificationReminder, }) snoozeEmailConfirmationPrompt() } ax.metric('init', { initMs: Math.round( // @ts-expect-error Emitted by Metro in the bundle prelude performance.now() - global.__BUNDLE_START_TIME__, ), }) if (IS_WEB) { void Referrer.getReferrerInfo().then(referrerInfo => { if (referrerInfo && referrerInfo.hostname !== 'bsky.app') { ax.metric('deepLink:referrerReceived', { to: window.location.href, referrer: referrerInfo?.referrer, hostname: referrerInfo?.hostname, }) } }) } // temp, just testing void ax.features.enabled(ax.features.AATest) }) return ( { const currentScreen = getCurrentRouteName() // do this before metric setNavigationMetadata({ previousScreen: previousScreen.current, currentScreen, }) ax.metric('router:navigate', {from: previousScreen.current}) previousScreen.current = currentScreen }} onReady={onNavigationReady} // WARNING: Implicit navigation to nested navigators is depreciated in React Navigation 7.x // However, there's a fair amount of places we do that, especially in when popping to the top of stacks. // See BottomBar.tsx for an example of how to handle nested navigators in the tabs correctly. // I'm scared of missing a spot (esp. with push notifications etc) so let's enable this legacy behaviour for now. // We will need to confirm we handle nested navigators correctly by the time we migrate to React Navigation 8.x // -sfn navigationInChildEnabled> {children} {/* Login links need navigation context, including inside the modal. */} ) } function getCurrentRouteName() { if (navigationRef.isReady()) { return navigationRef.getCurrentRoute()?.name } else { return undefined } } /** * These helpers can be used from outside of the RoutesContainer * (eg in the state models). */ function navigate( name: K, params?: AllNavigatorParams[K], ) { if (navigationRef.isReady()) { return Promise.race([ new Promise(resolve => { const handler = () => { resolve() navigationRef.removeListener('state', handler) } navigationRef.addListener('state', handler) // @ts-expect-error I don't know what would make typescript happy but I have a life -prf navigationRef.navigate(name, params) }), timeout(1e3), ]) } return Promise.resolve() } /** Returns to the previous app screen when one is available. */ export function goBack() { if (!navigationRef.isReady() || !navigationRef.canGoBack()) return false navigationRef.goBack() return true } function resetToTab( tabName: 'HomeTab' | 'SearchTab' | 'MessagesTab' | 'NotificationsTab', ) { if (navigationRef.isReady()) { void navigate(tabName) if (navigationRef.canGoBack()) { navigationRef.dispatch(StackActions.popToTop()) //we need to check .canGoBack() before calling it } } } // returns a promise that resolves after the state reset is complete function reset(): Promise { if (navigationRef.isReady()) { navigationRef.dispatch( CommonActions.reset({ index: 0, routes: [{name: IS_NATIVE ? 'HomeTab' : 'Home'}], }), ) return Promise.race([ timeout(1e3), new Promise(resolve => { const handler = () => { resolve() navigationRef.removeListener('state', handler) } navigationRef.addListener('state', handler) }), ]) } else { return Promise.resolve() } } export { FlatNavigator, navigate, reset, resetToTab, RoutesContainer, TabsNavigator, }