about summary refs log tree commit diff
diff options
context:
space:
mode:
authorPaul Frazee <pfrazee@gmail.com>2024-01-25 15:33:23 -0800
committerGitHub <noreply@github.com>2024-01-25 15:33:23 -0800
commit5443503593a67cc7ff6e081ef9b1fe66ea0cbe0d (patch)
tree3a18e5a1b3a272c6ee7705dc6d64a9e17a23a9df
parent335bef3d308e6aff2238fc4820bd39c8f7517112 (diff)
downloadvoidsky-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.json2
-rw-r--r--src/components/Button.tsx1
-rw-r--r--src/components/Loader.tsx41
-rw-r--r--src/components/icons/Group3.tsx5
-rw-r--r--src/components/icons/Loader.tsx5
-rw-r--r--src/screens/Deactivated.tsx208
-rw-r--r--src/state/persisted/schema.ts1
-rw-r--r--src/state/session/index.tsx38
-rw-r--r--src/view/shell/createNativeStackNavigatorWithAuth.tsx6
-rw-r--r--yarn.lock8
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"