Bluesky app fork with some witchin' additions 馃挮
at feat/tealfm 338 lines 8.8 kB view raw
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}