forked from
jollywhoppers.com/witchsky.app
Bluesky app fork with some witchin' additions 馃挮
1import {useCallback, useEffect, useState} from 'react'
2import {BackHandler, useWindowDimensions, View} from 'react-native'
3import {Drawer} from 'react-native-drawer-layout'
4import {SystemBars} from 'react-native-edge-to-edge'
5import {Gesture} from 'react-native-gesture-handler'
6import {useSafeAreaInsets} from 'react-native-safe-area-context'
7import {useNavigation, useNavigationState} from '@react-navigation/native'
8
9import {useDedupe} from '#/lib/hooks/useDedupe'
10import {useIntentHandler} from '#/lib/hooks/useIntentHandler'
11import {useNotificationsHandler} from '#/lib/hooks/useNotificationHandler'
12import {useNotificationsRegistration} from '#/lib/notifications/notifications'
13import {isStateAtTabRoot} from '#/lib/routes/helpers'
14import {useDialogFullyExpandedCountContext} from '#/state/dialogs'
15import {useSession} from '#/state/session'
16import {
17 useIsDrawerOpen,
18 useIsDrawerSwipeDisabled,
19 useSetDrawerOpen,
20} from '#/state/shell'
21import {useCloseAnyActiveElement} from '#/state/util'
22import {Lightbox} from '#/view/com/lightbox/Lightbox'
23import {ModalsContainer} from '#/view/com/modals/Modal'
24import {ErrorBoundary} from '#/view/com/util/ErrorBoundary'
25import {Deactivated} from '#/screens/Deactivated'
26import {Takendown} from '#/screens/Takendown'
27import {atoms as a, select, useTheme} from '#/alf'
28import {setSystemUITheme} from '#/alf/util/systemUI'
29import {AgeAssuranceRedirectDialog} from '#/components/ageAssurance/AgeAssuranceRedirectDialog'
30import {EmailDialog} from '#/components/dialogs/EmailDialog'
31import {InAppBrowserConsentDialog} from '#/components/dialogs/InAppBrowserConsent'
32import {LinkWarningDialog} from '#/components/dialogs/LinkWarning'
33import {MutedWordsDialog} from '#/components/dialogs/MutedWords'
34import {NuxDialogs} from '#/components/dialogs/nuxs'
35import {SigninDialog} from '#/components/dialogs/Signin'
36import {GlobalReportDialog} from '#/components/moderation/ReportDialog'
37import {
38 Outlet as PolicyUpdateOverlayPortalOutlet,
39 usePolicyUpdateContext,
40} from '#/components/PolicyUpdateOverlay'
41import {Outlet as PortalOutlet} from '#/components/Portal'
42import {useAgeAssurance} from '#/ageAssurance'
43import {NoAccessScreen} from '#/ageAssurance/components/NoAccessScreen'
44import {RedirectOverlay} from '#/ageAssurance/components/RedirectOverlay'
45import {PassiveAnalytics} from '#/analytics/PassiveAnalytics'
46import {IS_ANDROID, IS_IOS} from '#/env'
47import {RoutesContainer, TabsNavigator} from '#/Navigation'
48import {BottomSheetOutlet} from '../../../modules/bottom-sheet'
49import {updateActiveViewAsync} from '../../../modules/expo-bluesky-swiss-army/src/VisibilityView'
50import {Composer} from './Composer'
51import {DrawerContent} from './Drawer'
52
53function ShellInner() {
54 const winDim = useWindowDimensions()
55 const insets = useSafeAreaInsets()
56 const {state: policyUpdateState} = usePolicyUpdateContext()
57
58 const closeAnyActiveElement = useCloseAnyActiveElement()
59
60 useNotificationsRegistration()
61 useNotificationsHandler()
62
63 useEffect(() => {
64 if (IS_ANDROID) {
65 const listener = BackHandler.addEventListener('hardwareBackPress', () => {
66 return closeAnyActiveElement()
67 })
68
69 return () => {
70 listener.remove()
71 }
72 }
73 }, [closeAnyActiveElement])
74
75 // HACK
76 // expo-video doesn't like it when you try and move a `player` to another `VideoView`. Instead, we need to actually
77 // unregister that player to let the new screen register it. This is only a problem on Android, so we only need to
78 // apply it there.
79 // The `state` event should only fire whenever we push or pop to a screen, and should not fire consecutively quickly.
80 // To be certain though, we will also dedupe these calls.
81 const navigation = useNavigation()
82 const dedupe = useDedupe(1000)
83 useEffect(() => {
84 if (!IS_ANDROID) return
85 const onFocusOrBlur = () => {
86 setTimeout(() => {
87 dedupe(updateActiveViewAsync)
88 }, 500)
89 }
90 navigation.addListener('state', onFocusOrBlur)
91 return () => {
92 navigation.removeListener('state', onFocusOrBlur)
93 }
94 }, [dedupe, navigation])
95
96 const drawerLayout = useCallback(
97 ({children}: {children: React.ReactNode}) => (
98 <DrawerLayout>{children}</DrawerLayout>
99 ),
100 [],
101 )
102
103 return (
104 <>
105 <View style={[a.h_full]}>
106 <ErrorBoundary
107 style={{paddingTop: insets.top, paddingBottom: insets.bottom}}>
108 <TabsNavigator layout={drawerLayout} />
109 </ErrorBoundary>
110 </View>
111
112 <Composer winHeight={winDim.height} />
113 <ModalsContainer />
114 <MutedWordsDialog />
115 <SigninDialog />
116 <EmailDialog />
117 <AgeAssuranceRedirectDialog />
118 <InAppBrowserConsentDialog />
119 <LinkWarningDialog />
120 <Lightbox />
121 <NuxDialogs />
122 <GlobalReportDialog />
123
124 {/* Until policy update has been completed by the user, don't render anything that is portaled */}
125 {policyUpdateState.completed && (
126 <>
127 <PortalOutlet />
128 <BottomSheetOutlet />
129 </>
130 )}
131
132 <PolicyUpdateOverlayPortalOutlet />
133 </>
134 )
135}
136
137function DrawerLayout({children}: {children: React.ReactNode}) {
138 const t = useTheme()
139 const isDrawerOpen = useIsDrawerOpen()
140 const setIsDrawerOpen = useSetDrawerOpen()
141 const isDrawerSwipeDisabled = useIsDrawerSwipeDisabled()
142 const winDim = useWindowDimensions()
143
144 const canGoBack = useNavigationState(state => !isStateAtTabRoot(state))
145 const {hasSession} = useSession()
146
147 const swipeEnabled = !canGoBack && hasSession && !isDrawerSwipeDisabled
148 const [trendingScrollGesture] = useState(() => Gesture.Native())
149
150 const renderDrawerContent = useCallback(() => <DrawerContent />, [])
151 const onOpenDrawer = useCallback(
152 () => setIsDrawerOpen(true),
153 [setIsDrawerOpen],
154 )
155 const onCloseDrawer = useCallback(
156 () => setIsDrawerOpen(false),
157 [setIsDrawerOpen],
158 )
159
160 return (
161 <Drawer
162 renderDrawerContent={renderDrawerContent}
163 drawerStyle={{width: Math.min(400, winDim.width * 0.8)}}
164 configureGestureHandler={handler => {
165 handler = handler.requireExternalGestureToFail(trendingScrollGesture)
166
167 if (swipeEnabled) {
168 if (isDrawerOpen) {
169 return handler.activeOffsetX([-1, 1])
170 } else {
171 return (
172 handler
173 // Any movement to the left is a pager swipe
174 // so fail the drawer gesture immediately.
175 .failOffsetX(-1)
176 // Don't rush declaring that a movement to the right
177 // is a drawer swipe. It could be a vertical scroll.
178 .activeOffsetX(5)
179 )
180 }
181 } else {
182 // Fail the gesture immediately.
183 // This seems more reliable than the `swipeEnabled` prop.
184 // With `swipeEnabled` alone, the gesture may freeze after toggling off/on.
185 return handler.failOffsetX([0, 0]).failOffsetY([0, 0])
186 }
187 }}
188 open={isDrawerOpen}
189 onOpen={onOpenDrawer}
190 onClose={onCloseDrawer}
191 swipeEdgeWidth={winDim.width}
192 swipeMinVelocity={100}
193 swipeMinDistance={10}
194 drawerType={IS_IOS ? 'slide' : 'front'}
195 overlayStyle={{
196 backgroundColor: select(t.name, {
197 light: 'rgba(0, 57, 117, 0.1)',
198 dark: IS_ANDROID
199 ? 'rgba(16, 133, 254, 0.1)'
200 : 'rgba(1, 82, 168, 0.1)',
201 dim: 'rgba(10, 13, 16, 0.8)',
202 }),
203 }}>
204 {children}
205 </Drawer>
206 )
207}
208
209export function Shell() {
210 const t = useTheme()
211 const aa = useAgeAssurance()
212 const {currentAccount} = useSession()
213 const fullyExpandedCount = useDialogFullyExpandedCountContext()
214
215 useIntentHandler()
216
217 useEffect(() => {
218 setSystemUITheme('theme', t)
219 }, [t])
220
221 return (
222 <View testID="mobileShellView" style={[a.h_full, t.atoms.bg]}>
223 <SystemBars
224 style={{
225 statusBar:
226 t.name !== 'light' || (IS_IOS && fullyExpandedCount > 0)
227 ? 'light'
228 : 'dark',
229 navigationBar: t.name !== 'light' ? 'light' : 'dark',
230 }}
231 />
232 {currentAccount?.status === 'takendown' ? (
233 <Takendown />
234 ) : currentAccount?.status === 'deactivated' ? (
235 <Deactivated />
236 ) : (
237 <>
238 {aa.state.access === aa.Access.None ? (
239 <NoAccessScreen />
240 ) : (
241 <RoutesContainer>
242 <ShellInner />
243 </RoutesContainer>
244 )}
245
246 <RedirectOverlay />
247 </>
248 )}
249
250 <PassiveAnalytics />
251 </View>
252 )
253}