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