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