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, Trans} from '@lingui/macro'
13import {useLingui} from '@lingui/react'
14
15import {retry} from '#/lib/async/retry'
16import {wait} from '#/lib/async/wait'
17import {parseLinkingUrl} from '#/lib/parseLinkingUrl'
18import {isWeb} from '#/platform/detection'
19import {isIOS} from '#/platform/detection'
20import {useAgent, useSession} from '#/state/session'
21import {atoms as a, platform, useBreakpoints, useTheme} from '#/alf'
22import {AgeAssuranceBadge} from '#/components/ageAssurance/AgeAssuranceBadge'
23import {Button, ButtonText} from '#/components/Button'
24import {FullWindowOverlay} from '#/components/FullWindowOverlay'
25import {CheckThick_Stroke2_Corner0_Rounded as SuccessIcon} from '#/components/icons/Check'
26import {CircleInfo_Stroke2_Corner0_Rounded as ErrorIcon} from '#/components/icons/CircleInfo'
27import {Loader} from '#/components/Loader'
28import {Text} from '#/components/Typography'
29import {refetchAgeAssuranceServerState} from '#/ageAssurance'
30import {logger} from '#/ageAssurance'
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 state = parseRedirectOverlayState({
91 result: params.get('result') ?? undefined,
92 actorDid: params.get('actorDid') ?? undefined,
93 })
94
95 if (isWeb) {
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 (state && currentAccount && state.actorDid === currentAccount.did) {
106 return state
107 }
108
109 return null
110 })
111 const open = useCallback((state: RedirectOverlayState) => {
112 setState(state)
113 }, [])
114 const close = useCallback(() => {
115 setState(null)
116 }, [])
117
118 return (
119 <Context.Provider
120 value={useMemo(
121 () => ({
122 isOpen: state !== null,
123 open,
124 close,
125 }),
126 [state, open, close],
127 )}>
128 {children}
129 </Context.Provider>
130 )
131}
132
133export function RedirectOverlay() {
134 const t = useTheme()
135 const {_} = useLingui()
136 const {isOpen} = useRedirectOverlayContext()
137 const {gtMobile} = useBreakpoints()
138
139 return isOpen ? (
140 <FullWindowOverlay>
141 <View
142 style={[
143 a.fixed,
144 a.inset_0,
145 // setting a zIndex when using FullWindowOverlay on iOS
146 // means the taps pass straight through to the underlying content (???)
147 // so don't set it on iOS. FullWindowOverlay already does the job.
148 !isIOS && {zIndex: 9999},
149 t.atoms.bg,
150 gtMobile ? a.p_2xl : a.p_xl,
151 a.align_center,
152 // @ts-ignore
153 platform({
154 web: {
155 paddingTop: '35vh',
156 },
157 default: {
158 paddingTop: Dimensions.get('window').height * 0.35,
159 },
160 }),
161 ]}>
162 <View
163 role="dialog"
164 aria-role="dialog"
165 aria-label={_(msg`Verifying your age assurance status`)}>
166 <View style={[a.pb_3xl, {width: 300}]}>
167 <Inner />
168 </View>
169 </View>
170 </View>
171 </FullWindowOverlay>
172 ) : null
173}
174
175function Inner() {
176 const t = useTheme()
177 const {_} = useLingui()
178 const agent = useAgent()
179 const polling = useRef(false)
180 const unmounted = useRef(false)
181 const [error, setError] = useState(false)
182 const [success, setSuccess] = useState(false)
183 const {close} = useRedirectOverlayContext()
184
185 useEffect(() => {
186 if (polling.current) return
187
188 polling.current = true
189
190 logger.metric('ageAssurance:redirectDialogOpen', {})
191
192 wait(
193 3e3,
194 retry(
195 5,
196 () => true,
197 async () => {
198 if (!agent.session) return
199 if (unmounted.current) return
200
201 const data = await refetchAgeAssuranceServerState({agent})
202
203 if (data?.state.status !== 'assured') {
204 throw new Error(
205 `Polling for age assurance state did not receive assured status`,
206 )
207 }
208
209 return data
210 },
211 1e3,
212 ),
213 )
214 .then(async data => {
215 if (!data) return
216 if (!agent.session) return
217 if (unmounted.current) return
218
219 setSuccess(true)
220
221 logger.metric('ageAssurance:redirectDialogSuccess', {})
222 })
223 .catch(() => {
224 if (unmounted.current) return
225 setError(true)
226 logger.metric('ageAssurance:redirectDialogFail', {})
227 })
228
229 return () => {
230 unmounted.current = true
231 }
232 }, [agent])
233
234 if (success) {
235 return (
236 <>
237 <View style={[a.align_start, a.w_full]}>
238 <AgeAssuranceBadge />
239
240 <View
241 style={[
242 a.flex_row,
243 a.justify_between,
244 a.align_center,
245 a.gap_sm,
246 a.pt_lg,
247 a.pb_md,
248 ]}>
249 <SuccessIcon size="sm" fill={t.palette.positive_500} />
250 <Text style={[a.text_3xl, a.font_bold]}>
251 <Trans>Success</Trans>
252 </Text>
253 </View>
254
255 <Text style={[a.text_md, a.leading_snug]}>
256 <Trans>
257 We've confirmed your age assurance status. You can now close this
258 dialog.
259 </Trans>
260 </Text>
261
262 <View style={[a.w_full, a.pt_lg]}>
263 <Button
264 label={_(msg`Close`)}
265 size="large"
266 variant="solid"
267 color="secondary"
268 onPress={() => close()}>
269 <ButtonText>
270 <Trans>Close</Trans>
271 </ButtonText>
272 </Button>
273 </View>
274 </View>
275 </>
276 )
277 }
278
279 return (
280 <>
281 <View style={[a.align_start, a.w_full]}>
282 <AgeAssuranceBadge />
283
284 <View
285 style={[
286 a.flex_row,
287 a.justify_between,
288 a.align_center,
289 a.gap_sm,
290 a.pt_lg,
291 a.pb_md,
292 ]}>
293 {error && <ErrorIcon size="lg" fill={t.palette.negative_500} />}
294
295 <Text style={[a.text_3xl, a.font_bold]}>
296 {error ? <Trans>Connection issue</Trans> : <Trans>Verifying</Trans>}
297 </Text>
298
299 {!error && <Loader size="lg" />}
300 </View>
301
302 <Text style={[a.text_md, t.atoms.text_contrast_medium, a.leading_snug]}>
303 {error ? (
304 <Trans>
305 We were unable to receive the verification due to a connection
306 issue. It may arrive later. If it does, your account will update
307 automatically.
308 </Trans>
309 ) : (
310 <Trans>
311 We're confirming your age assurance status with our servers. This
312 should only take a few seconds.
313 </Trans>
314 )}
315 </Text>
316
317 {error && (
318 <View style={[a.w_full, a.pt_lg]}>
319 <Button
320 label={_(msg`Close`)}
321 size="large"
322 variant="solid"
323 color="secondary"
324 onPress={() => close()}>
325 <ButtonText>
326 <Trans>Close</Trans>
327 </ButtonText>
328 </Button>
329 </View>
330 )}
331 </View>
332 </>
333 )
334}