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