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 state = 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 (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 !IS_IOS && {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 ax = useAnalytics()
178 const {_} = useLingui()
179 const agent = useAgent()
180 const polling = useRef(false)
181 const unmounted = useRef(false)
182 const [error, setError] = useState(false)
183 const [success, setSuccess] = useState(false)
184 const {close} = useRedirectOverlayContext()
185
186 useEffect(() => {
187 if (polling.current) return
188
189 polling.current = true
190
191 ax.metric('ageAssurance:redirectDialogOpen', {})
192
193 wait(
194 3e3,
195 retry(
196 5,
197 () => true,
198 async () => {
199 if (!agent.session) return
200 if (unmounted.current) return
201
202 const data = await refetchAgeAssuranceServerState({agent})
203
204 if (data?.state.status !== 'assured') {
205 throw new Error(
206 `Polling for age assurance state did not receive assured status`,
207 )
208 }
209
210 return data
211 },
212 1e3,
213 ),
214 )
215 .then(async data => {
216 if (!data) return
217 if (!agent.session) return
218 if (unmounted.current) return
219
220 setSuccess(true)
221
222 ax.metric('ageAssurance:redirectDialogSuccess', {})
223 })
224 .catch(() => {
225 if (unmounted.current) return
226 setError(true)
227 ax.metric('ageAssurance:redirectDialogFail', {})
228 })
229
230 return () => {
231 unmounted.current = true
232 }
233 }, [ax, agent])
234
235 if (success) {
236 return (
237 <>
238 <View style={[a.align_start, a.w_full]}>
239 <AgeAssuranceBadge />
240
241 <View
242 style={[
243 a.flex_row,
244 a.justify_between,
245 a.align_center,
246 a.gap_sm,
247 a.pt_lg,
248 a.pb_md,
249 ]}>
250 <SuccessIcon size="sm" fill={t.palette.positive_500} />
251 <Text style={[a.text_3xl, a.font_bold]}>
252 <Trans>Success</Trans>
253 </Text>
254 </View>
255
256 <Text style={[a.text_md, a.leading_snug]}>
257 <Trans>
258 We've confirmed your age assurance status. You can now close this
259 dialog.
260 </Trans>
261 </Text>
262
263 <View style={[a.w_full, a.pt_lg]}>
264 <Button
265 label={_(msg`Close`)}
266 size="large"
267 variant="solid"
268 color="secondary"
269 onPress={() => close()}>
270 <ButtonText>
271 <Trans>Close</Trans>
272 </ButtonText>
273 </Button>
274 </View>
275 </View>
276 </>
277 )
278 }
279
280 return (
281 <>
282 <View style={[a.align_start, a.w_full]}>
283 <AgeAssuranceBadge />
284
285 <View
286 style={[
287 a.flex_row,
288 a.justify_between,
289 a.align_center,
290 a.gap_sm,
291 a.pt_lg,
292 a.pb_md,
293 ]}>
294 {error && <ErrorIcon size="lg" fill={t.palette.negative_500} />}
295
296 <Text style={[a.text_3xl, a.font_bold]}>
297 {error ? <Trans>Connection issue</Trans> : <Trans>Verifying</Trans>}
298 </Text>
299
300 {!error && <Loader size="lg" />}
301 </View>
302
303 <Text style={[a.text_md, t.atoms.text_contrast_medium, a.leading_snug]}>
304 {error ? (
305 <Trans>
306 We were unable to receive the verification due to a connection
307 issue. It may arrive later. If it does, your account will update
308 automatically.
309 </Trans>
310 ) : (
311 <Trans>
312 We're confirming your age assurance status with our servers. This
313 should only take a few seconds.
314 </Trans>
315 )}
316 </Text>
317
318 {error && (
319 <View style={[a.w_full, a.pt_lg]}>
320 <Button
321 label={_(msg`Close`)}
322 size="large"
323 variant="solid"
324 color="secondary"
325 onPress={() => close()}>
326 <ButtonText>
327 <Trans>Close</Trans>
328 </ButtonText>
329 </Button>
330 </View>
331 )}
332 </View>
333 </>
334 )
335}