An ATproto social media client -- with an independent Appview.
1import {type JSX, useCallback, useRef} from 'react'
2import {Linking} from 'react-native'
3import * as Notifications from 'expo-notifications'
4import {i18n, type MessageDescriptor} from '@lingui/core'
5import {msg} from '@lingui/macro'
6import {
7 type BottomTabBarProps,
8 createBottomTabNavigator,
9} from '@react-navigation/bottom-tabs'
10import {
11 CommonActions,
12 createNavigationContainerRef,
13 DarkTheme,
14 DefaultTheme,
15 type LinkingOptions,
16 NavigationContainer,
17 StackActions,
18} from '@react-navigation/native'
19
20import {timeout} from '#/lib/async/timeout'
21import {useColorSchemeStyle} from '#/lib/hooks/useColorSchemeStyle'
22import {
23 getNotificationPayload,
24 type NotificationPayload,
25 notificationToURL,
26 storePayloadForAccountSwitch,
27} from '#/lib/hooks/useNotificationHandler'
28import {useWebScrollRestoration} from '#/lib/hooks/useWebScrollRestoration'
29import {logger as notyLogger} from '#/lib/notifications/util'
30import {buildStateObject} from '#/lib/routes/helpers'
31import {
32 type AllNavigatorParams,
33 type BottomTabNavigatorParams,
34 type FlatNavigatorParams,
35 type HomeTabNavigatorParams,
36 type MessagesTabNavigatorParams,
37 type MyProfileTabNavigatorParams,
38 type NotificationsTabNavigatorParams,
39 type SearchTabNavigatorParams,
40} from '#/lib/routes/types'
41import {type RouteParams, type State} from '#/lib/routes/types'
42import {attachRouteToLogEvents, logEvent} from '#/lib/statsig/statsig'
43import {bskyTitle} from '#/lib/strings/headings'
44import {logger} from '#/logger'
45import {isNative, isWeb} from '#/platform/detection'
46import {useUnreadNotifications} from '#/state/queries/notifications/unread'
47import {useSession} from '#/state/session'
48import {
49 shouldRequestEmailConfirmation,
50 snoozeEmailConfirmationPrompt,
51} from '#/state/shell/reminders'
52import {CommunityGuidelinesScreen} from '#/view/screens/CommunityGuidelines'
53import {CopyrightPolicyScreen} from '#/view/screens/CopyrightPolicy'
54import {DebugModScreen} from '#/view/screens/DebugMod'
55import {FeedsScreen} from '#/view/screens/Feeds'
56import {HomeScreen} from '#/view/screens/Home'
57import {ListsScreen} from '#/view/screens/Lists'
58import {ModerationBlockedAccounts} from '#/view/screens/ModerationBlockedAccounts'
59import {ModerationModlistsScreen} from '#/view/screens/ModerationModlists'
60import {ModerationMutedAccounts} from '#/view/screens/ModerationMutedAccounts'
61import {NotFoundScreen} from '#/view/screens/NotFound'
62import {NotificationsScreen} from '#/view/screens/Notifications'
63import {PostThreadScreen} from '#/view/screens/PostThread'
64import {PrivacyPolicyScreen} from '#/view/screens/PrivacyPolicy'
65import {ProfileScreen} from '#/view/screens/Profile'
66import {ProfileFeedLikedByScreen} from '#/view/screens/ProfileFeedLikedBy'
67import {Storybook} from '#/view/screens/Storybook'
68import {SupportScreen} from '#/view/screens/Support'
69import {TermsOfServiceScreen} from '#/view/screens/TermsOfService'
70import {BottomBar} from '#/view/shell/bottom-bar/BottomBar'
71import {createNativeStackNavigatorWithAuth} from '#/view/shell/createNativeStackNavigatorWithAuth'
72import {BookmarksScreen} from '#/screens/Bookmarks'
73import {SharedPreferencesTesterScreen} from '#/screens/E2E/SharedPreferencesTesterScreen'
74import HashtagScreen from '#/screens/Hashtag'
75import {LogScreen} from '#/screens/Log'
76import {MessagesScreen} from '#/screens/Messages/ChatList'
77import {MessagesConversationScreen} from '#/screens/Messages/Conversation'
78import {MessagesInboxScreen} from '#/screens/Messages/Inbox'
79import {MessagesSettingsScreen} from '#/screens/Messages/Settings'
80import {ModerationScreen} from '#/screens/Moderation'
81import {Screen as ModerationVerificationSettings} from '#/screens/Moderation/VerificationSettings'
82import {Screen as ModerationInteractionSettings} from '#/screens/ModerationInteractionSettings'
83import {NotificationsActivityListScreen} from '#/screens/Notifications/ActivityList'
84import {PostLikedByScreen} from '#/screens/Post/PostLikedBy'
85import {PostQuotesScreen} from '#/screens/Post/PostQuotes'
86import {PostRepostedByScreen} from '#/screens/Post/PostRepostedBy'
87import {ProfileKnownFollowersScreen} from '#/screens/Profile/KnownFollowers'
88import {ProfileFeedScreen} from '#/screens/Profile/ProfileFeed'
89import {ProfileFollowersScreen} from '#/screens/Profile/ProfileFollowers'
90import {ProfileFollowsScreen} from '#/screens/Profile/ProfileFollows'
91import {ProfileLabelerLikedByScreen} from '#/screens/Profile/ProfileLabelerLikedBy'
92import {ProfileListScreen} from '#/screens/ProfileList'
93import {SavedFeeds} from '#/screens/SavedFeeds'
94import {SearchScreen} from '#/screens/Search'
95import {ActivityPrivacySettingsScreen} from '#/screens/Settings/ActivityPrivacySettings'
96import {AppearanceSettingsScreen} from '#/screens/Settings/AppearanceSettings'
97import {AppIconSettingsScreen} from '#/screens/Settings/AppIconSettings'
98import {ExperimentalSettingsScreen} from '#/screens/Settings/ExperimentalSettings'
99import {InterestsSettingsScreen} from '#/screens/Settings/InterestsSettings'
100import {LegacyNotificationSettingsScreen} from '#/screens/Settings/LegacyNotificationSettings'
101import {NotificationSettingsScreen} from '#/screens/Settings/NotificationSettings'
102import {ActivityNotificationSettingsScreen} from '#/screens/Settings/NotificationSettings/ActivityNotificationSettings'
103import {LikeNotificationSettingsScreen} from '#/screens/Settings/NotificationSettings/LikeNotificationSettings'
104import {LikesOnRepostsNotificationSettingsScreen} from '#/screens/Settings/NotificationSettings/LikesOnRepostsNotificationSettings'
105import {MentionNotificationSettingsScreen} from '#/screens/Settings/NotificationSettings/MentionNotificationSettings'
106import {MiscellaneousNotificationSettingsScreen} from '#/screens/Settings/NotificationSettings/MiscellaneousNotificationSettings'
107import {NewFollowerNotificationSettingsScreen} from '#/screens/Settings/NotificationSettings/NewFollowerNotificationSettings'
108import {QuoteNotificationSettingsScreen} from '#/screens/Settings/NotificationSettings/QuoteNotificationSettings'
109import {ReplyNotificationSettingsScreen} from '#/screens/Settings/NotificationSettings/ReplyNotificationSettings'
110import {RepostNotificationSettingsScreen} from '#/screens/Settings/NotificationSettings/RepostNotificationSettings'
111import {RepostsOnRepostsNotificationSettingsScreen} from '#/screens/Settings/NotificationSettings/RepostsOnRepostsNotificationSettings'
112import {
113 StarterPackScreen,
114 StarterPackScreenShort,
115} from '#/screens/StarterPack/StarterPackScreen'
116import {Wizard} from '#/screens/StarterPack/Wizard'
117import {VideoFeed} from '#/screens/VideoFeed'
118import {type Theme, useTheme} from '#/alf'
119import {
120 EmailDialogScreenID,
121 useEmailDialogControl,
122} from '#/components/dialogs/EmailDialog'
123import {router} from '#/routes'
124import {Referrer} from '../modules/expo-bluesky-swiss-army'
125import {useAccountSwitcher} from './lib/hooks/useAccountSwitcher'
126import {useNonReactiveCallback} from './lib/hooks/useNonReactiveCallback'
127import {ProfileSearchScreen} from './screens/Profile/ProfileSearch'
128import {AboutSettingsScreen} from './screens/Settings/AboutSettings'
129import {AccessibilitySettingsScreen} from './screens/Settings/AccessibilitySettings'
130import {AccountSettingsScreen} from './screens/Settings/AccountSettings'
131import {AppPasswordsScreen} from './screens/Settings/AppPasswords'
132import {ContentAndMediaSettingsScreen} from './screens/Settings/ContentAndMediaSettings'
133import {ExternalMediaPreferencesScreen} from './screens/Settings/ExternalMediaPreferences'
134import {FollowingFeedPreferencesScreen} from './screens/Settings/FollowingFeedPreferences'
135import {LanguageSettingsScreen} from './screens/Settings/LanguageSettings'
136import {PrivacyAndSecuritySettingsScreen} from './screens/Settings/PrivacyAndSecuritySettings'
137import {SettingsScreen} from './screens/Settings/Settings'
138import {ThreadPreferencesScreen} from './screens/Settings/ThreadPreferences'
139import TopicScreen from './screens/Topic'
140import {useLoggedOutViewControls} from './state/shell/logged-out'
141import {useCloseAllActiveElements} from './state/util'
142
143const navigationRef = createNavigationContainerRef<AllNavigatorParams>()
144
145const HomeTab = createNativeStackNavigatorWithAuth<HomeTabNavigatorParams>()
146const SearchTab = createNativeStackNavigatorWithAuth<SearchTabNavigatorParams>()
147const NotificationsTab =
148 createNativeStackNavigatorWithAuth<NotificationsTabNavigatorParams>()
149const MyProfileTab =
150 createNativeStackNavigatorWithAuth<MyProfileTabNavigatorParams>()
151const MessagesTab =
152 createNativeStackNavigatorWithAuth<MessagesTabNavigatorParams>()
153const Flat = createNativeStackNavigatorWithAuth<FlatNavigatorParams>()
154const Tab = createBottomTabNavigator<BottomTabNavigatorParams>()
155
156/**
157 * These "common screens" are reused across stacks.
158 */
159function commonScreens(Stack: typeof Flat, unreadCountLabel?: string) {
160 const title = (page: MessageDescriptor) =>
161 bskyTitle(i18n._(page), unreadCountLabel)
162
163 return (
164 <>
165 <Stack.Screen
166 name="NotFound"
167 getComponent={() => NotFoundScreen}
168 options={{title: title(msg`Not Found`)}}
169 />
170 <Stack.Screen
171 name="Lists"
172 component={ListsScreen}
173 options={{title: title(msg`Lists`), requireAuth: true}}
174 />
175 <Stack.Screen
176 name="Moderation"
177 getComponent={() => ModerationScreen}
178 options={{title: title(msg`Moderation`), requireAuth: true}}
179 />
180 <Stack.Screen
181 name="ModerationModlists"
182 getComponent={() => ModerationModlistsScreen}
183 options={{title: title(msg`Moderation Lists`), requireAuth: true}}
184 />
185 <Stack.Screen
186 name="ModerationMutedAccounts"
187 getComponent={() => ModerationMutedAccounts}
188 options={{title: title(msg`Muted Accounts`), requireAuth: true}}
189 />
190 <Stack.Screen
191 name="ModerationBlockedAccounts"
192 getComponent={() => ModerationBlockedAccounts}
193 options={{title: title(msg`Blocked Accounts`), requireAuth: true}}
194 />
195 <Stack.Screen
196 name="ModerationInteractionSettings"
197 getComponent={() => ModerationInteractionSettings}
198 options={{
199 title: title(msg`Post Interaction Settings`),
200 requireAuth: true,
201 }}
202 />
203 <Stack.Screen
204 name="ModerationVerificationSettings"
205 getComponent={() => ModerationVerificationSettings}
206 options={{
207 title: title(msg`Verification Settings`),
208 requireAuth: true,
209 }}
210 />
211 <Stack.Screen
212 name="Settings"
213 getComponent={() => SettingsScreen}
214 options={{title: title(msg`Settings`), requireAuth: true}}
215 />
216 <Stack.Screen
217 name="LanguageSettings"
218 getComponent={() => LanguageSettingsScreen}
219 options={{title: title(msg`Language Settings`), requireAuth: true}}
220 />
221 <Stack.Screen
222 name="Profile"
223 getComponent={() => ProfileScreen}
224 options={({route}) => ({
225 title: bskyTitle(`@${route.params.name}`, unreadCountLabel),
226 })}
227 />
228 <Stack.Screen
229 name="ProfileFollowers"
230 getComponent={() => ProfileFollowersScreen}
231 options={({route}) => ({
232 title: title(msg`People following @${route.params.name}`),
233 })}
234 />
235 <Stack.Screen
236 name="ProfileFollows"
237 getComponent={() => ProfileFollowsScreen}
238 options={({route}) => ({
239 title: title(msg`People followed by @${route.params.name}`),
240 })}
241 />
242 <Stack.Screen
243 name="ProfileKnownFollowers"
244 getComponent={() => ProfileKnownFollowersScreen}
245 options={({route}) => ({
246 title: title(msg`Followers of @${route.params.name} that you know`),
247 })}
248 />
249 <Stack.Screen
250 name="ProfileList"
251 getComponent={() => ProfileListScreen}
252 options={{title: title(msg`List`), requireAuth: true}}
253 />
254 <Stack.Screen
255 name="ProfileSearch"
256 getComponent={() => ProfileSearchScreen}
257 options={({route}) => ({
258 title: title(msg`Search @${route.params.name}'s posts`),
259 })}
260 />
261 <Stack.Screen
262 name="PostThread"
263 getComponent={() => PostThreadScreen}
264 options={({route}) => ({
265 title: title(msg`Post by @${route.params.name}`),
266 })}
267 />
268 <Stack.Screen
269 name="PostLikedBy"
270 getComponent={() => PostLikedByScreen}
271 options={({route}) => ({
272 title: title(msg`Post by @${route.params.name}`),
273 })}
274 />
275 <Stack.Screen
276 name="PostRepostedBy"
277 getComponent={() => PostRepostedByScreen}
278 options={({route}) => ({
279 title: title(msg`Post by @${route.params.name}`),
280 })}
281 />
282 <Stack.Screen
283 name="PostQuotes"
284 getComponent={() => PostQuotesScreen}
285 options={({route}) => ({
286 title: title(msg`Post by @${route.params.name}`),
287 })}
288 />
289 <Stack.Screen
290 name="ProfileFeed"
291 getComponent={() => ProfileFeedScreen}
292 options={{title: title(msg`Feed`)}}
293 />
294 <Stack.Screen
295 name="ProfileFeedLikedBy"
296 getComponent={() => ProfileFeedLikedByScreen}
297 options={{title: title(msg`Liked by`)}}
298 />
299 <Stack.Screen
300 name="ProfileLabelerLikedBy"
301 getComponent={() => ProfileLabelerLikedByScreen}
302 options={{title: title(msg`Liked by`)}}
303 />
304 <Stack.Screen
305 name="Debug"
306 getComponent={() => Storybook}
307 options={{title: title(msg`Storybook`), requireAuth: true}}
308 />
309 <Stack.Screen
310 name="DebugMod"
311 getComponent={() => DebugModScreen}
312 options={{title: title(msg`Moderation states`), requireAuth: true}}
313 />
314 <Stack.Screen
315 name="SharedPreferencesTester"
316 getComponent={() => SharedPreferencesTesterScreen}
317 options={{title: title(msg`Shared Preferences Tester`)}}
318 />
319 <Stack.Screen
320 name="Log"
321 getComponent={() => LogScreen}
322 options={{title: title(msg`Log`), requireAuth: true}}
323 />
324 <Stack.Screen
325 name="Support"
326 getComponent={() => SupportScreen}
327 options={{title: title(msg`Support`)}}
328 />
329 <Stack.Screen
330 name="PrivacyPolicy"
331 getComponent={() => PrivacyPolicyScreen}
332 options={{title: title(msg`Privacy Policy`)}}
333 />
334 <Stack.Screen
335 name="TermsOfService"
336 getComponent={() => TermsOfServiceScreen}
337 options={{title: title(msg`Terms of Service`)}}
338 />
339 <Stack.Screen
340 name="CommunityGuidelines"
341 getComponent={() => CommunityGuidelinesScreen}
342 options={{title: title(msg`Community Guidelines`)}}
343 />
344 <Stack.Screen
345 name="CopyrightPolicy"
346 getComponent={() => CopyrightPolicyScreen}
347 options={{title: title(msg`Copyright Policy`)}}
348 />
349 <Stack.Screen
350 name="AppPasswords"
351 getComponent={() => AppPasswordsScreen}
352 options={{title: title(msg`App Passwords`), requireAuth: true}}
353 />
354 <Stack.Screen
355 name="SavedFeeds"
356 getComponent={() => SavedFeeds}
357 options={{title: title(msg`Edit My Feeds`), requireAuth: true}}
358 />
359 <Stack.Screen
360 name="PreferencesFollowingFeed"
361 getComponent={() => FollowingFeedPreferencesScreen}
362 options={{
363 title: title(msg`Following Feed Preferences`),
364 requireAuth: true,
365 }}
366 />
367 <Stack.Screen
368 name="PreferencesThreads"
369 getComponent={() => ThreadPreferencesScreen}
370 options={{title: title(msg`Threads Preferences`), requireAuth: true}}
371 />
372 <Stack.Screen
373 name="PreferencesExternalEmbeds"
374 getComponent={() => ExternalMediaPreferencesScreen}
375 options={{
376 title: title(msg`External Media Preferences`),
377 requireAuth: true,
378 }}
379 />
380 <Stack.Screen
381 name="AccessibilitySettings"
382 getComponent={() => AccessibilitySettingsScreen}
383 options={{
384 title: title(msg`Accessibility Settings`),
385 requireAuth: true,
386 }}
387 />
388 <Stack.Screen
389 name="ExperimentalSettings"
390 getComponent={() => ExperimentalSettingsScreen}
391 options={{
392 title: title(msg`Experimental Settings`),
393 requireAuth: true,
394 }}
395 />
396 <Stack.Screen
397 name="AppearanceSettings"
398 getComponent={() => AppearanceSettingsScreen}
399 options={{
400 title: title(msg`Appearance`),
401 requireAuth: true,
402 }}
403 />
404 <Stack.Screen
405 name="AccountSettings"
406 getComponent={() => AccountSettingsScreen}
407 options={{
408 title: title(msg`Account`),
409 requireAuth: true,
410 }}
411 />
412 <Stack.Screen
413 name="PrivacyAndSecuritySettings"
414 getComponent={() => PrivacyAndSecuritySettingsScreen}
415 options={{
416 title: title(msg`Privacy and Security`),
417 requireAuth: true,
418 }}
419 />
420 <Stack.Screen
421 name="ActivityPrivacySettings"
422 getComponent={() => ActivityPrivacySettingsScreen}
423 options={{
424 title: title(msg`Privacy and Security`),
425 requireAuth: true,
426 }}
427 />
428 <Stack.Screen
429 name="NotificationSettings"
430 getComponent={() => NotificationSettingsScreen}
431 options={{title: title(msg`Notification settings`), requireAuth: true}}
432 />
433 <Stack.Screen
434 name="ReplyNotificationSettings"
435 getComponent={() => ReplyNotificationSettingsScreen}
436 options={{
437 title: title(msg`Reply notifications`),
438 requireAuth: true,
439 }}
440 />
441 <Stack.Screen
442 name="MentionNotificationSettings"
443 getComponent={() => MentionNotificationSettingsScreen}
444 options={{
445 title: title(msg`Mention notifications`),
446 requireAuth: true,
447 }}
448 />
449 <Stack.Screen
450 name="QuoteNotificationSettings"
451 getComponent={() => QuoteNotificationSettingsScreen}
452 options={{
453 title: title(msg`Quote notifications`),
454 requireAuth: true,
455 }}
456 />
457 <Stack.Screen
458 name="LikeNotificationSettings"
459 getComponent={() => LikeNotificationSettingsScreen}
460 options={{
461 title: title(msg`Like notifications`),
462 requireAuth: true,
463 }}
464 />
465 <Stack.Screen
466 name="RepostNotificationSettings"
467 getComponent={() => RepostNotificationSettingsScreen}
468 options={{
469 title: title(msg`Repost notifications`),
470 requireAuth: true,
471 }}
472 />
473 <Stack.Screen
474 name="NewFollowerNotificationSettings"
475 getComponent={() => NewFollowerNotificationSettingsScreen}
476 options={{
477 title: title(msg`New follower notifications`),
478 requireAuth: true,
479 }}
480 />
481 <Stack.Screen
482 name="LikesOnRepostsNotificationSettings"
483 getComponent={() => LikesOnRepostsNotificationSettingsScreen}
484 options={{
485 title: title(msg`Likes of your reposts notifications`),
486 requireAuth: true,
487 }}
488 />
489 <Stack.Screen
490 name="RepostsOnRepostsNotificationSettings"
491 getComponent={() => RepostsOnRepostsNotificationSettingsScreen}
492 options={{
493 title: title(msg`Reposts of your reposts notifications`),
494 requireAuth: true,
495 }}
496 />
497 <Stack.Screen
498 name="ActivityNotificationSettings"
499 getComponent={() => ActivityNotificationSettingsScreen}
500 options={{
501 title: title(msg`Activity notifications`),
502 requireAuth: true,
503 }}
504 />
505 <Stack.Screen
506 name="MiscellaneousNotificationSettings"
507 getComponent={() => MiscellaneousNotificationSettingsScreen}
508 options={{
509 title: title(msg`Miscellaneous notifications`),
510 requireAuth: true,
511 }}
512 />
513 <Stack.Screen
514 name="ContentAndMediaSettings"
515 getComponent={() => ContentAndMediaSettingsScreen}
516 options={{
517 title: title(msg`Content and Media`),
518 requireAuth: true,
519 }}
520 />
521 <Stack.Screen
522 name="InterestsSettings"
523 getComponent={() => InterestsSettingsScreen}
524 options={{
525 title: title(msg`Your interests`),
526 requireAuth: true,
527 }}
528 />
529 <Stack.Screen
530 name="AboutSettings"
531 getComponent={() => AboutSettingsScreen}
532 options={{
533 title: title(msg`About`),
534 requireAuth: true,
535 }}
536 />
537 <Stack.Screen
538 name="AppIconSettings"
539 getComponent={() => AppIconSettingsScreen}
540 options={{
541 title: title(msg`App Icon`),
542 requireAuth: true,
543 }}
544 />
545 <Stack.Screen
546 name="Hashtag"
547 getComponent={() => HashtagScreen}
548 options={{title: title(msg`Hashtag`)}}
549 />
550 <Stack.Screen
551 name="Topic"
552 getComponent={() => TopicScreen}
553 options={{title: title(msg`Topic`)}}
554 />
555 <Stack.Screen
556 name="MessagesConversation"
557 getComponent={() => MessagesConversationScreen}
558 options={{title: title(msg`Chat`), requireAuth: true}}
559 />
560 <Stack.Screen
561 name="MessagesSettings"
562 getComponent={() => MessagesSettingsScreen}
563 options={{title: title(msg`Chat settings`), requireAuth: true}}
564 />
565 <Stack.Screen
566 name="MessagesInbox"
567 getComponent={() => MessagesInboxScreen}
568 options={{title: title(msg`Chat request inbox`), requireAuth: true}}
569 />
570 <Stack.Screen
571 name="NotificationsActivityList"
572 getComponent={() => NotificationsActivityListScreen}
573 options={{title: title(msg`Notifications`), requireAuth: true}}
574 />
575 <Stack.Screen
576 name="LegacyNotificationSettings"
577 getComponent={() => LegacyNotificationSettingsScreen}
578 options={{title: title(msg`Notification settings`), requireAuth: true}}
579 />
580 <Stack.Screen
581 name="Feeds"
582 getComponent={() => FeedsScreen}
583 options={{title: title(msg`Feeds`)}}
584 />
585 <Stack.Screen
586 name="StarterPack"
587 getComponent={() => StarterPackScreen}
588 options={{title: title(msg`Starter Pack`)}}
589 />
590 <Stack.Screen
591 name="StarterPackShort"
592 getComponent={() => StarterPackScreenShort}
593 options={{title: title(msg`Starter Pack`)}}
594 />
595 <Stack.Screen
596 name="StarterPackWizard"
597 getComponent={() => Wizard}
598 options={{title: title(msg`Create a starter pack`), requireAuth: true}}
599 />
600 <Stack.Screen
601 name="StarterPackEdit"
602 getComponent={() => Wizard}
603 options={{title: title(msg`Edit your starter pack`), requireAuth: true}}
604 />
605 <Stack.Screen
606 name="VideoFeed"
607 getComponent={() => VideoFeed}
608 options={{
609 title: title(msg`Video Feed`),
610 requireAuth: true,
611 }}
612 />
613 <Stack.Screen
614 name="Bookmarks"
615 getComponent={() => BookmarksScreen}
616 options={{
617 title: title(msg`Saved Posts`),
618 requireAuth: true,
619 }}
620 />
621 </>
622 )
623}
624
625/**
626 * The TabsNavigator is used by native mobile to represent the routes
627 * in 3 distinct tab-stacks with a different root screen on each.
628 */
629function TabsNavigator() {
630 const tabBar = useCallback(
631 (props: JSX.IntrinsicAttributes & BottomTabBarProps) => (
632 <BottomBar {...props} />
633 ),
634 [],
635 )
636
637 return (
638 <Tab.Navigator
639 initialRouteName="HomeTab"
640 backBehavior="initialRoute"
641 screenOptions={{headerShown: false, lazy: true}}
642 tabBar={tabBar}>
643 <Tab.Screen name="HomeTab" getComponent={() => HomeTabNavigator} />
644 <Tab.Screen name="SearchTab" getComponent={() => SearchTabNavigator} />
645 <Tab.Screen
646 name="MessagesTab"
647 getComponent={() => MessagesTabNavigator}
648 />
649 <Tab.Screen
650 name="NotificationsTab"
651 getComponent={() => NotificationsTabNavigator}
652 />
653 <Tab.Screen
654 name="MyProfileTab"
655 getComponent={() => MyProfileTabNavigator}
656 />
657 </Tab.Navigator>
658 )
659}
660
661function screenOptions(t: Theme) {
662 return {
663 fullScreenGestureEnabled: true,
664 headerShown: false,
665 contentStyle: t.atoms.bg,
666 } as const
667}
668
669function HomeTabNavigator() {
670 const t = useTheme()
671
672 return (
673 <HomeTab.Navigator screenOptions={screenOptions(t)} initialRouteName="Home">
674 <HomeTab.Screen name="Home" getComponent={() => HomeScreen} />
675 <HomeTab.Screen name="Start" getComponent={() => HomeScreen} />
676 {commonScreens(HomeTab as typeof Flat)}
677 </HomeTab.Navigator>
678 )
679}
680
681function SearchTabNavigator() {
682 const t = useTheme()
683 return (
684 <SearchTab.Navigator
685 screenOptions={screenOptions(t)}
686 initialRouteName="Search">
687 <SearchTab.Screen name="Search" getComponent={() => SearchScreen} />
688 {commonScreens(SearchTab as typeof Flat)}
689 </SearchTab.Navigator>
690 )
691}
692
693function NotificationsTabNavigator() {
694 const t = useTheme()
695 return (
696 <NotificationsTab.Navigator
697 screenOptions={screenOptions(t)}
698 initialRouteName="Notifications">
699 <NotificationsTab.Screen
700 name="Notifications"
701 getComponent={() => NotificationsScreen}
702 options={{requireAuth: true}}
703 />
704 {commonScreens(NotificationsTab as typeof Flat)}
705 </NotificationsTab.Navigator>
706 )
707}
708
709function MyProfileTabNavigator() {
710 const t = useTheme()
711 return (
712 <MyProfileTab.Navigator
713 screenOptions={screenOptions(t)}
714 initialRouteName="MyProfile">
715 <MyProfileTab.Screen
716 // MyProfile is not in AllNavigationParams - asserting as Profile at least
717 // gives us typechecking for initialParams -sfn
718 name={'MyProfile' as 'Profile'}
719 getComponent={() => ProfileScreen}
720 initialParams={{name: 'me', hideBackButton: true}}
721 />
722 {commonScreens(MyProfileTab as unknown as typeof Flat)}
723 </MyProfileTab.Navigator>
724 )
725}
726
727function MessagesTabNavigator() {
728 const t = useTheme()
729 return (
730 <MessagesTab.Navigator
731 screenOptions={screenOptions(t)}
732 initialRouteName="Messages">
733 <MessagesTab.Screen
734 name="Messages"
735 getComponent={() => MessagesScreen}
736 options={({route}) => ({
737 requireAuth: true,
738 animationTypeForReplace: route.params?.animation ?? 'push',
739 })}
740 />
741 {commonScreens(MessagesTab as typeof Flat)}
742 </MessagesTab.Navigator>
743 )
744}
745
746/**
747 * The FlatNavigator is used by Web to represent the routes
748 * in a single ("flat") stack.
749 */
750const FlatNavigator = () => {
751 const t = useTheme()
752 const numUnread = useUnreadNotifications()
753 const screenListeners = useWebScrollRestoration()
754 const title = (page: MessageDescriptor) => bskyTitle(i18n._(page), numUnread)
755
756 return (
757 <Flat.Navigator
758 screenListeners={screenListeners}
759 screenOptions={screenOptions(t)}>
760 <Flat.Screen
761 name="Home"
762 getComponent={() => HomeScreen}
763 options={{title: title(msg`Home`)}}
764 />
765 <Flat.Screen
766 name="Search"
767 getComponent={() => SearchScreen}
768 options={{title: title(msg`Explore`)}}
769 />
770 <Flat.Screen
771 name="Notifications"
772 getComponent={() => NotificationsScreen}
773 options={{title: title(msg`Notifications`), requireAuth: true}}
774 />
775 <Flat.Screen
776 name="Messages"
777 getComponent={() => MessagesScreen}
778 options={{title: title(msg`Messages`), requireAuth: true}}
779 />
780 <Flat.Screen
781 name="Start"
782 getComponent={() => HomeScreen}
783 options={{title: title(msg`Home`)}}
784 />
785 {commonScreens(Flat, numUnread)}
786 </Flat.Navigator>
787 )
788}
789
790/**
791 * The RoutesContainer should wrap all components which need access
792 * to the navigation context.
793 */
794
795const LINKING = {
796 // TODO figure out what we are going to use
797 // note: `bluesky://` is what is used in app.config.js
798 prefixes: ['bsky://', 'bluesky://', 'https://social.shatteredsky.net'],
799
800 getPathFromState(state: State) {
801 // find the current node in the navigation tree
802 let node = state.routes[state.index || 0]
803 while (node.state?.routes && typeof node.state?.index === 'number') {
804 node = node.state?.routes[node.state?.index]
805 }
806
807 // build the path
808 const route = router.matchName(node.name)
809 if (typeof route === 'undefined') {
810 return '/' // default to home
811 }
812 return route.build((node.params || {}) as RouteParams)
813 },
814
815 getStateFromPath(path: string) {
816 const [name, params] = router.matchPath(path)
817
818 // Any time we receive a url that starts with `intent/` we want to ignore it here. It will be handled in the
819 // intent handler hook. We should check for the trailing slash, because if there isn't one then it isn't a valid
820 // intent
821 // On web, there is no route state that's created by default, so we should initialize it as the home route. On
822 // native, since the home tab and the home screen are defined as initial routes, we don't need to return a state
823 // since it will be created by react-navigation.
824 if (path.includes('intent/')) {
825 if (isNative) return
826 return buildStateObject('Flat', 'Home', params)
827 }
828
829 if (isNative) {
830 if (name === 'Search') {
831 return buildStateObject('SearchTab', 'Search', params)
832 }
833 if (name === 'Notifications') {
834 return buildStateObject('NotificationsTab', 'Notifications', params)
835 }
836 if (name === 'Home') {
837 return buildStateObject('HomeTab', 'Home', params)
838 }
839 if (name === 'Messages') {
840 return buildStateObject('MessagesTab', 'Messages', params)
841 }
842 // 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
843 return buildStateObject('HomeTab', name, params, [
844 {
845 name: 'Home',
846 params: {},
847 },
848 ])
849 } else {
850 const res = buildStateObject('Flat', name, params)
851 return res
852 }
853 },
854} satisfies LinkingOptions<AllNavigatorParams>
855
856/**
857 * Used to ensure we don't handle the same notification twice
858 */
859let lastHandledNotificationDateDedupe: number | undefined
860
861function RoutesContainer({children}: React.PropsWithChildren<{}>) {
862 const theme = useColorSchemeStyle(DefaultTheme, DarkTheme)
863 const {currentAccount, accounts} = useSession()
864 const {onPressSwitchAccount} = useAccountSwitcher()
865 const {setShowLoggedOut} = useLoggedOutViewControls()
866 const prevLoggedRouteName = useRef<string | undefined>(undefined)
867 const emailDialogControl = useEmailDialogControl()
868 const closeAllActiveElements = useCloseAllActiveElements()
869
870 /**
871 * Handle navigation to a conversation, or prepares for account switch.
872 *
873 * Non-reactive because we need the latest data from some hooks
874 * after an async call - sfn
875 */
876 const handleChatMessage = useNonReactiveCallback(
877 (payload: Extract<NotificationPayload, {reason: 'chat-message'}>) => {
878 notyLogger.debug(`handleChatMessage`, {payload})
879
880 if (payload.recipientDid !== currentAccount?.did) {
881 // handled in useNotificationHandler after account switch finishes
882 storePayloadForAccountSwitch(payload)
883 closeAllActiveElements()
884
885 const account = accounts.find(a => a.did === payload.recipientDid)
886 if (account) {
887 onPressSwitchAccount(account, 'Notification')
888 } else {
889 setShowLoggedOut(true)
890 }
891 } else {
892 // @ts-expect-error nested navigators aren't typed -sfn
893 navigate('MessagesTab', {
894 screen: 'Messages',
895 params: {
896 pushToConversation: payload.convoId,
897 },
898 })
899 }
900 },
901 )
902
903 async function handlePushNotificationEntry() {
904 if (!isNative) return
905
906 // deep links take precedence - on android,
907 // getLastNotificationResponseAsync returns a "notification"
908 // that is actually a deep link. avoid handling it twice -sfn
909 if (await Linking.getInitialURL()) {
910 return
911 }
912
913 /**
914 * The notification that caused the app to open, if applicable
915 */
916 const response = await Notifications.getLastNotificationResponseAsync()
917
918 if (response) {
919 notyLogger.debug(`handlePushNotificationEntry: response`, {response})
920
921 if (response.notification.date === lastHandledNotificationDateDedupe)
922 return
923 lastHandledNotificationDateDedupe = response.notification.date
924
925 const payload = getNotificationPayload(response.notification)
926
927 if (payload) {
928 notyLogger.metric(
929 'notifications:openApp',
930 {reason: payload.reason, causedBoot: true},
931 {statsig: false},
932 )
933
934 if (payload.reason === 'chat-message') {
935 handleChatMessage(payload)
936 } else {
937 const path = notificationToURL(payload)
938
939 if (path === '/notifications') {
940 resetToTab('NotificationsTab')
941 notyLogger.debug(`handlePushNotificationEntry: default navigate`)
942 } else if (path) {
943 const [screen, params] = router.matchPath(path)
944 // @ts-expect-error nested navigators aren't typed -sfn
945 navigate('HomeTab', {screen, params})
946 notyLogger.debug(`handlePushNotificationEntry: navigate`, {
947 screen,
948 params,
949 })
950 }
951 }
952 }
953 }
954 }
955
956 function onReady() {
957 prevLoggedRouteName.current = getCurrentRouteName()
958 if (currentAccount && shouldRequestEmailConfirmation(currentAccount)) {
959 emailDialogControl.open({
960 id: EmailDialogScreenID.VerificationReminder,
961 })
962 snoozeEmailConfirmationPrompt()
963 }
964 }
965
966 return (
967 <>
968 <NavigationContainer
969 ref={navigationRef}
970 linking={LINKING}
971 theme={theme}
972 onStateChange={() => {
973 logger.metric(
974 'router:navigate',
975 {from: prevLoggedRouteName.current},
976 {statsig: false},
977 )
978 prevLoggedRouteName.current = getCurrentRouteName()
979 }}
980 onReady={() => {
981 attachRouteToLogEvents(getCurrentRouteName)
982 logModuleInitTime()
983 onReady()
984 logger.metric('router:navigate', {}, {statsig: false})
985 handlePushNotificationEntry()
986 }}
987 // WARNING: Implicit navigation to nested navigators is depreciated in React Navigation 7.x
988 // However, there's a fair amount of places we do that, especially in when popping to the top of stacks.
989 // See BottomBar.tsx for an example of how to handle nested navigators in the tabs correctly.
990 // I'm scared of missing a spot (esp. with push notifications etc) so let's enable this legacy behaviour for now.
991 // We will need to confirm we handle nested navigators correctly by the time we migrate to React Navigation 8.x
992 // -sfn
993 navigationInChildEnabled>
994 {children}
995 </NavigationContainer>
996 </>
997 )
998}
999
1000function getCurrentRouteName() {
1001 if (navigationRef.isReady()) {
1002 return navigationRef.getCurrentRoute()?.name
1003 } else {
1004 return undefined
1005 }
1006}
1007
1008/**
1009 * These helpers can be used from outside of the RoutesContainer
1010 * (eg in the state models).
1011 */
1012
1013function navigate<K extends keyof AllNavigatorParams>(
1014 name: K,
1015 params?: AllNavigatorParams[K],
1016) {
1017 if (navigationRef.isReady()) {
1018 return Promise.race([
1019 new Promise<void>(resolve => {
1020 const handler = () => {
1021 resolve()
1022 navigationRef.removeListener('state', handler)
1023 }
1024 navigationRef.addListener('state', handler)
1025
1026 // @ts-ignore I dont know what would make typescript happy but I have a life -prf
1027 navigationRef.navigate(name, params)
1028 }),
1029 timeout(1e3),
1030 ])
1031 }
1032 return Promise.resolve()
1033}
1034
1035function resetToTab(
1036 tabName: 'HomeTab' | 'SearchTab' | 'MessagesTab' | 'NotificationsTab',
1037) {
1038 if (navigationRef.isReady()) {
1039 navigate(tabName)
1040 if (navigationRef.canGoBack()) {
1041 navigationRef.dispatch(StackActions.popToTop()) //we need to check .canGoBack() before calling it
1042 }
1043 }
1044}
1045
1046// returns a promise that resolves after the state reset is complete
1047function reset(): Promise<void> {
1048 if (navigationRef.isReady()) {
1049 navigationRef.dispatch(
1050 CommonActions.reset({
1051 index: 0,
1052 routes: [{name: isNative ? 'HomeTab' : 'Home'}],
1053 }),
1054 )
1055 return Promise.race([
1056 timeout(1e3),
1057 new Promise<void>(resolve => {
1058 const handler = () => {
1059 resolve()
1060 navigationRef.removeListener('state', handler)
1061 }
1062 navigationRef.addListener('state', handler)
1063 }),
1064 ])
1065 } else {
1066 return Promise.resolve()
1067 }
1068}
1069
1070let didInit = false
1071function logModuleInitTime() {
1072 if (didInit) {
1073 return
1074 }
1075 didInit = true
1076
1077 const initMs = Math.round(
1078 // @ts-ignore Emitted by Metro in the bundle prelude
1079 performance.now() - global.__BUNDLE_START_TIME__,
1080 )
1081 console.log(`Time to first paint: ${initMs} ms`)
1082 logEvent('init', {
1083 initMs,
1084 })
1085
1086 if (isWeb) {
1087 const referrerInfo = Referrer.getReferrerInfo()
1088 if (referrerInfo && referrerInfo.hostname !== 'social.shatteredsky.net') {
1089 logEvent('deepLink:referrerReceived', {
1090 to: window.location.href,
1091 referrer: referrerInfo?.referrer,
1092 hostname: referrerInfo?.hostname,
1093 })
1094 }
1095 }
1096
1097 if (__DEV__) {
1098 // This log is noisy, so keep false committed
1099 const shouldLog = false
1100 // Relies on our patch to polyfill.js in metro-runtime
1101 const initLogs = (global as any).__INIT_LOGS__
1102 if (shouldLog && Array.isArray(initLogs)) {
1103 console.log(initLogs.join('\n'))
1104 }
1105 }
1106}
1107
1108export {
1109 FlatNavigator,
1110 navigate,
1111 reset,
1112 resetToTab,
1113 RoutesContainer,
1114 TabsNavigator,
1115}