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 refinementszio/stable
parent
335bef3d30
commit
5443503593
|
@ -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",
|
||||
|
|
|
@ -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 : []),
|
||||
|
|
|
@ -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>
|
||||
)
|
||||
}
|
|
@ -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',
|
||||
})
|
|
@ -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',
|
||||
})
|
|
@ -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
|
||||
}
|
|
@ -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>
|
||||
|
||||
|
|
|
@ -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
|
||||
}
|
||||
|
|
|
@ -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)} />
|
||||
}
|
||||
|
|
|
@ -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"
|
||||
|
|
Loading…
Reference in New Issue