about summary refs log tree commit diff
path: root/src/components/ageAssurance
diff options
context:
space:
mode:
authorEric Bailey <git@esb.lol>2025-07-17 14:32:58 -0500
committerGitHub <noreply@github.com>2025-07-17 14:32:58 -0500
commit964eed54eaa53f0912b336391642654cb8a0f605 (patch)
treefa5beda05823ca6025e8bcec4ad711f52919baba /src/components/ageAssurance
parent00b017804bcb811b5f9292a88619423df3a29ef8 (diff)
downloadvoidsky-964eed54eaa53f0912b336391642654cb8a0f605.tar.zst
Age assurance fast-follows (#8656)
* Add feed banner

* Comment

* Update nux name

* Handle did error

* Hide mod settings if underage or age restricted

* Add metrics

* Remove DEV override

* Copy suggestion

* Small copy edits

* useState

* Fix bug

* Update src/components/ageAssurance/useAgeAssuranceCopy.ts

Co-authored-by: surfdude29 <149612116+surfdude29@users.noreply.github.com>

* Get rid of debug button

---------

Co-authored-by: surfdude29 <149612116+surfdude29@users.noreply.github.com>
Diffstat (limited to 'src/components/ageAssurance')
-rw-r--r--src/components/ageAssurance/AgeAssuranceAccountCard.tsx9
-rw-r--r--src/components/ageAssurance/AgeAssuranceAdmonition.tsx6
-rw-r--r--src/components/ageAssurance/AgeAssuranceAppealDialog.tsx4
-rw-r--r--src/components/ageAssurance/AgeAssuranceDismissibleFeedBanner.tsx141
-rw-r--r--src/components/ageAssurance/AgeAssuranceDismissibleHeaderButton.tsx95
-rw-r--r--src/components/ageAssurance/AgeAssuranceDismissibleNotice.tsx6
-rw-r--r--src/components/ageAssurance/AgeAssuranceInitDialog.tsx52
-rw-r--r--src/components/ageAssurance/AgeAssuranceRedirectDialog.tsx6
-rw-r--r--src/components/ageAssurance/AgeRestrictedScreen.tsx12
-rw-r--r--src/components/ageAssurance/useAgeAssuranceCopy.ts7
10 files changed, 216 insertions, 122 deletions
diff --git a/src/components/ageAssurance/AgeAssuranceAccountCard.tsx b/src/components/ageAssurance/AgeAssuranceAccountCard.tsx
index 530e43d44..a00a8c71a 100644
--- a/src/components/ageAssurance/AgeAssuranceAccountCard.tsx
+++ b/src/components/ageAssurance/AgeAssuranceAccountCard.tsx
@@ -4,6 +4,7 @@ import {useLingui} from '@lingui/react'
 
 import {dateDiff, useGetTimeAgo} from '#/lib/hooks/useTimeAgo'
 import {useAgeAssurance} from '#/state/ageAssurance/useAgeAssurance'
+import {logger} from '#/state/ageAssurance/util'
 import {atoms as a, useBreakpoints, useTheme, type ViewStyleProp} from '#/alf'
 import {Admonition} from '#/components/Admonition'
 import {AgeAssuranceAppealDialog} from '#/components/ageAssurance/AgeAssuranceAppealDialog'
@@ -83,6 +84,7 @@ function Inner({style}: ViewStyleProp & {}) {
                   label={_(msg`Contact our moderation team`)}
                   {...createStaticClick(() => {
                     appealControl.open()
+                    logger.metric('ageAssurance:appealDialogOpen', {})
                   })}>
                   contact our moderation team
                 </InlineLinkText>{' '}
@@ -109,7 +111,12 @@ function Inner({style}: ViewStyleProp & {}) {
                   size="small"
                   variant="solid"
                   color={hasInitiated ? 'secondary' : 'primary'}
-                  onPress={() => control.open()}>
+                  onPress={() => {
+                    control.open()
+                    logger.metric('ageAssurance:initDialogOpen', {
+                      hasInitiatedPreviously: hasInitiated,
+                    })
+                  }}>
                   <ButtonText>
                     {hasInitiated ? (
                       <Trans>Verify again</Trans>
diff --git a/src/components/ageAssurance/AgeAssuranceAdmonition.tsx b/src/components/ageAssurance/AgeAssuranceAdmonition.tsx
index d140b7873..1c77adbbb 100644
--- a/src/components/ageAssurance/AgeAssuranceAdmonition.tsx
+++ b/src/components/ageAssurance/AgeAssuranceAdmonition.tsx
@@ -3,6 +3,7 @@ import {msg, Trans} from '@lingui/macro'
 import {useLingui} from '@lingui/react'
 
 import {useAgeAssurance} from '#/state/ageAssurance/useAgeAssurance'
+import {logger} from '#/state/ageAssurance/util'
 import {atoms as a, select, useTheme, type ViewStyleProp} from '#/alf'
 import {useDialogControl} from '#/components/ageAssurance/AgeAssuranceInitDialog'
 import type * as Dialog from '#/components/Dialog'
@@ -87,7 +88,10 @@ function Inner({
                 <InlineLinkText
                   label={_(msg`Go to account settings`)}
                   to={'/settings/account'}
-                  style={[a.text_sm, a.leading_snug, a.font_bold]}>
+                  style={[a.text_sm, a.leading_snug, a.font_bold]}
+                  onPress={() => {
+                    logger.metric('ageAssurance:navigateToSettings', {})
+                  }}>
                   account settings.
                 </InlineLinkText>
               </Trans>
diff --git a/src/components/ageAssurance/AgeAssuranceAppealDialog.tsx b/src/components/ageAssurance/AgeAssuranceAppealDialog.tsx
index 166f6c26d..cc0d568ca 100644
--- a/src/components/ageAssurance/AgeAssuranceAppealDialog.tsx
+++ b/src/components/ageAssurance/AgeAssuranceAppealDialog.tsx
@@ -5,7 +5,7 @@ import {msg, Trans} from '@lingui/macro'
 import {useLingui} from '@lingui/react'
 import {useMutation} from '@tanstack/react-query'
 
-import {logger} from '#/logger'
+import {logger} from '#/state/ageAssurance/util'
 import {useAgent, useSession} from '#/state/session'
 import * as Toast from '#/view/com/util/Toast'
 import {atoms as a, useBreakpoints, web} from '#/alf'
@@ -45,6 +45,8 @@ function Inner({control}: {control: Dialog.DialogControlProps}) {
 
   const {mutate, isPending} = useMutation({
     mutationFn: async () => {
+      logger.metric('ageAssurance:appealDialogSubmit', {})
+
       await agent.createModerationReport(
         {
           reasonType: ComAtprotoModerationDefs.REASONAPPEAL,
diff --git a/src/components/ageAssurance/AgeAssuranceDismissibleFeedBanner.tsx b/src/components/ageAssurance/AgeAssuranceDismissibleFeedBanner.tsx
new file mode 100644
index 000000000..cad7e2dc8
--- /dev/null
+++ b/src/components/ageAssurance/AgeAssuranceDismissibleFeedBanner.tsx
@@ -0,0 +1,141 @@
+import {useMemo} from 'react'
+import {View} from 'react-native'
+import {msg} from '@lingui/macro'
+import {useLingui} from '@lingui/react'
+
+import {useAgeAssurance} from '#/state/ageAssurance/useAgeAssurance'
+import {logger} from '#/state/ageAssurance/util'
+import {Nux, useNux, useSaveNux} from '#/state/queries/nuxs'
+import {atoms as a, select, useTheme} from '#/alf'
+import {useAgeAssuranceCopy} from '#/components/ageAssurance/useAgeAssuranceCopy'
+import {Button} from '#/components/Button'
+import {ShieldCheck_Stroke2_Corner0_Rounded as Shield} from '#/components/icons/Shield'
+import {TimesLarge_Stroke2_Corner0_Rounded as X} from '#/components/icons/Times'
+import {Link} from '#/components/Link'
+import {Text} from '#/components/Typography'
+
+export function useInternalState() {
+  const {isReady, isDeclaredUnderage, isAgeRestricted, lastInitiatedAt} =
+    useAgeAssurance()
+  const {nux} = useNux(Nux.AgeAssuranceDismissibleFeedBanner)
+  const {mutate: save, variables} = useSaveNux()
+  const hidden = !!variables
+
+  const visible = useMemo(() => {
+    if (!isReady) return false
+    if (isDeclaredUnderage) return false
+    if (!isAgeRestricted) return false
+    if (lastInitiatedAt) return false
+    if (hidden) return false
+    if (nux && nux.completed) return false
+    return true
+  }, [
+    isReady,
+    isDeclaredUnderage,
+    isAgeRestricted,
+    lastInitiatedAt,
+    hidden,
+    nux,
+  ])
+
+  const close = () => {
+    save({
+      id: Nux.AgeAssuranceDismissibleFeedBanner,
+      completed: true,
+      data: undefined,
+    })
+  }
+
+  return {visible, close}
+}
+
+export function AgeAssuranceDismissibleFeedBanner() {
+  const t = useTheme()
+  const {_} = useLingui()
+  const {visible, close} = useInternalState()
+  const copy = useAgeAssuranceCopy()
+
+  if (!visible) return null
+
+  return (
+    <View
+      style={[
+        a.px_lg,
+        {
+          paddingVertical: 10,
+          backgroundColor: select(t.name, {
+            light: t.palette.primary_25,
+            dark: t.palette.primary_25,
+            dim: t.palette.primary_25,
+          }),
+        },
+      ]}>
+      <Link
+        label={_(msg`Learn more about age assurance`)}
+        to="/settings/account"
+        onPress={() => {
+          close()
+          logger.metric('ageAssurance:navigateToSettings', {})
+        }}
+        style={[a.w_full, a.justify_between, a.align_center, a.gap_md]}>
+        <View
+          style={[
+            a.align_center,
+            a.justify_center,
+            a.rounded_full,
+            {
+              width: 42,
+              height: 42,
+              backgroundColor: select(t.name, {
+                light: t.palette.primary_100,
+                dark: t.palette.primary_100,
+                dim: t.palette.primary_100,
+              }),
+            },
+          ]}>
+          <Shield size="lg" />
+        </View>
+
+        <View
+          style={[
+            a.flex_1,
+            {
+              paddingRight: 40,
+            },
+          ]}>
+          <View style={{maxWidth: 400}}>
+            <Text style={[a.leading_snug]}>{copy.banner}</Text>
+          </View>
+        </View>
+      </Link>
+
+      <Button
+        label={_(msg`Don't show again`)}
+        size="small"
+        onPress={() => {
+          close()
+          logger.metric('ageAssurance:dismissFeedBanner', {})
+        }}
+        style={[
+          a.absolute,
+          a.justify_center,
+          a.align_center,
+          {
+            top: 0,
+            bottom: 0,
+            right: 0,
+            paddingRight: a.px_md.paddingLeft,
+          },
+        ]}>
+        <X
+          width={20}
+          fill={select(t.name, {
+            light: t.palette.primary_600,
+            dark: t.palette.primary_600,
+            dim: t.palette.primary_600,
+          })}
+        />
+      </Button>
+    </View>
+  )
+}
diff --git a/src/components/ageAssurance/AgeAssuranceDismissibleHeaderButton.tsx b/src/components/ageAssurance/AgeAssuranceDismissibleHeaderButton.tsx
deleted file mode 100644
index b6505fb0e..000000000
--- a/src/components/ageAssurance/AgeAssuranceDismissibleHeaderButton.tsx
+++ /dev/null
@@ -1,95 +0,0 @@
-import {useMemo} from 'react'
-import {View} from 'react-native'
-import {msg, Trans} from '@lingui/macro'
-import {useLingui} from '@lingui/react'
-
-import {useAgeAssurance} from '#/state/ageAssurance/useAgeAssurance'
-import {Nux, useNux, useSaveNux} from '#/state/queries/nuxs'
-import {atoms as a, select, useTheme} from '#/alf'
-import {ShieldCheck_Stroke2_Corner0_Rounded as Shield} from '#/components/icons/Shield'
-import {Link} from '#/components/Link'
-import {Text} from '#/components/Typography'
-
-export function useInternalState() {
-  const {isReady, isDeclaredUnderage, isAgeRestricted, lastInitiatedAt} =
-    useAgeAssurance()
-  const {nux} = useNux(Nux.AgeAssuranceDismissibleHeaderButton)
-  const {mutate: save, variables} = useSaveNux()
-  const hidden = !!variables
-
-  const visible = useMemo(() => {
-    if (!isReady) return false
-    if (isDeclaredUnderage) return false
-    if (!isAgeRestricted) return false
-    if (lastInitiatedAt) return false
-    if (hidden) return false
-    if (nux && nux.completed) return false
-    return true
-  }, [
-    isReady,
-    isDeclaredUnderage,
-    isAgeRestricted,
-    lastInitiatedAt,
-    hidden,
-    nux,
-  ])
-
-  const close = () => {
-    save({
-      id: Nux.AgeAssuranceDismissibleHeaderButton,
-      completed: true,
-      data: undefined,
-    })
-  }
-
-  return {visible, close}
-}
-
-export function AgeAssuranceDismissibleHeaderButton() {
-  const t = useTheme()
-  const {_} = useLingui()
-  const {visible, close} = useInternalState()
-
-  if (!visible) return null
-
-  return (
-    <Link
-      label={_(msg`Learn more about age assurance`)}
-      to="/settings/account"
-      onPress={close}>
-      <View
-        style={[
-          a.flex_row,
-          a.align_center,
-          a.gap_xs,
-          a.px_sm,
-          a.pr_sm,
-          a.rounded_full,
-          {
-            paddingVertical: 6,
-            backgroundColor: select(t.name, {
-              light: t.palette.primary_100,
-              dark: t.palette.primary_100,
-              dim: t.palette.primary_100,
-            }),
-          },
-        ]}>
-        <Shield size="sm" />
-        <Text
-          style={[
-            a.font_bold,
-            a.leading_snug,
-            {
-              color: select(t.name, {
-                light: t.palette.primary_800,
-                dark: t.palette.primary_800,
-                dim: t.palette.primary_800,
-              }),
-            },
-          ]}>
-          <Trans>Age Assurance</Trans>
-        </Text>
-      </View>
-    </Link>
-  )
-}
diff --git a/src/components/ageAssurance/AgeAssuranceDismissibleNotice.tsx b/src/components/ageAssurance/AgeAssuranceDismissibleNotice.tsx
index 30e2fbec4..c9f242ca8 100644
--- a/src/components/ageAssurance/AgeAssuranceDismissibleNotice.tsx
+++ b/src/components/ageAssurance/AgeAssuranceDismissibleNotice.tsx
@@ -3,6 +3,7 @@ import {msg} from '@lingui/macro'
 import {useLingui} from '@lingui/react'
 
 import {useAgeAssurance} from '#/state/ageAssurance/useAgeAssurance'
+import {logger} from '#/state/ageAssurance/util'
 import {Nux, useNux, useSaveNux} from '#/state/queries/nuxs'
 import {atoms as a, type ViewStyleProp} from '#/alf'
 import {AgeAssuranceAdmonition} from '#/components/ageAssurance/AgeAssuranceAdmonition'
@@ -37,13 +38,14 @@ export function AgeAssuranceDismissibleNotice({style}: ViewStyleProp & {}) {
           variant="solid"
           color="secondary_inverted"
           shape="round"
-          onPress={() =>
+          onPress={() => {
             save({
               id: Nux.AgeAssuranceDismissibleNotice,
               completed: true,
               data: undefined,
             })
-          }
+            logger.metric('ageAssurance:dismissSettingsNotice', {})
+          }}
           style={[
             a.absolute,
             {
diff --git a/src/components/ageAssurance/AgeAssuranceInitDialog.tsx b/src/components/ageAssurance/AgeAssuranceInitDialog.tsx
index ad13cc1c2..a189d9af2 100644
--- a/src/components/ageAssurance/AgeAssuranceInitDialog.tsx
+++ b/src/components/ageAssurance/AgeAssuranceInitDialog.tsx
@@ -1,16 +1,22 @@
 import {useState} from 'react'
 import {View} from 'react-native'
+import {XRPCError} from '@atproto/xrpc'
 import {msg, Trans} from '@lingui/macro'
 import {useLingui} from '@lingui/react'
 import {validate as validateEmail} from 'email-validator'
 
 import {useCleanError} from '#/lib/hooks/useCleanError'
+import {
+  SupportCode,
+  useCreateSupportLink,
+} from '#/lib/hooks/useCreateSupportLink'
 import {useGetTimeAgo} from '#/lib/hooks/useTimeAgo'
 import {useTLDs} from '#/lib/hooks/useTLDs'
 import {isEmailMaybeInvalid} from '#/lib/strings/email'
 import {type AppLanguage} from '#/locale/languages'
 import {useAgeAssuranceContext} from '#/state/ageAssurance'
 import {useInitAgeAssurance} from '#/state/ageAssurance/useInitAgeAssurance'
+import {logger} from '#/state/ageAssurance/util'
 import {useLanguagePrefs} from '#/state/preferences'
 import {useSession} from '#/state/session'
 import {atoms as a, useTheme, web} from '#/alf'
@@ -66,6 +72,7 @@ function Inner() {
   const {lastInitiatedAt} = useAgeAssuranceContext()
   const getTimeAgo = useGetTimeAgo()
   const tlds = useTLDs()
+  const createSupportLink = useCreateSupportLink()
 
   const wasRecentlyInitiated =
     lastInitiatedAt &&
@@ -79,7 +86,7 @@ function Inner() {
   const [language, setLanguage] = useState<string | undefined>(
     convertToKWSSupportedLanguage(langPrefs.appLanguage),
   )
-  const [error, setError] = useState<string>('')
+  const [error, setError] = useState<React.ReactNode>(null)
 
   const {mutateAsync: init, isPending} = useInitAgeAssurance()
 
@@ -109,6 +116,8 @@ function Inner() {
   const onSubmit = async () => {
     setLanguageError(false)
 
+    logger.metric('ageAssurance:initDialogSubmit', {})
+
     try {
       const {status} = runEmailValidation()
 
@@ -125,22 +134,35 @@ function Inner() {
 
       setSuccess(true)
     } catch (e) {
-      const {clean, raw} = cleanError(e)
-
-      if (clean) {
-        setError(clean || _(msg`Something went wrong, please try again`))
-      } else {
-        let message = _(msg`Something went wrong, please try again`)
-
-        if (raw) {
-          if (raw.startsWith('This email address is not supported')) {
-            message = _(
+      if (e instanceof XRPCError) {
+        if (e.error === 'InvalidEmail') {
+          setError(
+            _(
               msg`Please enter a valid, non-temporary email address. You may need to access this email in the future.`,
-            )
-          }
+            ),
+          )
+          logger.metric('ageAssurance:initDialogError', {code: 'InvalidEmail'})
+        } else if (e.error === 'DidTooLong') {
+          setError(
+            <>
+              <Trans>
+                We're having issues initializing the age assurance process for
+                your account. Please{' '}
+                <InlineLinkText
+                  to={createSupportLink({code: SupportCode.AA_DID, email})}
+                  label={_(msg`Contact support`)}>
+                  contact support
+                </InlineLinkText>{' '}
+                for assistance.
+              </Trans>
+            </>,
+          )
+          logger.metric('ageAssurance:initDialogError', {code: 'DidTooLong'})
         }
-
-        setError(message)
+      } else {
+        const {clean, raw} = cleanError(e)
+        setError(clean || raw || _(msg`Something went wrong, please try again`))
+        logger.metric('ageAssurance:initDialogError', {code: 'other'})
       }
     }
   }
diff --git a/src/components/ageAssurance/AgeAssuranceRedirectDialog.tsx b/src/components/ageAssurance/AgeAssuranceRedirectDialog.tsx
index 41e706fee..ff2e0bfd0 100644
--- a/src/components/ageAssurance/AgeAssuranceRedirectDialog.tsx
+++ b/src/components/ageAssurance/AgeAssuranceRedirectDialog.tsx
@@ -7,6 +7,7 @@ import {retry} from '#/lib/async/retry'
 import {wait} from '#/lib/async/wait'
 import {isNative} from '#/platform/detection'
 import {useAgeAssuranceAPIContext} from '#/state/ageAssurance'
+import {logger} from '#/state/ageAssurance/util'
 import {useAgent} from '#/state/session'
 import {atoms as a, useTheme, web} from '#/alf'
 import {AgeAssuranceBadge} from '#/components/ageAssurance/AgeAssuranceBadge'
@@ -92,6 +93,8 @@ export function Inner({}: {optimisticState?: AgeAssuranceRedirectDialogState}) {
 
     polling.current = true
 
+    logger.metric('ageAssurance:redirectDialogOpen', {})
+
     wait(
       3e3,
       retry(
@@ -124,12 +127,15 @@ export function Inner({}: {optimisticState?: AgeAssuranceRedirectDialogState}) {
 
         control.clear()
         control.control.close()
+
+        logger.metric('ageAssurance:redirectDialogSuccess', {})
       })
       .catch(() => {
         if (unmounted.current) return
         setError(true)
         // try a refetch anyway
         refreshAgeAssuranceState()
+        logger.metric('ageAssurance:redirectDialogFail', {})
       })
 
     return () => {
diff --git a/src/components/ageAssurance/AgeRestrictedScreen.tsx b/src/components/ageAssurance/AgeRestrictedScreen.tsx
index 2a9882415..b47cc5b0c 100644
--- a/src/components/ageAssurance/AgeRestrictedScreen.tsx
+++ b/src/components/ageAssurance/AgeRestrictedScreen.tsx
@@ -3,6 +3,7 @@ import {msg, Trans} from '@lingui/macro'
 import {useLingui} from '@lingui/react'
 
 import {useAgeAssurance} from '#/state/ageAssurance/useAgeAssurance'
+import {logger} from '#/state/ageAssurance/util'
 import {atoms as a} from '#/alf'
 import {Admonition} from '#/components/Admonition'
 import {AgeAssuranceBadge} from '#/components/ageAssurance/AgeAssuranceBadge'
@@ -61,13 +62,11 @@ export function AgeRestrictedScreen({
           <View style={[a.gap_sm, a.pb_lg]}>
             <Text style={[a.text_xl, a.leading_snug, a.font_heavy]}>
               <Trans>
-                You must verify your age in order to access this screen.
+                You must complete age assurance in order to access this screen.
               </Trans>
             </Text>
 
-            <Text style={[a.text_md, a.leading_snug]}>
-              <Trans>{copy.notice}</Trans>
-            </Text>
+            <Text style={[a.text_md, a.leading_snug]}>{copy.notice}</Text>
           </View>
 
           <View
@@ -77,7 +76,10 @@ export function AgeRestrictedScreen({
               to="/settings/account"
               size="small"
               variant="solid"
-              color="primary">
+              color="primary"
+              onPress={() => {
+                logger.metric('ageAssurance:navigateToSettings', {})
+              }}>
               <ButtonText>
                 <Trans>Go to account settings</Trans>
               </ButtonText>
diff --git a/src/components/ageAssurance/useAgeAssuranceCopy.ts b/src/components/ageAssurance/useAgeAssuranceCopy.ts
index 045806994..f8a0edd79 100644
--- a/src/components/ageAssurance/useAgeAssuranceCopy.ts
+++ b/src/components/ageAssurance/useAgeAssuranceCopy.ts
@@ -8,10 +8,13 @@ export function useAgeAssuranceCopy() {
   return useMemo(() => {
     return {
       notice: _(
-        msg`The laws in your location require that you verify your age before accessing certain features on Bluesky like adult content and direct messaging.`,
+        msg`The laws in your location require you to verify you're an adult before accessing certain features on Bluesky, like adult content and direct messaging.`,
+      ),
+      banner: _(
+        msg`The laws in your location require you to verify you're an adult. Tap to learn more.`,
       ),
       chatsInfoText: _(
-        msg`Don't worry! All existing messages and settings are saved and will be available after you've been verified to be 18 or older.`,
+        msg`Don't worry! All existing messages and settings are saved and will be available after you verify you're an adult.`,
       ),
     }
   }, [_])