Bluesky app fork with some witchin' additions 馃挮
witchsky.app
bluesky
fork
client
1import {useCallback, useEffect, useLayoutEffect, useState} from 'react'
2import {StyleSheet, TouchableWithoutFeedback, View} from 'react-native'
3import {msg} from '@lingui/core/macro'
4import {useLingui} from '@lingui/react'
5import {useNavigation} from '@react-navigation/native'
6import {RemoveScrollBar} from 'react-remove-scroll-bar'
7
8import {useIntentHandler} from '#/lib/hooks/useIntentHandler'
9import {type NavigationProp} from '#/lib/routes/types'
10import {useSession} from '#/state/session'
11import {useIsDrawerOpen, useSetDrawerOpen} from '#/state/shell'
12import {useCloseAllActiveElements} from '#/state/util'
13import {Lightbox} from '#/view/com/lightbox/Lightbox'
14import {ModalsContainer} from '#/view/com/modals/Modal'
15import {ErrorBoundary} from '#/view/com/util/ErrorBoundary'
16import {LOGO_PATH, LOGO_VIEW_BOX} from '#/view/icons/Logo'
17import {Deactivated} from '#/screens/Deactivated'
18import {Takendown} from '#/screens/Takendown'
19import {atoms as a, select, useBreakpoints, useTheme} from '#/alf'
20import {AgeAssuranceRedirectDialog} from '#/components/ageAssurance/AgeAssuranceRedirectDialog'
21import {EmailDialog} from '#/components/dialogs/EmailDialog'
22import {LinkWarningDialog} from '#/components/dialogs/LinkWarning'
23import {MutedWordsDialog} from '#/components/dialogs/MutedWords'
24import {NuxDialogs} from '#/components/dialogs/nuxs'
25import {SigninDialog} from '#/components/dialogs/Signin'
26import {useWelcomeModal} from '#/components/hooks/useWelcomeModal'
27import {GlobalReportDialog} from '#/components/moderation/ReportDialog'
28import {
29 Outlet as PolicyUpdateOverlayPortalOutlet,
30 usePolicyUpdateContext,
31} from '#/components/PolicyUpdateOverlay'
32import {Outlet as PortalOutlet} from '#/components/Portal'
33import {WelcomeModal} from '#/components/WelcomeModal'
34import {RedirectOverlay} from '#/ageAssurance/components/RedirectOverlay'
35import {PassiveAnalytics} from '#/analytics/PassiveAnalytics'
36import {FlatNavigator, RoutesContainer} from '#/Navigation'
37import {Composer} from './Composer'
38import {DrawerContent} from './Drawer'
39
40function createFaviconDataUrl(color: string) {
41 const svg = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="${LOGO_VIEW_BOX}"><path fill="${color}" d="${LOGO_PATH}"/></svg>`
42 return `data:image/svg+xml,${encodeURIComponent(svg)}`
43}
44
45function upsertHeadLink({
46 rel,
47 href,
48 type,
49 sizes,
50}: {
51 rel: string
52 href: string
53 type?: string
54 sizes?: string
55}) {
56 let link = document.head.querySelector<HTMLLinkElement>(`link[rel="${rel}"]`)
57
58 if (!link) {
59 link = document.createElement('link')
60 link.rel = rel
61 document.head.appendChild(link)
62 }
63
64 if (type) link.type = type
65 if (sizes) link.sizes = sizes
66 link.href = href
67}
68
69function ShellInner() {
70 const t = useTheme()
71 const navigator = useNavigation<NavigationProp>()
72 const closeAllActiveElements = useCloseAllActiveElements()
73 const {state: policyUpdateState} = usePolicyUpdateContext()
74 const welcomeModalControl = useWelcomeModal()
75
76 useIntentHandler()
77
78 useLayoutEffect(() => {
79 const rootElement = document.documentElement
80 rootElement.className = `html`
81 rootElement.style.setProperty(
82 'background',
83 `${t.atoms.bg.backgroundColor}`,
84 'important',
85 )
86 }, [t.atoms.bg.backgroundColor, t.name])
87
88 useLayoutEffect(() => {
89 const color = t.palette.primary_500
90
91 const styleId = 'prosemirror-mention-color'
92 let style = document.getElementById(styleId) as HTMLStyleElement | null
93
94 if (!style) {
95 style = document.createElement('style')
96 style.id = styleId
97 document.head.appendChild(style)
98 }
99
100 style.innerHTML = `
101 .ProseMirror .mention {
102 color: ${color} !important;
103 }
104 .ProseMirror a,
105 .ProseMirror .autolink {
106 color: ${color} !important;
107 }
108 `
109 }, [t.palette.primary_500])
110
111 useLayoutEffect(() => {
112 const faviconHref = createFaviconDataUrl(t.palette.primary_500)
113
114 upsertHeadLink({
115 rel: 'icon',
116 href: faviconHref,
117 type: 'image/svg+xml',
118 sizes: 'any',
119 })
120 upsertHeadLink({
121 rel: 'shortcut icon',
122 href: faviconHref,
123 type: 'image/svg+xml',
124 })
125 }, [t.palette.primary_500])
126
127 useEffect(() => {
128 const unsubscribe = navigator.addListener('state', () => {
129 closeAllActiveElements()
130 })
131 return unsubscribe
132 }, [navigator, closeAllActiveElements])
133
134 const drawerLayout = useCallback(
135 ({children}: {children: React.ReactNode}) => (
136 <DrawerLayout>{children}</DrawerLayout>
137 ),
138 [],
139 )
140 return (
141 <>
142 <ErrorBoundary>
143 <FlatNavigator layout={drawerLayout} />
144 </ErrorBoundary>
145 <Composer winHeight={0} />
146 <ModalsContainer />
147 <MutedWordsDialog />
148 <SigninDialog />
149 <EmailDialog />
150 <AgeAssuranceRedirectDialog />
151 <LinkWarningDialog />
152 <Lightbox />
153 <NuxDialogs />
154 <GlobalReportDialog />
155
156 {welcomeModalControl.isOpen && (
157 <WelcomeModal control={welcomeModalControl} />
158 )}
159
160 {/* Until policy update has been completed by the user, don't render anything that is portaled */}
161 {policyUpdateState.completed && (
162 <>
163 <PortalOutlet />
164 </>
165 )}
166
167 <PolicyUpdateOverlayPortalOutlet />
168
169 {/* workaround for a WebKit compositing bug. After a
170 dialog (which uses Portal + fixed elements + CSS animations)
171 closes, WebKit can skip painting subsequent view transitions.
172 A persistent zero-size fixed element keeps the compositing
173 tree from entering this broken state. */}
174 <div
175 aria-hidden
176 style={{
177 position: 'fixed',
178 width: 0,
179 height: 0,
180 pointerEvents: 'none',
181 }}
182 />
183 </>
184 )
185}
186
187function DrawerLayout({children}: {children: React.ReactNode}) {
188 const t = useTheme()
189 const isDrawerOpen = useIsDrawerOpen()
190 const setDrawerOpen = useSetDrawerOpen()
191 const {gtTablet} = useBreakpoints()
192 const {_} = useLingui()
193 const showDrawer = !gtTablet && isDrawerOpen
194 const [showDrawerDelayedExit, setShowDrawerDelayedExit] = useState(showDrawer)
195
196 useLayoutEffect(() => {
197 if (showDrawer !== showDrawerDelayedExit) {
198 if (showDrawer) {
199 setShowDrawerDelayedExit(true)
200 } else {
201 const timeout = setTimeout(() => {
202 setShowDrawerDelayedExit(false)
203 }, 160)
204 return () => clearTimeout(timeout)
205 }
206 }
207 }, [showDrawer, showDrawerDelayedExit])
208
209 return (
210 <>
211 {children}
212 {showDrawerDelayedExit && (
213 <>
214 <RemoveScrollBar />
215 <TouchableWithoutFeedback
216 onPress={ev => {
217 // Only close if press happens outside of the drawer
218 if (ev.target === ev.currentTarget) {
219 setDrawerOpen(false)
220 }
221 }}
222 accessibilityLabel={_(msg`Close drawer menu`)}
223 accessibilityHint="">
224 <View
225 style={[
226 styles.drawerMask,
227 {
228 backgroundColor: showDrawer
229 ? select(t.name, {
230 light: 'rgba(0, 57, 117, 0.1)',
231 dark: 'rgba(1, 82, 168, 0.1)',
232 dim: 'rgba(10, 13, 16, 0.8)',
233 })
234 : 'transparent',
235 },
236 a.transition_color,
237 ]}>
238 <View
239 style={[
240 styles.drawerContainer,
241 showDrawer ? a.slide_in_left : a.slide_out_left,
242 ]}>
243 <DrawerContent />
244 </View>
245 </View>
246 </TouchableWithoutFeedback>
247 </>
248 )}
249 </>
250 )
251}
252
253export function Shell() {
254 const t = useTheme()
255 const {currentAccount} = useSession()
256 return (
257 <View style={[a.util_screen_outer, t.atoms.bg]}>
258 {currentAccount?.status === 'takendown' ? (
259 <Takendown />
260 ) : currentAccount?.status === 'deactivated' ? (
261 <Deactivated />
262 ) : (
263 <>
264 <RoutesContainer>
265 <ShellInner />
266 </RoutesContainer>
267
268 <RedirectOverlay />
269 </>
270 )}
271
272 <PassiveAnalytics />
273 </View>
274 )
275}
276
277const styles = StyleSheet.create({
278 drawerMask: {
279 ...a.fixed,
280 width: '100%',
281 height: '100%',
282 top: 0,
283 left: 0,
284 },
285 drawerContainer: {
286 display: 'flex',
287 ...a.fixed,
288 top: 0,
289 left: 0,
290 height: '100%',
291 width: 330,
292 maxWidth: '80%',
293 },
294})