about summary refs log tree commit diff
path: root/src
diff options
context:
space:
mode:
Diffstat (limited to 'src')
-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
-rw-r--r--src/lib/hooks/useCreateSupportLink.ts39
-rw-r--r--src/lib/notifications/notifications.ts3
-rw-r--r--src/logger/metrics.ts16
-rw-r--r--src/screens/Moderation/index.tsx243
-rw-r--r--src/screens/Settings/AboutSettings.tsx16
-rw-r--r--src/state/ageAssurance/index.tsx4
-rw-r--r--src/state/ageAssurance/useAgeAssurance.ts4
-rw-r--r--src/state/ageAssurance/useIsAgeAssuranceEnabled.ts3
-rw-r--r--src/state/ageAssurance/util.ts3
-rw-r--r--src/state/queries/nuxs/definitions.ts6
-rw-r--r--src/view/com/posts/PostFeed.tsx47
21 files changed, 461 insertions, 261 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.`,
       ),
     }
   }, [_])
diff --git a/src/lib/hooks/useCreateSupportLink.ts b/src/lib/hooks/useCreateSupportLink.ts
new file mode 100644
index 000000000..5ec7578c5
--- /dev/null
+++ b/src/lib/hooks/useCreateSupportLink.ts
@@ -0,0 +1,39 @@
+import {useCallback} from 'react'
+import {msg} from '@lingui/macro'
+import {useLingui} from '@lingui/react'
+
+import {useSession} from '#/state/session'
+
+export const ZENDESK_SUPPORT_URL =
+  'https://blueskyweb.zendesk.com/hc/requests/new'
+
+export enum SupportCode {
+  AA_DID = 'AA_DID',
+}
+
+/**
+ * {@link https://support.zendesk.com/hc/en-us/articles/4408839114522-Creating-pre-filled-ticket-forms}
+ */
+export function useCreateSupportLink() {
+  const {_} = useLingui()
+  const {currentAccount} = useSession()
+
+  return useCallback(
+    ({code, email}: {code: SupportCode; email?: string}) => {
+      const url = new URL(ZENDESK_SUPPORT_URL)
+      if (currentAccount) {
+        url.search = new URLSearchParams({
+          tf_anonymous_requester_email: email || currentAccount.email || '', // email will be defined
+          tf_description:
+            `[Code: ${code}] — ` + _(msg`Please write your message below:`),
+          /**
+           * Custom field specific to {@link ZENDESK_SUPPORT_URL} form
+           */
+          tf_17205412673421: currentAccount.handle + ` (${currentAccount.did})`,
+        }).toString()
+      }
+      return url.toString()
+    },
+    [_, currentAccount],
+  )
+}
diff --git a/src/lib/notifications/notifications.ts b/src/lib/notifications/notifications.ts
index 0d2f9ed09..67a38a52c 100644
--- a/src/lib/notifications/notifications.ts
+++ b/src/lib/notifications/notifications.ts
@@ -192,6 +192,9 @@ export function useNotificationsRegistration() {
      * Register the push token with the Bluesky server, whenever it changes.
      * This is also fired any time `getDevicePushTokenAsync` is called.
      *
+     * Since this is registered immediately after `getAndRegisterPushToken`, it
+     * should also detect that getter and be fired almost immediately after this.
+     *
      * According to the Expo docs, there is a chance that the token will change
      * while the app is open in some rare cases. This will fire
      * `registerPushToken` whenever that happens.
diff --git a/src/logger/metrics.ts b/src/logger/metrics.ts
index 3390c4b4b..dfca1f7d8 100644
--- a/src/logger/metrics.ts
+++ b/src/logger/metrics.ts
@@ -457,4 +457,20 @@ export type MetricEvents = {
     name: string
     value: string
   }
+
+  'ageAssurance:navigateToSettings': {}
+  'ageAssurance:dismissFeedBanner': {}
+  'ageAssurance:dismissSettingsNotice': {}
+  'ageAssurance:initDialogOpen': {
+    hasInitiatedPreviously: boolean
+  }
+  'ageAssurance:initDialogSubmit': {}
+  'ageAssurance:initDialogError': {
+    code: string
+  }
+  'ageAssurance:redirectDialogOpen': {}
+  'ageAssurance:redirectDialogSuccess': {}
+  'ageAssurance:redirectDialogFail': {}
+  'ageAssurance:appealDialogOpen': {}
+  'ageAssurance:appealDialogSubmit': {}
 }
diff --git a/src/screens/Moderation/index.tsx b/src/screens/Moderation/index.tsx
index a7b434e52..1517792a1 100644
--- a/src/screens/Moderation/index.tsx
+++ b/src/screens/Moderation/index.tsx
@@ -304,127 +304,144 @@ export function ModerationScreenInner({
         </Link>
       </View>
 
-      <Text
-        style={[
-          a.pt_2xl,
-          a.pb_md,
-          a.text_md,
-          a.font_bold,
-          t.atoms.text_contrast_high,
-        ]}>
-        <Trans>Content filters</Trans>
-      </Text>
+      {declaredAge === undefined && (
+        <>
+          <Text
+            style={[
+              a.pt_2xl,
+              a.pb_md,
+              a.text_md,
+              a.font_bold,
+              t.atoms.text_contrast_high,
+            ]}>
+            <Trans>Content filters</Trans>
+          </Text>
 
-      <AgeAssuranceAdmonition style={[a.pb_md]}>
-        <Trans>
-          You must complete age assurance in order to access the settings below.
-        </Trans>
-      </AgeAssuranceAdmonition>
+          <Button
+            label={_(msg`Confirm your birthdate`)}
+            size="small"
+            variant="solid"
+            color="secondary"
+            onPress={() => {
+              birthdateDialogControl.open()
+            }}
+            style={[a.justify_between, a.rounded_md, a.px_lg, a.py_lg]}>
+            <ButtonText>
+              <Trans>Confirm your age:</Trans>
+            </ButtonText>
+            <ButtonText>
+              <Trans>Set birthdate</Trans>
+            </ButtonText>
+          </Button>
 
-      <View style={[a.gap_md]}>
-        {declaredAge === undefined && (
-          <>
-            <Button
-              label={_(msg`Confirm your birthdate`)}
-              size="small"
-              variant="solid"
-              color="secondary"
-              onPress={() => {
-                birthdateDialogControl.open()
-              }}
-              style={[a.justify_between, a.rounded_md, a.px_lg, a.py_lg]}>
-              <ButtonText>
-                <Trans>Confirm your age:</Trans>
-              </ButtonText>
-              <ButtonText>
-                <Trans>Set birthdate</Trans>
-              </ButtonText>
-            </Button>
+          <BirthDateSettingsDialog control={birthdateDialogControl} />
+        </>
+      )}
 
-            <BirthDateSettingsDialog control={birthdateDialogControl} />
-          </>
-        )}
-        <View
-          style={[
-            a.w_full,
-            a.rounded_md,
-            a.overflow_hidden,
-            t.atoms.bg_contrast_25,
-          ]}>
-          {!isDeclaredUnderage && !isAgeRestricted && (
-            <>
-              <View
-                style={[
-                  a.py_lg,
-                  a.px_lg,
-                  a.flex_row,
-                  a.align_center,
-                  a.justify_between,
-                  disabledOnIOS && {opacity: 0.5},
-                ]}>
-                <Text style={[a.font_bold, t.atoms.text_contrast_high]}>
-                  <Trans>Enable adult content</Trans>
-                </Text>
-                <Toggle.Item
-                  label={_(msg`Toggle to enable or disable adult content`)}
-                  disabled={disabledOnIOS}
-                  name="adultContent"
-                  value={adultContentEnabled}
-                  onChange={onToggleAdultContentEnabled}>
-                  <View style={[a.flex_row, a.align_center, a.gap_sm]}>
-                    <Text style={[t.atoms.text_contrast_medium]}>
-                      {adultContentEnabled ? (
-                        <Trans>Enabled</Trans>
-                      ) : (
-                        <Trans>Disabled</Trans>
-                      )}
+      {!isDeclaredUnderage && (
+        <>
+          <Text
+            style={[
+              a.pt_2xl,
+              a.pb_md,
+              a.text_md,
+              a.font_bold,
+              t.atoms.text_contrast_high,
+            ]}>
+            <Trans>Content filters</Trans>
+          </Text>
+
+          <AgeAssuranceAdmonition style={[a.pb_md]}>
+            <Trans>
+              You must complete age assurance in order to access the settings
+              below.
+            </Trans>
+          </AgeAssuranceAdmonition>
+
+          <View style={[a.gap_md]}>
+            <View
+              style={[
+                a.w_full,
+                a.rounded_md,
+                a.overflow_hidden,
+                t.atoms.bg_contrast_25,
+              ]}>
+              {!isDeclaredUnderage && (
+                <>
+                  <View
+                    style={[
+                      a.py_lg,
+                      a.px_lg,
+                      a.flex_row,
+                      a.align_center,
+                      a.justify_between,
+                      disabledOnIOS && {opacity: 0.5},
+                    ]}>
+                    <Text style={[a.font_bold, t.atoms.text_contrast_high]}>
+                      <Trans>Enable adult content</Trans>
                     </Text>
-                    <Toggle.Switch />
+                    <Toggle.Item
+                      label={_(msg`Toggle to enable or disable adult content`)}
+                      disabled={disabledOnIOS || isAgeRestricted}
+                      name="adultContent"
+                      value={adultContentEnabled}
+                      onChange={onToggleAdultContentEnabled}>
+                      <View style={[a.flex_row, a.align_center, a.gap_sm]}>
+                        <Text style={[t.atoms.text_contrast_medium]}>
+                          {adultContentEnabled ? (
+                            <Trans>Enabled</Trans>
+                          ) : (
+                            <Trans>Disabled</Trans>
+                          )}
+                        </Text>
+                        <Toggle.Switch />
+                      </View>
+                    </Toggle.Item>
                   </View>
-                </Toggle.Item>
-              </View>
-              {disabledOnIOS && (
-                <View style={[a.pb_lg, a.px_lg]}>
-                  <Text>
-                    <Trans>
-                      Adult content can only be enabled via the Web at{' '}
-                      <InlineLinkText
-                        label={_(msg`The Bluesky web application`)}
-                        to=""
-                        onPress={evt => {
-                          evt.preventDefault()
-                          Linking.openURL('https://bsky.app/')
-                          return false
-                        }}>
-                        bsky.app
-                      </InlineLinkText>
-                      .
-                    </Trans>
-                  </Text>
-                </View>
-              )}
-              <Divider />
+                  {disabledOnIOS && (
+                    <View style={[a.pb_lg, a.px_lg]}>
+                      <Text>
+                        <Trans>
+                          Adult content can only be enabled via the Web at{' '}
+                          <InlineLinkText
+                            label={_(msg`The Bluesky web application`)}
+                            to=""
+                            onPress={evt => {
+                              evt.preventDefault()
+                              Linking.openURL('https://bsky.app/')
+                              return false
+                            }}>
+                            bsky.app
+                          </InlineLinkText>
+                          .
+                        </Trans>
+                      </Text>
+                    </View>
+                  )}
 
-              {adultContentEnabled && (
-                <>
-                  <GlobalLabelPreference labelDefinition={LABELS.porn} />
-                  <Divider />
-                  <GlobalLabelPreference labelDefinition={LABELS.sexual} />
-                  <Divider />
-                  <GlobalLabelPreference
-                    labelDefinition={LABELS['graphic-media']}
-                  />
-                  <Divider />
+                  {adultContentEnabled && (
+                    <>
+                      <Divider />
+                      <GlobalLabelPreference labelDefinition={LABELS.porn} />
+                      <Divider />
+                      <GlobalLabelPreference labelDefinition={LABELS.sexual} />
+                      <Divider />
+                      <GlobalLabelPreference
+                        labelDefinition={LABELS['graphic-media']}
+                      />
+                      <Divider />
+                      <GlobalLabelPreference
+                        disabled={isDeclaredUnderage || isAgeRestricted}
+                        labelDefinition={LABELS.nudity}
+                      />
+                    </>
+                  )}
                 </>
               )}
-            </>
-          )}
-          <GlobalLabelPreference
-            disabled={isDeclaredUnderage || isAgeRestricted}
-            labelDefinition={LABELS.nudity}
-          />
-        </View>
-      </View>
+            </View>
+          </View>
+        </>
+      )}
 
       <Text
         style={[
diff --git a/src/screens/Settings/AboutSettings.tsx b/src/screens/Settings/AboutSettings.tsx
index 6be881a88..0ce127ff3 100644
--- a/src/screens/Settings/AboutSettings.tsx
+++ b/src/screens/Settings/AboutSettings.tsx
@@ -20,11 +20,9 @@ import {BroomSparkle_Stroke2_Corner2_Rounded as BroomSparkleIcon} from '#/compon
 import {CodeLines_Stroke2_Corner2_Rounded as CodeLinesIcon} from '#/components/icons/CodeLines'
 import {Globe_Stroke2_Corner0_Rounded as GlobeIcon} from '#/components/icons/Globe'
 import {Newspaper_Stroke2_Corner2_Rounded as NewspaperIcon} from '#/components/icons/Newspaper'
-import {ShieldCheck_Stroke2_Corner0_Rounded as Shield} from '#/components/icons/Shield'
 import {Wrench_Stroke2_Corner2_Rounded as WrenchIcon} from '#/components/icons/Wrench'
 import * as Layout from '#/components/Layout'
 import {Loader} from '#/components/Loader'
-import {device} from '#/storage'
 import {useDemoMode} from '#/storage/hooks/demo-mode'
 import {useDevMode} from '#/storage/hooks/dev-mode'
 import {OTAInfo} from './components/OTAInfo'
@@ -181,20 +179,6 @@ export function AboutSettingsScreen({}: Props) {
                   </SettingsList.ItemText>
                 </SettingsList.PressableItem>
               )}
-
-              <SettingsList.PressableItem
-                onPress={() => {
-                  device.set(['geolocation'], {
-                    countryCode: 'GB',
-                    isAgeRestrictedGeo: true,
-                  })
-                }}
-                label="Simulate age restriction">
-                <SettingsList.ItemIcon icon={Shield} />
-                <SettingsList.ItemText>
-                  Simulate age restriction
-                </SettingsList.ItemText>
-              </SettingsList.PressableItem>
             </>
           )}
         </SettingsList.Container>
diff --git a/src/state/ageAssurance/index.tsx b/src/state/ageAssurance/index.tsx
index aab954e6c..eded74773 100644
--- a/src/state/ageAssurance/index.tsx
+++ b/src/state/ageAssurance/index.tsx
@@ -6,17 +6,15 @@ import {networkRetry} from '#/lib/async/retry'
 import {useGetAndRegisterPushToken} from '#/lib/notifications/notifications'
 import {useGate} from '#/lib/statsig/statsig'
 import {isNetworkError} from '#/lib/strings/errors'
-import {Logger} from '#/logger'
 import {
   type AgeAssuranceAPIContextType,
   type AgeAssuranceContextType,
 } from '#/state/ageAssurance/types'
 import {useIsAgeAssuranceEnabled} from '#/state/ageAssurance/useIsAgeAssuranceEnabled'
+import {logger} from '#/state/ageAssurance/util'
 import {useGeolocation} from '#/state/geolocation'
 import {useAgent} from '#/state/session'
 
-const logger = Logger.create(Logger.Context.AgeAssurance)
-
 export const createAgeAssuranceQueryKey = (did: string) =>
   ['ageAssurance', did] as const
 
diff --git a/src/state/ageAssurance/useAgeAssurance.ts b/src/state/ageAssurance/useAgeAssurance.ts
index 455f38c92..0215cc88d 100644
--- a/src/state/ageAssurance/useAgeAssurance.ts
+++ b/src/state/ageAssurance/useAgeAssurance.ts
@@ -1,11 +1,9 @@
 import {useMemo} from 'react'
 
-import {Logger} from '#/logger'
 import {useAgeAssuranceContext} from '#/state/ageAssurance'
+import {logger} from '#/state/ageAssurance/util'
 import {usePreferencesQuery} from '#/state/queries/preferences'
 
-const logger = Logger.create(Logger.Context.AgeAssurance)
-
 type AgeAssurance = ReturnType<typeof useAgeAssuranceContext> & {
   /**
    * The age the user has declared in their preferences, if any.
diff --git a/src/state/ageAssurance/useIsAgeAssuranceEnabled.ts b/src/state/ageAssurance/useIsAgeAssuranceEnabled.ts
index 5c1a7b1c4..06fe46d23 100644
--- a/src/state/ageAssurance/useIsAgeAssuranceEnabled.ts
+++ b/src/state/ageAssurance/useIsAgeAssuranceEnabled.ts
@@ -8,6 +8,7 @@ export function useIsAgeAssuranceEnabled() {
   const {geolocation} = useGeolocation()
 
   return useMemo(() => {
-    return gate('age_assurance') && !!geolocation?.isAgeRestrictedGeo
+    const enabled = gate('age_assurance')
+    return enabled && !!geolocation?.isAgeRestrictedGeo
   }, [geolocation, gate])
 }
diff --git a/src/state/ageAssurance/util.ts b/src/state/ageAssurance/util.ts
new file mode 100644
index 000000000..6b0e97b1c
--- /dev/null
+++ b/src/state/ageAssurance/util.ts
@@ -0,0 +1,3 @@
+import {Logger} from '#/logger'
+
+export const logger = Logger.create(Logger.Context.AgeAssurance)
diff --git a/src/state/queries/nuxs/definitions.ts b/src/state/queries/nuxs/definitions.ts
index 61657992f..3d5c132f2 100644
--- a/src/state/queries/nuxs/definitions.ts
+++ b/src/state/queries/nuxs/definitions.ts
@@ -8,7 +8,7 @@ export enum Nux {
   InitialVerificationAnnouncement = 'InitialVerificationAnnouncement',
   ActivitySubscriptions = 'ActivitySubscriptions',
   AgeAssuranceDismissibleNotice = 'AgeAssuranceDismissibleNotice',
-  AgeAssuranceDismissibleHeaderButton = 'AgeAssuranceDismissibleHeaderButton',
+  AgeAssuranceDismissibleFeedBanner = 'AgeAssuranceDismissibleFeedBanner',
 }
 
 export const nuxNames = new Set(Object.values(Nux))
@@ -35,7 +35,7 @@ export type AppNux = BaseNux<
       data: undefined
     }
   | {
-      id: Nux.AgeAssuranceDismissibleHeaderButton
+      id: Nux.AgeAssuranceDismissibleFeedBanner
       data: undefined
     }
 >
@@ -46,5 +46,5 @@ export const NuxSchemas: Record<Nux, zod.ZodObject<any> | undefined> = {
   [Nux.InitialVerificationAnnouncement]: undefined,
   [Nux.ActivitySubscriptions]: undefined,
   [Nux.AgeAssuranceDismissibleNotice]: undefined,
-  [Nux.AgeAssuranceDismissibleHeaderButton]: undefined,
+  [Nux.AgeAssuranceDismissibleFeedBanner]: undefined,
 }
diff --git a/src/view/com/posts/PostFeed.tsx b/src/view/com/posts/PostFeed.tsx
index 1d0649b2e..34ebc06fa 100644
--- a/src/view/com/posts/PostFeed.tsx
+++ b/src/view/com/posts/PostFeed.tsx
@@ -43,11 +43,16 @@ import {
 import {useLiveNowConfig} from '#/state/service-config'
 import {useSession} from '#/state/session'
 import {useProgressGuide} from '#/state/shell/progress-guide'
+import {useSelectedFeed} from '#/state/shell/selected-feed'
 import {List, type ListRef} from '#/view/com/util/List'
 import {PostFeedLoadingPlaceholder} from '#/view/com/util/LoadingPlaceholder'
 import {LoadMoreRetryBtn} from '#/view/com/util/LoadMoreRetryBtn'
 import {type VideoFeedSourceContext} from '#/screens/VideoFeed/types'
 import {useBreakpoints, useLayoutBreakpoints} from '#/alf'
+import {
+  AgeAssuranceDismissibleFeedBanner,
+  useInternalState as useAgeAssuranceBannerState,
+} from '#/components/ageAssurance/AgeAssuranceDismissibleFeedBanner'
 import {ProgressGuide, SuggestedFollows} from '#/components/FeedInterstitials'
 import {
   PostFeedVideoGridRow,
@@ -131,6 +136,10 @@ type FeedRow =
       type: 'showLessFollowup'
       key: string
     }
+  | {
+      type: 'ageAssuranceBanner'
+      key: string
+    }
 
 export function getItemsForFeedback(feedRow: FeedRow): {
   item: FeedPostSliceItem
@@ -335,6 +344,14 @@ let PostFeed = ({
 
   const {trendingDisabled, trendingVideoDisabled} = useTrendingSettings()
 
+  const ageAssuranceBannerState = useAgeAssuranceBannerState()
+  const selectedFeed = useSelectedFeed()
+  /**
+   * Cached value of whether the current feed was selected at startup. We don't
+   * want this to update when user swipes.
+   */
+  const [isCurrentFeedAtStartupSelected] = useState(selectedFeed === feed)
+
   const feedItems: FeedRow[] = useMemo(() => {
     // wraps a slice item, and replaces it with a showLessFollowup item
     // if the user has pressed show less on it
@@ -450,6 +467,21 @@ let PostFeed = ({
                         type: 'interstitialProgressGuide',
                         key: 'interstitial-' + sliceIndex + '-' + lastFetchedAt,
                       })
+                    } else {
+                      /*
+                       * Only insert if Discover was the last selected feed at
+                       * startup, the progress guide isn't shown, and the
+                       * banner is eligible to be shown.
+                       */
+                      if (
+                        isCurrentFeedAtStartupSelected &&
+                        ageAssuranceBannerState.visible
+                      ) {
+                        arr.push({
+                          type: 'ageAssuranceBanner',
+                          key: 'ageAssuranceBanner-' + sliceIndex,
+                        })
+                      }
                     }
                     if (!rightNavVisible && !trendingDisabled) {
                       arr.push({
@@ -478,6 +510,17 @@ let PostFeed = ({
                       key: 'interstitial-' + sliceIndex + '-' + lastFetchedAt,
                     })
                   }
+                } else {
+                  /*
+                   * Only insert if this feed was the last selected feed at
+                   * startup and the banner is eligible to be shown.
+                   */
+                  if (sliceIndex === 0 && isCurrentFeedAtStartupSelected) {
+                    arr.push({
+                      type: 'ageAssuranceBanner',
+                      key: 'ageAssuranceBanner-' + sliceIndex,
+                    })
+                  }
                 }
               }
 
@@ -580,6 +623,8 @@ let PostFeed = ({
     isVideoFeed,
     areVideoFeedsEnabled,
     hasPressedShowLessUris,
+    ageAssuranceBannerState,
+    isCurrentFeedAtStartupSelected,
   ])
 
   // events
@@ -666,6 +711,8 @@ let PostFeed = ({
         return <SuggestedFollows feed={feed} />
       } else if (row.type === 'interstitialProgressGuide') {
         return <ProgressGuide />
+      } else if (row.type === 'ageAssuranceBanner') {
+        return <AgeAssuranceDismissibleFeedBanner />
       } else if (row.type === 'interstitialTrending') {
         return <TrendingInterstitial />
       } else if (row.type === 'interstitialTrendingVideos') {