Bluesky app fork with some witchin' additions 💫

Add account-activation queueing to signup (#2613)

* Add deactivated-account tracking

* Center button text

* Add Deactivated screen

* Add icon to Deactivated screen

* Abort session resumption if the session is deactivated

* Implement deactivated screen status checks

* Bump api@0.9.5

* Use new typo-fixed scope

* UI refinements

authored by

Paul Frazee and committed by
GitHub
54435035 335bef3d

+304 -11
+1 -1
package.json
··· 39 39 "nuke": "rm -rf ./node_modules && rm -rf ./ios && rm -rf ./android" 40 40 }, 41 41 "dependencies": { 42 - "@atproto/api": "^0.9.1", 42 + "@atproto/api": "^0.9.5", 43 43 "@bam.tech/react-native-image-resizer": "^3.0.4", 44 44 "@braintree/sanitize-url": "^6.0.2", 45 45 "@emoji-mart/react": "^1.1.1",
+1
src/components/Button.tsx
··· 337 337 a.flex_row, 338 338 a.align_center, 339 339 a.overflow_hidden, 340 + a.justify_center, 340 341 ...baseStyles, 341 342 ...(state.hovered || state.pressed ? hoverStyles : []), 342 343 ...(state.focused ? focusStyles : []),
+41
src/components/Loader.tsx
··· 1 + import React from 'react' 2 + import Animated, { 3 + Easing, 4 + useSharedValue, 5 + useAnimatedStyle, 6 + withRepeat, 7 + withTiming, 8 + } from 'react-native-reanimated' 9 + 10 + import {atoms as a} from '#/alf' 11 + import {Props, useCommonSVGProps} from '#/components/icons/common' 12 + import {Loader_Stroke2_Corner0_Rounded as Icon} from '#/components/icons/Loader' 13 + 14 + export function Loader(props: Props) { 15 + const common = useCommonSVGProps(props) 16 + const rotation = useSharedValue(0) 17 + 18 + const animatedStyles = useAnimatedStyle(() => ({ 19 + transform: [{rotate: rotation.value + 'deg'}], 20 + })) 21 + 22 + React.useEffect(() => { 23 + rotation.value = withRepeat( 24 + withTiming(360, {duration: 500, easing: Easing.linear}), 25 + -1, 26 + ) 27 + }, [rotation]) 28 + 29 + return ( 30 + <Animated.View 31 + style={[ 32 + a.relative, 33 + a.justify_center, 34 + a.align_center, 35 + {width: common.size, height: common.size}, 36 + animatedStyles, 37 + ]}> 38 + <Icon {...props} style={[a.absolute, a.inset_0, props.style]} /> 39 + </Animated.View> 40 + ) 41 + }
+5
src/components/icons/Group3.tsx
··· 1 + import {createSinglePathSVG} from './TEMPLATE' 2 + 3 + export const Group3_Stroke2_Corner0_Rounded = createSinglePathSVG({ 4 + path: 'M17 16H21.1456C20.8246 11.4468 17.7199 9.48509 15.0001 10.1147M10 4C10 5.65685 8.65685 7 7 7C5.34315 7 4 5.65685 4 4C4 2.34315 5.34315 1 7 1C8.65685 1 10 2.34315 10 4ZM18.5 4.5C18.5 5.88071 17.3807 7 16 7C14.6193 7 13.5 5.88071 13.5 4.5C13.5 3.11929 14.6193 2 16 2C17.3807 2 18.5 3.11929 18.5 4.5ZM1 17H13C12.3421 7.66667 1.65792 7.66667 1 17Z', 5 + })
+5
src/components/icons/Loader.tsx
··· 1 + import {createSinglePathSVG} from './TEMPLATE' 2 + 3 + export const Loader_Stroke2_Corner0_Rounded = createSinglePathSVG({ 4 + path: 'M12 5a7 7 0 0 0-5.218 11.666A1 1 0 0 1 5.292 18a9 9 0 1 1 13.416 0 1 1 0 1 1-1.49-1.334A7 7 0 0 0 12 5Z', 5 + })
+208
src/screens/Deactivated.tsx
··· 1 + import React from 'react' 2 + import {View} from 'react-native' 3 + import {useSafeAreaInsets} from 'react-native-safe-area-context' 4 + import {useLingui} from '@lingui/react' 5 + import {msg, Trans} from '@lingui/macro' 6 + import {useOnboardingDispatch} from '#/state/shell' 7 + import {getAgent, isSessionDeactivated, useSessionApi} from '#/state/session' 8 + import {logger} from '#/logger' 9 + import {pluralize} from '#/lib/strings/helpers' 10 + 11 + import {atoms as a, useTheme, useBreakpoints} from '#/alf' 12 + import {Button, ButtonIcon, ButtonText} from '#/components/Button' 13 + import {Text} from '#/components/Typography' 14 + import {isWeb} from '#/platform/detection' 15 + import {H2, P} from '#/components/Typography' 16 + import {ScrollView} from '#/view/com/util/Views' 17 + import {Group3_Stroke2_Corner0_Rounded as Group3} from '#/components/icons/Group3' 18 + import {Loader} from '#/components/Loader' 19 + 20 + const COL_WIDTH = 400 21 + 22 + export function Deactivated() { 23 + const {_} = useLingui() 24 + const t = useTheme() 25 + const insets = useSafeAreaInsets() 26 + const {gtMobile} = useBreakpoints() 27 + const onboardingDispatch = useOnboardingDispatch() 28 + const {logout} = useSessionApi() 29 + 30 + const [isProcessing, setProcessing] = React.useState(false) 31 + const [estimatedTime, setEstimatedTime] = React.useState<string | undefined>( 32 + undefined, 33 + ) 34 + const [placeInQueue, setPlaceInQueue] = React.useState<number | undefined>( 35 + undefined, 36 + ) 37 + 38 + const checkStatus = React.useCallback(async () => { 39 + setProcessing(true) 40 + try { 41 + const res = await getAgent().com.atproto.temp.checkSignupQueue() 42 + if (res.data.activated) { 43 + // ready to go, exchange the access token for a usable one and kick off onboarding 44 + await getAgent().refreshSession() 45 + if (!isSessionDeactivated(getAgent().session?.accessJwt)) { 46 + onboardingDispatch({type: 'start'}) 47 + } 48 + } else { 49 + // not ready, update UI 50 + setEstimatedTime(msToString(res.data.estimatedTimeMs)) 51 + if (typeof res.data.placeInQueue !== 'undefined') { 52 + setPlaceInQueue(Math.max(res.data.placeInQueue, 1)) 53 + } 54 + } 55 + } catch (e: any) { 56 + logger.error('Failed to check signup queue', {err: e.toString()}) 57 + } finally { 58 + setProcessing(false) 59 + } 60 + }, [setProcessing, setEstimatedTime, setPlaceInQueue, onboardingDispatch]) 61 + 62 + React.useEffect(() => { 63 + checkStatus() 64 + const interval = setInterval(checkStatus, 60e3) 65 + return () => clearInterval(interval) 66 + }, [checkStatus]) 67 + 68 + const checkBtn = ( 69 + <Button 70 + variant="solid" 71 + color="primary" 72 + size="large" 73 + label={_(msg`Check my status`)} 74 + onPress={checkStatus} 75 + disabled={isProcessing}> 76 + <ButtonText> 77 + <Trans>Check my status</Trans> 78 + </ButtonText> 79 + {isProcessing && <ButtonIcon icon={Loader} />} 80 + </Button> 81 + ) 82 + 83 + return ( 84 + <View 85 + aria-modal 86 + role="dialog" 87 + aria-role="dialog" 88 + aria-label={_(msg`You're in line`)} 89 + accessibilityLabel={_(msg`You're in line`)} 90 + accessibilityHint="" 91 + style={[a.absolute, a.inset_0, a.flex_1, t.atoms.bg]}> 92 + <ScrollView 93 + style={[a.h_full, a.w_full]} 94 + contentContainerStyle={{borderWidth: 0}}> 95 + <View 96 + style={[a.flex_row, a.justify_center, gtMobile ? a.pt_4xl : a.px_xl]}> 97 + <View style={[a.flex_1, {maxWidth: COL_WIDTH}]}> 98 + <View 99 + style={[a.w_full, a.justify_center, a.align_center, a.mt_4xl]}> 100 + <Group3 fill="none" stroke={t.palette.contrast_900} width={120} /> 101 + </View> 102 + 103 + <H2 style={[a.pb_sm]}> 104 + <Trans>You're in line</Trans> 105 + </H2> 106 + <P style={[t.atoms.text_contrast_700]}> 107 + <Trans> 108 + There's been a rush of new users! We'll activate your account as 109 + soon as we can. 110 + </Trans> 111 + </P> 112 + 113 + <View 114 + style={[ 115 + a.rounded_sm, 116 + a.px_2xl, 117 + a.py_4xl, 118 + a.mt_2xl, 119 + t.atoms.bg_contrast_50, 120 + ]}> 121 + {typeof placeInQueue === 'number' && ( 122 + <Text 123 + style={[a.text_5xl, a.text_center, a.font_bold, a.mb_2xl]}> 124 + {placeInQueue} 125 + </Text> 126 + )} 127 + <P style={[a.text_center]}> 128 + {typeof placeInQueue === 'number' ? ( 129 + <Trans>left to go.</Trans> 130 + ) : ( 131 + <Trans>You are in line.</Trans> 132 + )}{' '} 133 + {estimatedTime ? ( 134 + <Trans> 135 + We estimate {estimatedTime} until your account is ready. 136 + </Trans> 137 + ) : ( 138 + <Trans> 139 + We will let you know when your account is ready. 140 + </Trans> 141 + )} 142 + </P> 143 + </View> 144 + 145 + {isWeb && gtMobile && ( 146 + <View style={[a.w_full, a.flex_row, a.justify_between, a.pt_5xl]}> 147 + <Button 148 + variant="ghost" 149 + size="large" 150 + label={_(msg`Log out`)} 151 + onPress={logout}> 152 + <ButtonText style={[{color: t.palette.primary_500}]}> 153 + <Trans>Log out</Trans> 154 + </ButtonText> 155 + </Button> 156 + {checkBtn} 157 + </View> 158 + )} 159 + </View> 160 + 161 + <View style={{height: 200}} /> 162 + </View> 163 + </ScrollView> 164 + 165 + {(!isWeb || !gtMobile) && ( 166 + <View 167 + style={[ 168 + a.align_center, 169 + gtMobile ? a.px_5xl : a.px_xl, 170 + { 171 + paddingBottom: Math.max(insets.bottom, a.pb_5xl.paddingBottom), 172 + }, 173 + ]}> 174 + <View style={[a.w_full, a.gap_sm, {maxWidth: COL_WIDTH}]}> 175 + {checkBtn} 176 + <Button 177 + variant="ghost" 178 + size="large" 179 + label={_(msg`Log out`)} 180 + onPress={logout}> 181 + <ButtonText style={[{color: t.palette.primary_500}]}> 182 + <Trans>Log out</Trans> 183 + </ButtonText> 184 + </Button> 185 + </View> 186 + </View> 187 + )} 188 + </View> 189 + ) 190 + } 191 + 192 + function msToString(ms: number | undefined): string | undefined { 193 + if (ms && ms > 0) { 194 + const estimatedTimeMins = Math.ceil(ms / 60e3) 195 + if (estimatedTimeMins > 59) { 196 + const estimatedTimeHrs = Math.round(estimatedTimeMins / 60) 197 + if (estimatedTimeHrs > 6) { 198 + // dont even bother 199 + return undefined 200 + } 201 + // hours 202 + return `${estimatedTimeHrs} ${pluralize(estimatedTimeHrs, 'hour')}` 203 + } 204 + // minutes 205 + return `${estimatedTimeMins} ${pluralize(estimatedTimeMins, 'minute')}` 206 + } 207 + return undefined 208 + }
+1
src/state/persisted/schema.ts
··· 12 12 emailConfirmed: z.boolean().optional(), 13 13 refreshJwt: z.string().optional(), // optional because it can expire 14 14 accessJwt: z.string().optional(), // optional because it can expire 15 + deactivated: z.boolean().optional(), 15 16 }) 16 17 export type PersistedAccount = z.infer<typeof accountSchema> 17 18
+33 -5
src/state/session/index.tsx
··· 12 12 import {useLoggedOutViewControls} from '#/state/shell/logged-out' 13 13 import {useCloseAllActiveElements} from '#/state/util' 14 14 import {track} from '#/lib/analytics/analytics' 15 + import {hasProp} from '#/lib/type-guards' 15 16 16 17 let __globalAgent: BskyAgent = PUBLIC_BSKY_AGENT 17 18 ··· 125 126 handle: session?.handle || account.handle, 126 127 email: session?.email || account.email, 127 128 emailConfirmed: session?.emailConfirmed || account.emailConfirmed, 129 + deactivated: isSessionDeactivated(session?.accessJwt), 128 130 129 131 /* 130 132 * Tokens are undefined if the session expires, or if creation fails for ··· 139 141 did: refreshedAccount.did, 140 142 handle: refreshedAccount.handle, 141 143 service: refreshedAccount.service, 144 + deactivated: refreshedAccount.deactivated, 142 145 }) 143 146 144 147 if (expired) { ··· 235 238 throw new Error(`session: createAccount failed to establish a session`) 236 239 } 237 240 238 - /*dont await*/ agent.upsertProfile(_existing => { 239 - return { 240 - displayName: handle, 241 - } 242 - }) 241 + const deactivated = isSessionDeactivated(agent.session.accessJwt) 242 + if (!deactivated) { 243 + /*dont await*/ agent.upsertProfile(_existing => { 244 + return { 245 + displayName: handle, 246 + } 247 + }) 248 + } 243 249 244 250 const account: SessionAccount = { 245 251 service: agent.service.toString(), ··· 249 255 emailConfirmed: false, 250 256 refreshJwt: agent.session.refreshJwt, 251 257 accessJwt: agent.session.accessJwt, 258 + deactivated, 252 259 } 253 260 254 261 agent.setPersistSessionHandler( ··· 305 312 emailConfirmed: agent.session.emailConfirmed || false, 306 313 refreshJwt: agent.session.refreshJwt, 307 314 accessJwt: agent.session.accessJwt, 315 + deactivated: isSessionDeactivated(agent.session.accessJwt), 308 316 } 309 317 310 318 agent.setPersistSessionHandler( ··· 392 400 refreshJwt: account.refreshJwt || '', 393 401 did: account.did, 394 402 handle: account.handle, 403 + deactivated: 404 + isSessionDeactivated(account.accessJwt) || account.deactivated, 395 405 } 396 406 397 407 if (canReusePrevSession) { ··· 401 411 __globalAgent = agent 402 412 queryClient.clear() 403 413 upsertAccount(account) 414 + 415 + if (prevSession.deactivated) { 416 + // don't attempt to resume 417 + // use will be taken to the deactivated screen 418 + logger.info(`session: reusing session for deactivated account`) 419 + return 420 + } 404 421 405 422 // Intentionally not awaited to unblock the UI: 406 423 resumeSessionWithFreshAccount() ··· 466 483 emailConfirmed: agent.session.emailConfirmed || false, 467 484 refreshJwt: agent.session.refreshJwt, 468 485 accessJwt: agent.session.accessJwt, 486 + deactivated: isSessionDeactivated(agent.session.accessJwt), 469 487 } 470 488 } 471 489 }, ··· 687 705 [hasSession, setShowLoggedOut, closeAll], 688 706 ) 689 707 } 708 + 709 + export function isSessionDeactivated(accessJwt: string | undefined) { 710 + if (accessJwt) { 711 + const sessData = jwtDecode(accessJwt) 712 + return ( 713 + hasProp(sessData, 'scope') && sessData.scope === 'com.atproto.deactivated' 714 + ) 715 + } 716 + return false 717 + }
+5 -1
src/view/shell/createNativeStackNavigatorWithAuth.tsx
··· 35 35 } from '#/state/shell/logged-out' 36 36 import {useSession} from '#/state/session' 37 37 import {isWeb} from 'platform/detection' 38 + import {Deactivated} from '#/screens/Deactivated' 38 39 import {LoggedOut} from '../com/auth/LoggedOut' 39 40 import {Onboarding} from '../com/auth/Onboarding' 40 41 ··· 92 93 ) 93 94 94 95 // --- our custom logic starts here --- 95 - const {hasSession} = useSession() 96 + const {hasSession, currentAccount} = useSession() 96 97 const activeRoute = state.routes[state.index] 97 98 const activeDescriptor = descriptors[activeRoute.key] 98 99 const activeRouteRequiresAuth = activeDescriptor.options.requireAuth ?? false ··· 102 103 const {isMobile} = useWebMediaQueries() 103 104 if ((!PWI_ENABLED || activeRouteRequiresAuth) && !hasSession) { 104 105 return <LoggedOut /> 106 + } 107 + if (hasSession && currentAccount?.deactivated) { 108 + return <Deactivated /> 105 109 } 106 110 if (showLoggedOut) { 107 111 return <LoggedOut onDismiss={() => setShowLoggedOut(false)} />
+4 -4
yarn.lock
··· 48 48 typed-emitter "^2.1.0" 49 49 zod "^3.21.4" 50 50 51 - "@atproto/api@^0.9.1": 52 - version "0.9.1" 53 - resolved "https://registry.yarnpkg.com/@atproto/api/-/api-0.9.1.tgz#0b28baefa4af32bc4c05715b8641656f332546c6" 54 - integrity sha512-DHPc/dGgpf8sgPlfR9meIAk7s4YMll0g7HTq/W/LeaaaY0T6d3ZAtrgvjIU1aKCp5WNzTfzrmz0LIHIX46FHHw== 51 + "@atproto/api@^0.9.5": 52 + version "0.9.5" 53 + resolved "https://registry.yarnpkg.com/@atproto/api/-/api-0.9.5.tgz#630e5d9520bba38d0cd348c8028ddbb73bd074f8" 54 + integrity sha512-4vlwTbiWSkCV0DkfNMawiH+26Fv7txPr4x0vwq6KPIBz28UHPK9UyPseLKxi6/Aok74aPr8ySJ4+nfcmwcp08Q== 55 55 dependencies: 56 56 "@atproto/common-web" "^0.2.3" 57 57 "@atproto/lexicon" "^0.3.1"