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 {useAgent, useSession} from '#/state/session'
19import {atoms as a, platform, useBreakpoints, useTheme} from '#/alf'
20import {AgeAssuranceBadge} from '#/components/ageAssurance/AgeAssuranceBadge'
21import {Button, ButtonText} from '#/components/Button'
22import {FullWindowOverlay} from '#/components/FullWindowOverlay'
23import {CheckThick_Stroke2_Corner0_Rounded as SuccessIcon} from '#/components/icons/Check'
24import {CircleInfo_Stroke2_Corner0_Rounded as ErrorIcon} from '#/components/icons/CircleInfo'
25import {Loader} from '#/components/Loader'
26import {Text} from '#/components/Typography'
27import {refetchAgeAssuranceServerState} from '#/ageAssurance'
28import {useAnalytics} from '#/analytics'
29import {IS_IOS, IS_WEB} from '#/env'
30
31export type RedirectOverlayState = {
32 result: 'success' | 'unknown'
33 actorDid: string
34}
35
36/**
37 * Validate and parse the query parameters returned from the age assurance
38 * redirect. If not valid, returns `undefined` and the dialog will not open.
39 */
40export function parseRedirectOverlayState(
41 state: {
42 result?: string
43 actorDid?: string
44 } = {},
45): RedirectOverlayState | undefined {
46 let result: RedirectOverlayState['result'] = 'unknown'
47 const actorDid = state.actorDid
48
49 switch (state.result) {
50 case 'success':
51 result = 'success'
52 break
53 case 'unknown':
54 default:
55 result = 'unknown'
56 break
57 }
58
59 if (actorDid) {
60 return {
61 result,
62 actorDid,
63 }
64 }
65}
66
67const Context = createContext<{
68 isOpen: boolean
69 open: (state: RedirectOverlayState) => void
70 close: () => void
71}>({
72 isOpen: false,
73 open: () => {},
74 close: () => {},
75})
76
77export function useRedirectOverlayContext() {
78 return useContext(Context)
79}
80
81export function Provider({children}: {children?: React.ReactNode}) {
82 const {currentAccount} = useSession()
83 const incomingUrl = Linking.useLinkingURL()
84 const [state, setState] = useState<RedirectOverlayState | null>(() => {
85 if (!incomingUrl) return null
86 const url = parseLinkingUrl(incomingUrl)
87 if (url.pathname !== '/intent/age-assurance') return null
88 const params = url.searchParams
89 const state = parseRedirectOverlayState({
90 result: params.get('result') ?? undefined,
91 actorDid: params.get('actorDid') ?? undefined,
92 })
93
94 if (IS_WEB) {
95 // Clear the URL parameters so they don't re-trigger
96 history.pushState(null, '', '/')
97 }
98
99 /*
100 * If we don't have an account or the account doesn't match, do
101 * nothing. By the time the user switches to their other account, AA
102 * state should be ready for them.
103 */
104 if (state && currentAccount && state.actorDid === currentAccount.did) {
105 return state
106 }
107
108 return null
109 })
110 const open = useCallback((state: RedirectOverlayState) => {
111 setState(state)
112 }, [])
113 const close = useCallback(() => {
114 setState(null)
115 }, [])
116
117 return (
118 <Context.Provider
119 value={useMemo(
120 () => ({
121 isOpen: state !== null,
122 open,
123 close,
124 }),
125 [state, open, close],
126 )}>
127 {children}
128 </Context.Provider>
129 )
130}
131
132export function RedirectOverlay() {
133 const t = useTheme()
134 const {_} = useLingui()
135 const {isOpen} = useRedirectOverlayContext()
136 const {gtMobile} = useBreakpoints()
137
138 return isOpen ? (
139 <FullWindowOverlay>
140 <View
141 style={[
142 a.fixed,
143 a.inset_0,
144 // setting a zIndex when using FullWindowOverlay on iOS
145 // means the taps pass straight through to the underlying content (???)
146 // so don't set it on iOS. FullWindowOverlay already does the job.
147 !IS_IOS && {zIndex: 9999},
148 t.atoms.bg,
149 gtMobile ? a.p_2xl : a.p_xl,
150 a.align_center,
151 // @ts-ignore
152 platform({
153 web: {
154 paddingTop: '35vh',
155 },
156 default: {
157 paddingTop: Dimensions.get('window').height * 0.35,
158 },
159 }),
160 ]}>
161 <View
162 role="dialog"
163 aria-role="dialog"
164 aria-label={_(msg`Verifying your age assurance status`)}>
165 <View style={[a.pb_3xl, {width: 300}]}>
166 <Inner />
167 </View>
168 </View>
169 </View>
170 </FullWindowOverlay>
171 ) : null
172}
173
174function Inner() {
175 const t = useTheme()
176 const ax = useAnalytics()
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 ax.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 ax.metric('ageAssurance:redirectDialogSuccess', {})
222 })
223 .catch(() => {
224 if (unmounted.current) return
225 setError(true)
226 ax.metric('ageAssurance:redirectDialogFail', {})
227 })
228
229 return () => {
230 unmounted.current = true
231 }
232 }, [ax, 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}