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
zio/stable
Paul Frazee 2024-01-25 15:33:23 -08:00 committed by GitHub
parent 335bef3d30
commit 5443503593
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
10 changed files with 304 additions and 11 deletions

View File

@ -39,7 +39,7 @@
"nuke": "rm -rf ./node_modules && rm -rf ./ios && rm -rf ./android" "nuke": "rm -rf ./node_modules && rm -rf ./ios && rm -rf ./android"
}, },
"dependencies": { "dependencies": {
"@atproto/api": "^0.9.1", "@atproto/api": "^0.9.5",
"@bam.tech/react-native-image-resizer": "^3.0.4", "@bam.tech/react-native-image-resizer": "^3.0.4",
"@braintree/sanitize-url": "^6.0.2", "@braintree/sanitize-url": "^6.0.2",
"@emoji-mart/react": "^1.1.1", "@emoji-mart/react": "^1.1.1",

View File

@ -337,6 +337,7 @@ export function Button({
a.flex_row, a.flex_row,
a.align_center, a.align_center,
a.overflow_hidden, a.overflow_hidden,
a.justify_center,
...baseStyles, ...baseStyles,
...(state.hovered || state.pressed ? hoverStyles : []), ...(state.hovered || state.pressed ? hoverStyles : []),
...(state.focused ? focusStyles : []), ...(state.focused ? focusStyles : []),

View File

@ -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>
)
}

View File

@ -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',
})

View File

@ -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',
})

View File

@ -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
}

View File

@ -12,6 +12,7 @@ const accountSchema = z.object({
emailConfirmed: z.boolean().optional(), emailConfirmed: z.boolean().optional(),
refreshJwt: z.string().optional(), // optional because it can expire refreshJwt: z.string().optional(), // optional because it can expire
accessJwt: 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> export type PersistedAccount = z.infer<typeof accountSchema>

View File

@ -12,6 +12,7 @@ import {emitSessionDropped} from '../events'
import {useLoggedOutViewControls} from '#/state/shell/logged-out' import {useLoggedOutViewControls} from '#/state/shell/logged-out'
import {useCloseAllActiveElements} from '#/state/util' import {useCloseAllActiveElements} from '#/state/util'
import {track} from '#/lib/analytics/analytics' import {track} from '#/lib/analytics/analytics'
import {hasProp} from '#/lib/type-guards'
let __globalAgent: BskyAgent = PUBLIC_BSKY_AGENT let __globalAgent: BskyAgent = PUBLIC_BSKY_AGENT
@ -125,6 +126,7 @@ function createPersistSessionHandler(
handle: session?.handle || account.handle, handle: session?.handle || account.handle,
email: session?.email || account.email, email: session?.email || account.email,
emailConfirmed: session?.emailConfirmed || account.emailConfirmed, emailConfirmed: session?.emailConfirmed || account.emailConfirmed,
deactivated: isSessionDeactivated(session?.accessJwt),
/* /*
* Tokens are undefined if the session expires, or if creation fails for * Tokens are undefined if the session expires, or if creation fails for
@ -139,6 +141,7 @@ function createPersistSessionHandler(
did: refreshedAccount.did, did: refreshedAccount.did,
handle: refreshedAccount.handle, handle: refreshedAccount.handle,
service: refreshedAccount.service, service: refreshedAccount.service,
deactivated: refreshedAccount.deactivated,
}) })
if (expired) { if (expired) {
@ -235,11 +238,14 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
throw new Error(`session: createAccount failed to establish a session`) throw new Error(`session: createAccount failed to establish a session`)
} }
/*dont await*/ agent.upsertProfile(_existing => { const deactivated = isSessionDeactivated(agent.session.accessJwt)
return { if (!deactivated) {
displayName: handle, /*dont await*/ agent.upsertProfile(_existing => {
} return {
}) displayName: handle,
}
})
}
const account: SessionAccount = { const account: SessionAccount = {
service: agent.service.toString(), service: agent.service.toString(),
@ -249,6 +255,7 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
emailConfirmed: false, emailConfirmed: false,
refreshJwt: agent.session.refreshJwt, refreshJwt: agent.session.refreshJwt,
accessJwt: agent.session.accessJwt, accessJwt: agent.session.accessJwt,
deactivated,
} }
agent.setPersistSessionHandler( agent.setPersistSessionHandler(
@ -305,6 +312,7 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
emailConfirmed: agent.session.emailConfirmed || false, emailConfirmed: agent.session.emailConfirmed || false,
refreshJwt: agent.session.refreshJwt, refreshJwt: agent.session.refreshJwt,
accessJwt: agent.session.accessJwt, accessJwt: agent.session.accessJwt,
deactivated: isSessionDeactivated(agent.session.accessJwt),
} }
agent.setPersistSessionHandler( agent.setPersistSessionHandler(
@ -392,6 +400,8 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
refreshJwt: account.refreshJwt || '', refreshJwt: account.refreshJwt || '',
did: account.did, did: account.did,
handle: account.handle, handle: account.handle,
deactivated:
isSessionDeactivated(account.accessJwt) || account.deactivated,
} }
if (canReusePrevSession) { if (canReusePrevSession) {
@ -402,6 +412,13 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
queryClient.clear() queryClient.clear()
upsertAccount(account) 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: // Intentionally not awaited to unblock the UI:
resumeSessionWithFreshAccount() resumeSessionWithFreshAccount()
.then(freshAccount => { .then(freshAccount => {
@ -466,6 +483,7 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
emailConfirmed: agent.session.emailConfirmed || false, emailConfirmed: agent.session.emailConfirmed || false,
refreshJwt: agent.session.refreshJwt, refreshJwt: agent.session.refreshJwt,
accessJwt: agent.session.accessJwt, accessJwt: agent.session.accessJwt,
deactivated: isSessionDeactivated(agent.session.accessJwt),
} }
} }
}, },
@ -687,3 +705,13 @@ export function useRequireAuth() {
[hasSession, setShowLoggedOut, closeAll], [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
}

View File

@ -35,6 +35,7 @@ import {
} from '#/state/shell/logged-out' } from '#/state/shell/logged-out'
import {useSession} from '#/state/session' import {useSession} from '#/state/session'
import {isWeb} from 'platform/detection' import {isWeb} from 'platform/detection'
import {Deactivated} from '#/screens/Deactivated'
import {LoggedOut} from '../com/auth/LoggedOut' import {LoggedOut} from '../com/auth/LoggedOut'
import {Onboarding} from '../com/auth/Onboarding' import {Onboarding} from '../com/auth/Onboarding'
@ -92,7 +93,7 @@ function NativeStackNavigator({
) )
// --- our custom logic starts here --- // --- our custom logic starts here ---
const {hasSession} = useSession() const {hasSession, currentAccount} = useSession()
const activeRoute = state.routes[state.index] const activeRoute = state.routes[state.index]
const activeDescriptor = descriptors[activeRoute.key] const activeDescriptor = descriptors[activeRoute.key]
const activeRouteRequiresAuth = activeDescriptor.options.requireAuth ?? false const activeRouteRequiresAuth = activeDescriptor.options.requireAuth ?? false
@ -103,6 +104,9 @@ function NativeStackNavigator({
if ((!PWI_ENABLED || activeRouteRequiresAuth) && !hasSession) { if ((!PWI_ENABLED || activeRouteRequiresAuth) && !hasSession) {
return <LoggedOut /> return <LoggedOut />
} }
if (hasSession && currentAccount?.deactivated) {
return <Deactivated />
}
if (showLoggedOut) { if (showLoggedOut) {
return <LoggedOut onDismiss={() => setShowLoggedOut(false)} /> return <LoggedOut onDismiss={() => setShowLoggedOut(false)} />
} }

View File

@ -48,10 +48,10 @@
typed-emitter "^2.1.0" typed-emitter "^2.1.0"
zod "^3.21.4" zod "^3.21.4"
"@atproto/api@^0.9.1": "@atproto/api@^0.9.5":
version "0.9.1" version "0.9.5"
resolved "https://registry.yarnpkg.com/@atproto/api/-/api-0.9.1.tgz#0b28baefa4af32bc4c05715b8641656f332546c6" resolved "https://registry.yarnpkg.com/@atproto/api/-/api-0.9.5.tgz#630e5d9520bba38d0cd348c8028ddbb73bd074f8"
integrity sha512-DHPc/dGgpf8sgPlfR9meIAk7s4YMll0g7HTq/W/LeaaaY0T6d3ZAtrgvjIU1aKCp5WNzTfzrmz0LIHIX46FHHw== integrity sha512-4vlwTbiWSkCV0DkfNMawiH+26Fv7txPr4x0vwq6KPIBz28UHPK9UyPseLKxi6/Aok74aPr8ySJ4+nfcmwcp08Q==
dependencies: dependencies:
"@atproto/common-web" "^0.2.3" "@atproto/common-web" "^0.2.3"
"@atproto/lexicon" "^0.3.1" "@atproto/lexicon" "^0.3.1"