forked from
jollywhoppers.com/witchsky.app
Bluesky app fork with some witchin' additions 馃挮
1import {
2 createContext,
3 useCallback,
4 useContext,
5 useEffect,
6 useMemo,
7 useRef,
8 useState,
9} from 'react'
10import {Dimensions, View} from 'react-native'
11import * as Linking from 'expo-linking'
12import {msg} from '@lingui/core/macro'
13import {useLingui} from '@lingui/react'
14import {Trans} from '@lingui/react/macro'
15
16import {retry} from '#/lib/async/retry'
17import {wait} from '#/lib/async/wait'
18import {parseLinkingUrl} from '#/lib/parseLinkingUrl'
19import {useAgent, useSession} from '#/state/session'
20import {atoms as a, platform, useBreakpoints, useTheme} from '#/alf'
21import {AgeAssuranceBadge} from '#/components/ageAssurance/AgeAssuranceBadge'
22import {Button, ButtonText} from '#/components/Button'
23import {FullWindowOverlay} from '#/components/FullWindowOverlay'
24import {CheckThick_Stroke2_Corner0_Rounded as SuccessIcon} from '#/components/icons/Check'
25import {CircleInfo_Stroke2_Corner0_Rounded as ErrorIcon} from '#/components/icons/CircleInfo'
26import {Loader} from '#/components/Loader'
27import {Text} from '#/components/Typography'
28import {refetchAgeAssuranceServerState} from '#/ageAssurance'
29import {useAnalytics} from '#/analytics'
30import {IS_IOS, IS_WEB} from '#/env'
31
32export type RedirectOverlayState = {
33 result: 'success' | 'unknown'
34 actorDid: string
35}
36
37/**
38 * Validate and parse the query parameters returned from the age assurance
39 * redirect. If not valid, returns `undefined` and the dialog will not open.
40 */
41export function parseRedirectOverlayState(
42 state: {
43 result?: string
44 actorDid?: string
45 } = {},
46): RedirectOverlayState | undefined {
47 let result: RedirectOverlayState['result'] = 'unknown'
48 const actorDid = state.actorDid
49
50 switch (state.result) {
51 case 'success':
52 result = 'success'
53 break
54 case 'unknown':
55 default:
56 result = 'unknown'
57 break
58 }
59
60 if (actorDid) {
61 return {
62 result,
63 actorDid,
64 }
65 }
66}
67
68const Context = createContext<{
69 isOpen: boolean
70 open: (state: RedirectOverlayState) => void
71 close: () => void
72}>({
73 isOpen: false,
74 open: () => {},
75 close: () => {},
76})
77
78export function useRedirectOverlayContext() {
79 return useContext(Context)
80}
81
82export function Provider({children}: {children?: React.ReactNode}) {
83 const {currentAccount} = useSession()
84 const incomingUrl = Linking.useLinkingURL()
85 const [state, setState] = useState<RedirectOverlayState | null>(() => {
86 if (!incomingUrl) return null
87 const url = parseLinkingUrl(incomingUrl)
88 if (url.pathname !== '/intent/age-assurance') return null
89 const params = url.searchParams
90 const parsedState = parseRedirectOverlayState({
91 result: params.get('result') ?? undefined,
92 actorDid: params.get('actorDid') ?? undefined,
93 })
94
95 if (IS_WEB) {
96 // Clear the URL parameters so they don't re-trigger
97 history.pushState(null, '', '/')
98 }
99
100 /*
101 * If we don't have an account or the account doesn't match, do
102 * nothing. By the time the user switches to their other account, AA
103 * state should be ready for them.
104 */
105 if (
106 parsedState &&
107 currentAccount &&
108 parsedState.actorDid === currentAccount.did
109 ) {
110 return parsedState
111 }
112
113 return null
114 })
115 const open = useCallback((state: RedirectOverlayState) => {
116 setState(state)
117 }, [])
118 const close = useCallback(() => {
119 setState(null)
120 }, [])
121
122 return (
123 <Context.Provider
124 value={useMemo(
125 () => ({
126 isOpen: state !== null,
127 open,
128 close,
129 }),
130 [state, open, close],
131 )}>
132 {children}
133 </Context.Provider>
134 )
135}
136
137export function RedirectOverlay() {
138 const t = useTheme()
139 const {_} = useLingui()
140 const {isOpen} = useRedirectOverlayContext()
141 const {gtMobile} = useBreakpoints()
142
143 return isOpen ? (
144 <FullWindowOverlay>
145 <View
146 style={[
147 a.fixed,
148 a.inset_0,
149 // setting a zIndex when using FullWindowOverlay on iOS
150 // means the taps pass straight through to the underlying content (???)
151 // so don't set it on iOS. FullWindowOverlay already does the job.
152 !IS_IOS && {zIndex: 9999},
153 t.atoms.bg,
154 gtMobile ? a.p_2xl : a.p_xl,
155 a.align_center,
156 // @ts-ignore
157 platform({
158 web: {
159 paddingTop: '35vh',
160 },
161 default: {
162 paddingTop: Dimensions.get('window').height * 0.35,
163 },
164 }),
165 ]}>
166 <View
167 role="dialog"
168 aria-role="dialog"
169 aria-label={_(msg`Verifying your age assurance status`)}>
170 <View style={[a.pb_3xl, {width: 300}]}>
171 <Inner />
172 </View>
173 </View>
174 </View>
175 </FullWindowOverlay>
176 ) : null
177}
178
179function Inner() {
180 const t = useTheme()
181 const ax = useAnalytics()
182 const {_} = useLingui()
183 const agent = useAgent()
184 const polling = useRef(false)
185 const unmounted = useRef(false)
186 const [error, setError] = useState(false)
187 const [success, setSuccess] = useState(false)
188 const {close} = useRedirectOverlayContext()
189
190 useEffect(() => {
191 if (polling.current) return
192
193 polling.current = true
194
195 ax.metric('ageAssurance:redirectDialogOpen', {})
196
197 wait(
198 3e3,
199 retry(
200 5,
201 () => true,
202 async () => {
203 if (!agent.session) return
204 if (unmounted.current) return
205
206 const data = await refetchAgeAssuranceServerState({agent})
207
208 if (data?.state.status !== 'assured') {
209 throw new Error(
210 `Polling for age assurance state did not receive assured status`,
211 )
212 }
213
214 return data
215 },
216 1e3,
217 ),
218 )
219 .then(async data => {
220 if (!data) return
221 if (!agent.session) return
222 if (unmounted.current) return
223
224 setSuccess(true)
225
226 ax.metric('ageAssurance:redirectDialogSuccess', {})
227 })
228 .catch(() => {
229 if (unmounted.current) return
230 setError(true)
231 ax.metric('ageAssurance:redirectDialogFail', {})
232 })
233
234 return () => {
235 unmounted.current = true
236 }
237 }, [ax, agent])
238
239 if (success) {
240 return (
241 <>
242 <View style={[a.align_start, a.w_full]}>
243 <AgeAssuranceBadge />
244
245 <View
246 style={[
247 a.flex_row,
248 a.justify_between,
249 a.align_center,
250 a.gap_sm,
251 a.pt_lg,
252 a.pb_md,
253 ]}>
254 <SuccessIcon size="sm" fill={t.palette.positive_500} />
255 <Text style={[a.text_3xl, a.font_bold]}>
256 <Trans>Success</Trans>
257 </Text>
258 </View>
259
260 <Text style={[a.text_md, a.leading_snug]}>
261 <Trans>
262 We've confirmed your age assurance status. You can now close this
263 dialog.
264 </Trans>
265 </Text>
266
267 <View style={[a.w_full, a.pt_lg]}>
268 <Button
269 label={_(msg`Close`)}
270 size="large"
271 variant="solid"
272 color="secondary"
273 onPress={() => close()}>
274 <ButtonText>
275 <Trans>Close</Trans>
276 </ButtonText>
277 </Button>
278 </View>
279 </View>
280 </>
281 )
282 }
283
284 return (
285 <>
286 <View style={[a.align_start, a.w_full]}>
287 <AgeAssuranceBadge />
288
289 <View
290 style={[
291 a.flex_row,
292 a.justify_between,
293 a.align_center,
294 a.gap_sm,
295 a.pt_lg,
296 a.pb_md,
297 ]}>
298 {error && <ErrorIcon size="lg" fill={t.palette.negative_500} />}
299
300 <Text style={[a.text_3xl, a.font_bold]}>
301 {error ? <Trans>Connection issue</Trans> : <Trans>Verifying</Trans>}
302 </Text>
303
304 {!error && <Loader size="lg" />}
305 </View>
306
307 <Text style={[a.text_md, t.atoms.text_contrast_medium, a.leading_snug]}>
308 {error ? (
309 <Trans>
310 We were unable to receive the verification due to a connection
311 issue. It may arrive later. If it does, your account will update
312 automatically.
313 </Trans>
314 ) : (
315 <Trans>
316 We're confirming your age assurance status with our servers. This
317 should only take a few seconds.
318 </Trans>
319 )}
320 </Text>
321
322 {error && (
323 <View style={[a.w_full, a.pt_lg]}>
324 <Button
325 label={_(msg`Close`)}
326 size="large"
327 variant="solid"
328 color="secondary"
329 onPress={() => close()}>
330 <ButtonText>
331 <Trans>Close</Trans>
332 </ButtonText>
333 </Button>
334 </View>
335 )}
336 </View>
337 </>
338 )
339}