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 {SigninDialog} from '#/components/dialogs/Signin'
36import {
37 Outlet as PolicyUpdateOverlayPortalOutlet,
38 usePolicyUpdateContext,
39} from '#/components/PolicyUpdateOverlay'
40import {Outlet as PortalOutlet} from '#/components/Portal'
41import {useAgeAssurance} from '#/ageAssurance'
42import {NoAccessScreen} from '#/ageAssurance/components/NoAccessScreen'
43import {RedirectOverlay} from '#/ageAssurance/components/RedirectOverlay'
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 (isAndroid) {
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 (!isAndroid) 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 return (
94 <>
95 <View style={[a.h_full]}>
96 <ErrorBoundary
97 style={{paddingTop: insets.top, paddingBottom: insets.bottom}}>
98 <DrawerLayout>
99 <TabsNavigator />
100 </DrawerLayout>
101 </ErrorBoundary>
102 </View>
103
104 <Composer winHeight={winDim.height} />
105 <ModalsContainer />
106 <MutedWordsDialog />
107 <SigninDialog />
108 <EmailDialog />
109 <AgeAssuranceRedirectDialog />
110 <InAppBrowserConsentDialog />
111 <LinkWarningDialog />
112 <Lightbox />
113
114 {/* Until policy update has been completed by the user, don't render anything that is portaled */}
115 {policyUpdateState.completed && (
116 <>
117 <PortalOutlet />
118 <BottomSheetOutlet />
119 </>
120 )}
121
122 <PolicyUpdateOverlayPortalOutlet />
123 </>
124 )
125}
126
127function DrawerLayout({children}: {children: React.ReactNode}) {
128 const t = useTheme()
129 const isDrawerOpen = useIsDrawerOpen()
130 const setIsDrawerOpen = useSetDrawerOpen()
131 const isDrawerSwipeDisabled = useIsDrawerSwipeDisabled()
132 const winDim = useWindowDimensions()
133
134 const canGoBack = useNavigationState(state => !isStateAtTabRoot(state))
135 const {hasSession} = useSession()
136
137 const swipeEnabled = !canGoBack && hasSession && !isDrawerSwipeDisabled
138 const [trendingScrollGesture] = useState(() => Gesture.Native())
139
140 const renderDrawerContent = useCallback(() => <DrawerContent />, [])
141 const onOpenDrawer = useCallback(
142 () => setIsDrawerOpen(true),
143 [setIsDrawerOpen],
144 )
145 const onCloseDrawer = useCallback(
146 () => setIsDrawerOpen(false),
147 [setIsDrawerOpen],
148 )
149
150 return (
151 <Drawer
152 renderDrawerContent={renderDrawerContent}
153 drawerStyle={{width: Math.min(400, winDim.width * 0.8)}}
154 configureGestureHandler={handler => {
155 handler = handler.requireExternalGestureToFail(trendingScrollGesture)
156
157 if (swipeEnabled) {
158 if (isDrawerOpen) {
159 return handler.activeOffsetX([-1, 1])
160 } else {
161 return (
162 handler
163 // Any movement to the left is a pager swipe
164 // so fail the drawer gesture immediately.
165 .failOffsetX(-1)
166 // Don't rush declaring that a movement to the right
167 // is a drawer swipe. It could be a vertical scroll.
168 .activeOffsetX(5)
169 )
170 }
171 } else {
172 // Fail the gesture immediately.
173 // This seems more reliable than the `swipeEnabled` prop.
174 // With `swipeEnabled` alone, the gesture may freeze after toggling off/on.
175 return handler.failOffsetX([0, 0]).failOffsetY([0, 0])
176 }
177 }}
178 open={isDrawerOpen}
179 onOpen={onOpenDrawer}
180 onClose={onCloseDrawer}
181 swipeEdgeWidth={winDim.width}
182 swipeMinVelocity={100}
183 swipeMinDistance={10}
184 drawerType={isIOS ? 'slide' : 'front'}
185 overlayStyle={{
186 backgroundColor: select(t.name, {
187 light: 'rgba(0, 57, 117, 0.1)',
188 dark: isAndroid ? 'rgba(16, 133, 254, 0.1)' : 'rgba(1, 82, 168, 0.1)',
189 dim: 'rgba(10, 13, 16, 0.8)',
190 }),
191 }}>
192 {children}
193 </Drawer>
194 )
195}
196
197export function Shell() {
198 const t = useTheme()
199 const aa = useAgeAssurance()
200 const {currentAccount} = useSession()
201 const fullyExpandedCount = useDialogFullyExpandedCountContext()
202
203 useIntentHandler()
204
205 useEffect(() => {
206 setSystemUITheme('theme', t)
207 }, [t])
208
209 return (
210 <View testID="mobileShellView" style={[a.h_full, t.atoms.bg]}>
211 <SystemBars
212 style={{
213 statusBar:
214 t.name !== 'light' || (isIOS && fullyExpandedCount > 0)
215 ? 'light'
216 : 'dark',
217 navigationBar: t.name !== 'light' ? 'light' : 'dark',
218 }}
219 />
220 {currentAccount?.status === 'takendown' ? (
221 <Takendown />
222 ) : currentAccount?.status === 'deactivated' ? (
223 <Deactivated />
224 ) : (
225 <>
226 {aa.state.access === aa.Access.None ? (
227 <NoAccessScreen />
228 ) : (
229 <RoutesContainer>
230 <ShellInner />
231 </RoutesContainer>
232 )}
233
234 <RedirectOverlay />
235 </>
236 )}
237 </View>
238 )
239}