diff options
author | Paul Frazee <pfrazee@gmail.com> | 2024-01-25 15:33:23 -0800 |
---|---|---|
committer | GitHub <noreply@github.com> | 2024-01-25 15:33:23 -0800 |
commit | 5443503593a67cc7ff6e081ef9b1fe66ea0cbe0d (patch) | |
tree | 3a18e5a1b3a272c6ee7705dc6d64a9e17a23a9df | |
parent | 335bef3d308e6aff2238fc4820bd39c8f7517112 (diff) | |
download | voidsky-5443503593a67cc7ff6e081ef9b1fe66ea0cbe0d.tar.zst |
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
-rw-r--r-- | package.json | 2 | ||||
-rw-r--r-- | src/components/Button.tsx | 1 | ||||
-rw-r--r-- | src/components/Loader.tsx | 41 | ||||
-rw-r--r-- | src/components/icons/Group3.tsx | 5 | ||||
-rw-r--r-- | src/components/icons/Loader.tsx | 5 | ||||
-rw-r--r-- | src/screens/Deactivated.tsx | 208 | ||||
-rw-r--r-- | src/state/persisted/schema.ts | 1 | ||||
-rw-r--r-- | src/state/session/index.tsx | 38 | ||||
-rw-r--r-- | src/view/shell/createNativeStackNavigatorWithAuth.tsx | 6 | ||||
-rw-r--r-- | yarn.lock | 8 |
10 files changed, 304 insertions, 11 deletions
diff --git a/package.json b/package.json index 258f66b93..a95e158ab 100644 --- a/package.json +++ b/package.json @@ -39,7 +39,7 @@ "nuke": "rm -rf ./node_modules && rm -rf ./ios && rm -rf ./android" }, "dependencies": { - "@atproto/api": "^0.9.1", + "@atproto/api": "^0.9.5", "@bam.tech/react-native-image-resizer": "^3.0.4", "@braintree/sanitize-url": "^6.0.2", "@emoji-mart/react": "^1.1.1", diff --git a/src/components/Button.tsx b/src/components/Button.tsx index d2100f0b4..7c682ac1a 100644 --- a/src/components/Button.tsx +++ b/src/components/Button.tsx @@ -337,6 +337,7 @@ export function Button({ a.flex_row, a.align_center, a.overflow_hidden, + a.justify_center, ...baseStyles, ...(state.hovered || state.pressed ? hoverStyles : []), ...(state.focused ? focusStyles : []), diff --git a/src/components/Loader.tsx b/src/components/Loader.tsx new file mode 100644 index 000000000..bbe4e2f75 --- /dev/null +++ b/src/components/Loader.tsx @@ -0,0 +1,41 @@ +import React from 'react' +import Animated, { + Easing, + useSharedValue, + useAnimatedStyle, + withRepeat, + withTiming, +} from 'react-native-reanimated' + +import {atoms as a} from '#/alf' +import {Props, useCommonSVGProps} from '#/components/icons/common' +import {Loader_Stroke2_Corner0_Rounded as Icon} from '#/components/icons/Loader' + +export function Loader(props: Props) { + const common = useCommonSVGProps(props) + const rotation = useSharedValue(0) + + const animatedStyles = useAnimatedStyle(() => ({ + transform: [{rotate: rotation.value + 'deg'}], + })) + + React.useEffect(() => { + rotation.value = withRepeat( + withTiming(360, {duration: 500, easing: Easing.linear}), + -1, + ) + }, [rotation]) + + return ( + <Animated.View + style={[ + a.relative, + a.justify_center, + a.align_center, + {width: common.size, height: common.size}, + animatedStyles, + ]}> + <Icon {...props} style={[a.absolute, a.inset_0, props.style]} /> + </Animated.View> + ) +} diff --git a/src/components/icons/Group3.tsx b/src/components/icons/Group3.tsx new file mode 100644 index 000000000..2bb16ba87 --- /dev/null +++ b/src/components/icons/Group3.tsx @@ -0,0 +1,5 @@ +import {createSinglePathSVG} from './TEMPLATE' + +export const Group3_Stroke2_Corner0_Rounded = createSinglePathSVG({ + 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', +}) diff --git a/src/components/icons/Loader.tsx b/src/components/icons/Loader.tsx new file mode 100644 index 000000000..01419ba54 --- /dev/null +++ b/src/components/icons/Loader.tsx @@ -0,0 +1,5 @@ +import {createSinglePathSVG} from './TEMPLATE' + +export const Loader_Stroke2_Corner0_Rounded = createSinglePathSVG({ + 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', +}) diff --git a/src/screens/Deactivated.tsx b/src/screens/Deactivated.tsx new file mode 100644 index 000000000..965fad07b --- /dev/null +++ b/src/screens/Deactivated.tsx @@ -0,0 +1,208 @@ +import React from 'react' +import {View} from 'react-native' +import {useSafeAreaInsets} from 'react-native-safe-area-context' +import {useLingui} from '@lingui/react' +import {msg, Trans} from '@lingui/macro' +import {useOnboardingDispatch} from '#/state/shell' +import {getAgent, isSessionDeactivated, useSessionApi} from '#/state/session' +import {logger} from '#/logger' +import {pluralize} from '#/lib/strings/helpers' + +import {atoms as a, useTheme, useBreakpoints} from '#/alf' +import {Button, ButtonIcon, ButtonText} from '#/components/Button' +import {Text} from '#/components/Typography' +import {isWeb} from '#/platform/detection' +import {H2, P} from '#/components/Typography' +import {ScrollView} from '#/view/com/util/Views' +import {Group3_Stroke2_Corner0_Rounded as Group3} from '#/components/icons/Group3' +import {Loader} from '#/components/Loader' + +const COL_WIDTH = 400 + +export function Deactivated() { + const {_} = useLingui() + const t = useTheme() + const insets = useSafeAreaInsets() + const {gtMobile} = useBreakpoints() + const onboardingDispatch = useOnboardingDispatch() + const {logout} = useSessionApi() + + const [isProcessing, setProcessing] = React.useState(false) + const [estimatedTime, setEstimatedTime] = React.useState<string | undefined>( + undefined, + ) + const [placeInQueue, setPlaceInQueue] = React.useState<number | undefined>( + undefined, + ) + + const checkStatus = React.useCallback(async () => { + setProcessing(true) + try { + const res = await getAgent().com.atproto.temp.checkSignupQueue() + if (res.data.activated) { + // ready to go, exchange the access token for a usable one and kick off onboarding + await getAgent().refreshSession() + if (!isSessionDeactivated(getAgent().session?.accessJwt)) { + onboardingDispatch({type: 'start'}) + } + } else { + // not ready, update UI + setEstimatedTime(msToString(res.data.estimatedTimeMs)) + if (typeof res.data.placeInQueue !== 'undefined') { + setPlaceInQueue(Math.max(res.data.placeInQueue, 1)) + } + } + } catch (e: any) { + logger.error('Failed to check signup queue', {err: e.toString()}) + } finally { + setProcessing(false) + } + }, [setProcessing, setEstimatedTime, setPlaceInQueue, onboardingDispatch]) + + React.useEffect(() => { + checkStatus() + const interval = setInterval(checkStatus, 60e3) + return () => clearInterval(interval) + }, [checkStatus]) + + const checkBtn = ( + <Button + variant="solid" + color="primary" + size="large" + label={_(msg`Check my status`)} + onPress={checkStatus} + disabled={isProcessing}> + <ButtonText> + <Trans>Check my status</Trans> + </ButtonText> + {isProcessing && <ButtonIcon icon={Loader} />} + </Button> + ) + + return ( + <View + aria-modal + role="dialog" + aria-role="dialog" + aria-label={_(msg`You're in line`)} + accessibilityLabel={_(msg`You're in line`)} + accessibilityHint="" + style={[a.absolute, a.inset_0, a.flex_1, t.atoms.bg]}> + <ScrollView + style={[a.h_full, a.w_full]} + contentContainerStyle={{borderWidth: 0}}> + <View + style={[a.flex_row, a.justify_center, gtMobile ? a.pt_4xl : a.px_xl]}> + <View style={[a.flex_1, {maxWidth: COL_WIDTH}]}> + <View + style={[a.w_full, a.justify_center, a.align_center, a.mt_4xl]}> + <Group3 fill="none" stroke={t.palette.contrast_900} width={120} /> + </View> + + <H2 style={[a.pb_sm]}> + <Trans>You're in line</Trans> + </H2> + <P style={[t.atoms.text_contrast_700]}> + <Trans> + There's been a rush of new users! We'll activate your account as + soon as we can. + </Trans> + </P> + + <View + style={[ + a.rounded_sm, + a.px_2xl, + a.py_4xl, + a.mt_2xl, + t.atoms.bg_contrast_50, + ]}> + {typeof placeInQueue === 'number' && ( + <Text + style={[a.text_5xl, a.text_center, a.font_bold, a.mb_2xl]}> + {placeInQueue} + </Text> + )} + <P style={[a.text_center]}> + {typeof placeInQueue === 'number' ? ( + <Trans>left to go.</Trans> + ) : ( + <Trans>You are in line.</Trans> + )}{' '} + {estimatedTime ? ( + <Trans> + We estimate {estimatedTime} until your account is ready. + </Trans> + ) : ( + <Trans> + We will let you know when your account is ready. + </Trans> + )} + </P> + </View> + + {isWeb && gtMobile && ( + <View style={[a.w_full, a.flex_row, a.justify_between, a.pt_5xl]}> + <Button + variant="ghost" + size="large" + label={_(msg`Log out`)} + onPress={logout}> + <ButtonText style={[{color: t.palette.primary_500}]}> + <Trans>Log out</Trans> + </ButtonText> + </Button> + {checkBtn} + </View> + )} + </View> + + <View style={{height: 200}} /> + </View> + </ScrollView> + + {(!isWeb || !gtMobile) && ( + <View + style={[ + a.align_center, + gtMobile ? a.px_5xl : a.px_xl, + { + paddingBottom: Math.max(insets.bottom, a.pb_5xl.paddingBottom), + }, + ]}> + <View style={[a.w_full, a.gap_sm, {maxWidth: COL_WIDTH}]}> + {checkBtn} + <Button + variant="ghost" + size="large" + label={_(msg`Log out`)} + onPress={logout}> + <ButtonText style={[{color: t.palette.primary_500}]}> + <Trans>Log out</Trans> + </ButtonText> + </Button> + </View> + </View> + )} + </View> + ) +} + +function msToString(ms: number | undefined): string | undefined { + if (ms && ms > 0) { + const estimatedTimeMins = Math.ceil(ms / 60e3) + if (estimatedTimeMins > 59) { + const estimatedTimeHrs = Math.round(estimatedTimeMins / 60) + if (estimatedTimeHrs > 6) { + // dont even bother + return undefined + } + // hours + return `${estimatedTimeHrs} ${pluralize(estimatedTimeHrs, 'hour')}` + } + // minutes + return `${estimatedTimeMins} ${pluralize(estimatedTimeMins, 'minute')}` + } + return undefined +} diff --git a/src/state/persisted/schema.ts b/src/state/persisted/schema.ts index a6f2ea06a..870e14aaf 100644 --- a/src/state/persisted/schema.ts +++ b/src/state/persisted/schema.ts @@ -12,6 +12,7 @@ const accountSchema = z.object({ emailConfirmed: z.boolean().optional(), refreshJwt: z.string().optional(), // optional because it can expire accessJwt: z.string().optional(), // optional because it can expire + deactivated: z.boolean().optional(), }) export type PersistedAccount = z.infer<typeof accountSchema> diff --git a/src/state/session/index.tsx b/src/state/session/index.tsx index e49bc2b39..629aff6dc 100644 --- a/src/state/session/index.tsx +++ b/src/state/session/index.tsx @@ -12,6 +12,7 @@ import {emitSessionDropped} from '../events' import {useLoggedOutViewControls} from '#/state/shell/logged-out' import {useCloseAllActiveElements} from '#/state/util' import {track} from '#/lib/analytics/analytics' +import {hasProp} from '#/lib/type-guards' let __globalAgent: BskyAgent = PUBLIC_BSKY_AGENT @@ -125,6 +126,7 @@ function createPersistSessionHandler( handle: session?.handle || account.handle, email: session?.email || account.email, emailConfirmed: session?.emailConfirmed || account.emailConfirmed, + deactivated: isSessionDeactivated(session?.accessJwt), /* * Tokens are undefined if the session expires, or if creation fails for @@ -139,6 +141,7 @@ function createPersistSessionHandler( did: refreshedAccount.did, handle: refreshedAccount.handle, service: refreshedAccount.service, + deactivated: refreshedAccount.deactivated, }) if (expired) { @@ -235,11 +238,14 @@ export function Provider({children}: React.PropsWithChildren<{}>) { throw new Error(`session: createAccount failed to establish a session`) } - /*dont await*/ agent.upsertProfile(_existing => { - return { - displayName: handle, - } - }) + const deactivated = isSessionDeactivated(agent.session.accessJwt) + if (!deactivated) { + /*dont await*/ agent.upsertProfile(_existing => { + return { + displayName: handle, + } + }) + } const account: SessionAccount = { service: agent.service.toString(), @@ -249,6 +255,7 @@ export function Provider({children}: React.PropsWithChildren<{}>) { emailConfirmed: false, refreshJwt: agent.session.refreshJwt, accessJwt: agent.session.accessJwt, + deactivated, } agent.setPersistSessionHandler( @@ -305,6 +312,7 @@ export function Provider({children}: React.PropsWithChildren<{}>) { emailConfirmed: agent.session.emailConfirmed || false, refreshJwt: agent.session.refreshJwt, accessJwt: agent.session.accessJwt, + deactivated: isSessionDeactivated(agent.session.accessJwt), } agent.setPersistSessionHandler( @@ -392,6 +400,8 @@ export function Provider({children}: React.PropsWithChildren<{}>) { refreshJwt: account.refreshJwt || '', did: account.did, handle: account.handle, + deactivated: + isSessionDeactivated(account.accessJwt) || account.deactivated, } if (canReusePrevSession) { @@ -402,6 +412,13 @@ export function Provider({children}: React.PropsWithChildren<{}>) { queryClient.clear() upsertAccount(account) + if (prevSession.deactivated) { + // don't attempt to resume + // use will be taken to the deactivated screen + logger.info(`session: reusing session for deactivated account`) + return + } + // Intentionally not awaited to unblock the UI: resumeSessionWithFreshAccount() .then(freshAccount => { @@ -466,6 +483,7 @@ export function Provider({children}: React.PropsWithChildren<{}>) { emailConfirmed: agent.session.emailConfirmed || false, refreshJwt: agent.session.refreshJwt, accessJwt: agent.session.accessJwt, + deactivated: isSessionDeactivated(agent.session.accessJwt), } } }, @@ -687,3 +705,13 @@ export function useRequireAuth() { [hasSession, setShowLoggedOut, closeAll], ) } + +export function isSessionDeactivated(accessJwt: string | undefined) { + if (accessJwt) { + const sessData = jwtDecode(accessJwt) + return ( + hasProp(sessData, 'scope') && sessData.scope === 'com.atproto.deactivated' + ) + } + return false +} diff --git a/src/view/shell/createNativeStackNavigatorWithAuth.tsx b/src/view/shell/createNativeStackNavigatorWithAuth.tsx index 9fea6e49f..7e275502b 100644 --- a/src/view/shell/createNativeStackNavigatorWithAuth.tsx +++ b/src/view/shell/createNativeStackNavigatorWithAuth.tsx @@ -35,6 +35,7 @@ import { } from '#/state/shell/logged-out' import {useSession} from '#/state/session' import {isWeb} from 'platform/detection' +import {Deactivated} from '#/screens/Deactivated' import {LoggedOut} from '../com/auth/LoggedOut' import {Onboarding} from '../com/auth/Onboarding' @@ -92,7 +93,7 @@ function NativeStackNavigator({ ) // --- our custom logic starts here --- - const {hasSession} = useSession() + const {hasSession, currentAccount} = useSession() const activeRoute = state.routes[state.index] const activeDescriptor = descriptors[activeRoute.key] const activeRouteRequiresAuth = activeDescriptor.options.requireAuth ?? false @@ -103,6 +104,9 @@ function NativeStackNavigator({ if ((!PWI_ENABLED || activeRouteRequiresAuth) && !hasSession) { return <LoggedOut /> } + if (hasSession && currentAccount?.deactivated) { + return <Deactivated /> + } if (showLoggedOut) { return <LoggedOut onDismiss={() => setShowLoggedOut(false)} /> } diff --git a/yarn.lock b/yarn.lock index 1075743d8..c5d70ab5d 100644 --- a/yarn.lock +++ b/yarn.lock @@ -48,10 +48,10 @@ typed-emitter "^2.1.0" zod "^3.21.4" -"@atproto/api@^0.9.1": - version "0.9.1" - resolved "https://registry.yarnpkg.com/@atproto/api/-/api-0.9.1.tgz#0b28baefa4af32bc4c05715b8641656f332546c6" - integrity sha512-DHPc/dGgpf8sgPlfR9meIAk7s4YMll0g7HTq/W/LeaaaY0T6d3ZAtrgvjIU1aKCp5WNzTfzrmz0LIHIX46FHHw== +"@atproto/api@^0.9.5": + version "0.9.5" + resolved "https://registry.yarnpkg.com/@atproto/api/-/api-0.9.5.tgz#630e5d9520bba38d0cd348c8028ddbb73bd074f8" + integrity sha512-4vlwTbiWSkCV0DkfNMawiH+26Fv7txPr4x0vwq6KPIBz28UHPK9UyPseLKxi6/Aok74aPr8ySJ4+nfcmwcp08Q== dependencies: "@atproto/common-web" "^0.2.3" "@atproto/lexicon" "^0.3.1" |