forked from
jollywhoppers.com/witchsky.app
Bluesky app fork with some witchin' additions 💫
1import {useEffect, useRef} from 'react'
2import {View} from 'react-native'
3// Based on @react-navigation/native-stack/src/navigators/createNativeStackNavigator.ts
4// MIT License
5// Copyright (c) 2017 React Navigation Contributors
6import {
7 createNavigatorFactory,
8 type EventArg,
9 type NavigatorTypeBagBase,
10 type ParamListBase,
11 type StackActionHelpers,
12 StackActions,
13 type StackNavigationState,
14 StackRouter,
15 type StackRouterOptions,
16 type StaticConfig,
17 type TypedNavigator,
18 useNavigationBuilder,
19} from '@react-navigation/native'
20import {NativeStackView} from '@react-navigation/native-stack'
21import {
22 type NativeStackNavigationEventMap,
23 type NativeStackNavigationOptions,
24 type NativeStackNavigationProp,
25 type NativeStackNavigatorProps,
26} from '@react-navigation/native-stack'
27
28import {useWebMediaQueries} from '#/lib/hooks/useWebMediaQueries'
29import {useSession} from '#/state/session'
30import {useOnboardingState} from '#/state/shell'
31import {
32 useLoggedOutView,
33 useLoggedOutViewControls,
34} from '#/state/shell/logged-out'
35import {LoggedOut} from '#/view/com/auth/LoggedOut'
36import {Onboarding} from '#/screens/Onboarding'
37import {SignupQueued} from '#/screens/SignupQueued'
38import {atoms as a, useLayoutBreakpoints} from '#/alf'
39import {PolicyUpdateOverlay} from '#/components/PolicyUpdateOverlay'
40import {IS_NATIVE, IS_WEB} from '#/env'
41import {BottomBarWeb} from './bottom-bar/BottomBarWeb'
42import {DesktopLeftNav} from './desktop/LeftNav'
43import {DesktopRightNav} from './desktop/RightNav'
44
45// On web, only this many screens (beyond Home + focused) stay mounted.
46// Older screens are unmounted to prevent memory growth during long sessions.
47const WEB_MAX_CACHED_SCREENS = 5
48
49type NativeStackNavigationOptionsWithAuth = NativeStackNavigationOptions & {
50 requireAuth?: boolean
51}
52
53function NativeStackNavigator({
54 id,
55 initialRouteName,
56 UNSTABLE_routeNamesChangeBehavior,
57 children,
58 layout,
59 screenListeners,
60 screenOptions,
61 screenLayout,
62 UNSTABLE_router,
63 ...rest
64}: NativeStackNavigatorProps) {
65 // --- this is copy and pasted from the original native stack navigator ---
66 const {state, describe, descriptors, navigation, NavigationContent} =
67 useNavigationBuilder<
68 StackNavigationState<ParamListBase>,
69 StackRouterOptions,
70 StackActionHelpers<ParamListBase>,
71 NativeStackNavigationOptionsWithAuth,
72 NativeStackNavigationEventMap
73 >(StackRouter, {
74 id,
75 initialRouteName,
76 UNSTABLE_routeNamesChangeBehavior,
77 children,
78 layout,
79 screenListeners,
80 screenOptions,
81 screenLayout,
82 UNSTABLE_router,
83 })
84
85 useEffect(
86 () =>
87 // @ts-expect-error: there may not be a tab navigator in parent
88 navigation?.addListener?.('tabPress', (e: any) => {
89 const isFocused = navigation.isFocused()
90
91 // Run the operation in the next frame so we're sure all listeners have been run
92 // This is necessary to know if preventDefault() has been called
93 requestAnimationFrame(() => {
94 if (
95 state.index > 0 &&
96 isFocused &&
97 !(e as EventArg<'tabPress', true>).defaultPrevented
98 ) {
99 // When user taps on already focused tab and we're inside the tab,
100 // reset the stack to replicate native behaviour
101 navigation.dispatch({
102 ...StackActions.popToTop(),
103 target: state.key,
104 })
105 }
106 })
107 }),
108 [navigation, state.index, state.key],
109 )
110
111 // --- our custom logic starts here ---
112 // Web LRU: tracks route keys in most-recently-focused order
113 const lruKeysRef = useRef<string[]>([])
114 const {hasSession, currentAccount} = useSession()
115 const activeRoute = state.routes[state.index]
116 const activeDescriptor = descriptors[activeRoute.key]
117 const activeRouteRequiresAuth = activeDescriptor.options.requireAuth ?? false
118 const onboardingState = useOnboardingState()
119 const {showLoggedOut} = useLoggedOutView()
120 const {setShowLoggedOut} = useLoggedOutViewControls()
121 const {isMobile} = useWebMediaQueries()
122 const {leftNavMinimal} = useLayoutBreakpoints()
123 if (!hasSession && (activeRouteRequiresAuth || IS_NATIVE)) {
124 return <LoggedOut />
125 }
126 if (hasSession && currentAccount?.signupQueued) {
127 return <SignupQueued />
128 }
129 if (showLoggedOut) {
130 return <LoggedOut onDismiss={() => setShowLoggedOut(false)} />
131 }
132 if (onboardingState.isActive) {
133 return <Onboarding />
134 }
135 // On web, limit how many screens stay mounted to prevent memory growth.
136 // Home is always pinned, the focused screen is always mounted, and the
137 // most recently visited screens are kept up to WEB_MAX_CACHED_SCREENS.
138 // Evicted screens render a lightweight placeholder — the route stays in
139 // state so browser back/forward still works; the component just re-mounts.
140 let finalDescriptors = descriptors
141 if (IS_WEB) {
142 const focusedKey = activeRoute.key
143
144 // Update LRU: move focused key to front
145 const lru = lruKeysRef.current
146 const idx = lru.indexOf(focusedKey)
147 if (idx > 0) {
148 lru.splice(idx, 1)
149 lru.unshift(focusedKey)
150 } else if (idx === -1) {
151 lru.unshift(focusedKey)
152 }
153
154 // Remove keys for routes no longer in the stack
155 const routeKeySet = new Set(state.routes.map(r => r.key))
156 lruKeysRef.current = lruKeysRef.current.filter(k => routeKeySet.has(k))
157
158 // Build mount set: Home (pinned) + focused + N most recent
159 const mountSet = new Set<string>()
160 mountSet.add(focusedKey)
161 const homeKey = state.routes.find(r => r.name === 'Home')?.key
162 if (homeKey) mountSet.add(homeKey)
163 let cached = 0
164 for (const key of lruKeysRef.current) {
165 if (cached >= WEB_MAX_CACHED_SCREENS) break
166 if (!mountSet.has(key)) {
167 mountSet.add(key)
168 cached++
169 }
170 }
171
172 // Evicted screens get a lightweight placeholder instead of their full tree
173 finalDescriptors = {} as typeof descriptors
174 for (const key in descriptors) {
175 if (mountSet.has(key)) {
176 finalDescriptors[key] = descriptors[key]
177 } else {
178 finalDescriptors[key] = {
179 ...descriptors[key],
180 render: () => <View />,
181 }
182 }
183 }
184 }
185
186 // Show the bottom bar if we have a session only on mobile web. If we don't have a session, we want to show it
187 // on both tablet and mobile web so that we see the create account CTA.
188 const showBottomBar = hasSession ? isMobile : leftNavMinimal
189
190 return (
191 <NavigationContent>
192 <View role="main" style={a.flex_1}>
193 <NativeStackView
194 {...rest}
195 state={state}
196 navigation={navigation}
197 descriptors={finalDescriptors}
198 describe={describe}
199 />
200 </View>
201 {IS_WEB && (
202 <>
203 {showBottomBar ? <BottomBarWeb /> : <DesktopLeftNav />}
204 {!isMobile && <DesktopRightNav routeName={activeRoute.name} />}
205 </>
206 )}
207
208 {/* Only shown after logged in and onboaring etc are complete */}
209 {hasSession && <PolicyUpdateOverlay />}
210 </NavigationContent>
211 )
212}
213
214export function createNativeStackNavigatorWithAuth<
215 const ParamList extends ParamListBase,
216 const NavigatorID extends string | undefined = string | undefined,
217 const TypeBag extends NavigatorTypeBagBase = {
218 ParamList: ParamList
219 NavigatorID: NavigatorID
220 State: StackNavigationState<ParamList>
221 ScreenOptions: NativeStackNavigationOptionsWithAuth
222 EventMap: NativeStackNavigationEventMap
223 NavigationList: {
224 [RouteName in keyof ParamList]: NativeStackNavigationProp<
225 ParamList,
226 RouteName,
227 NavigatorID
228 >
229 }
230 Navigator: typeof NativeStackNavigator
231 },
232 const Config extends StaticConfig<TypeBag> = StaticConfig<TypeBag>,
233>(config?: Config): TypedNavigator<TypeBag, Config> {
234 return createNavigatorFactory(NativeStackNavigator)(config)
235}