about summary refs log tree commit diff
diff options
context:
space:
mode:
-rw-r--r--.eslintrc.js1
-rw-r--r--bskyweb/cmd/bskyweb/server.go1
-rw-r--r--package.json4
-rw-r--r--src/App.native.tsx86
-rw-r--r--src/App.web.tsx77
-rw-r--r--src/components/LanguageSelect.tsx50
-rw-r--r--src/components/PostControls/ShareMenu/ShareMenuItems.tsx4
-rw-r--r--src/components/PostControls/ShareMenu/ShareMenuItems.web.tsx4
-rw-r--r--src/components/ageAssurance/AgeAssuranceAccountCard.tsx148
-rw-r--r--src/components/ageAssurance/AgeAssuranceAdmonition.tsx100
-rw-r--r--src/components/ageAssurance/AgeAssuranceAppealDialog.tsx140
-rw-r--r--src/components/ageAssurance/AgeAssuranceBadge.tsx46
-rw-r--r--src/components/ageAssurance/AgeAssuranceDismissibleHeaderButton.tsx95
-rw-r--r--src/components/ageAssurance/AgeAssuranceDismissibleNotice.tsx59
-rw-r--r--src/components/ageAssurance/AgeAssuranceInitDialog.tsx351
-rw-r--r--src/components/ageAssurance/AgeAssuranceRedirectDialog.tsx196
-rw-r--r--src/components/ageAssurance/AgeRestrictedScreen.tsx93
-rw-r--r--src/components/ageAssurance/const.ts26
-rw-r--r--src/components/ageAssurance/useAgeAssuranceCopy.ts18
-rw-r--r--src/components/dialogs/Context.tsx6
-rw-r--r--src/components/moderation/LabelPreference.tsx41
-rw-r--r--src/lib/constants.ts3
-rw-r--r--src/lib/hooks/useIntentHandler.ts38
-rw-r--r--src/lib/hooks/useTLDs.ts15
-rw-r--r--src/lib/notifications/notifications.ts98
-rw-r--r--src/lib/statsig/gates.ts1
-rw-r--r--src/logger/types.ts1
-rw-r--r--src/screens/Messages/ChatList.tsx18
-rw-r--r--src/screens/Messages/Conversation.tsx17
-rw-r--r--src/screens/Messages/Inbox.tsx33
-rw-r--r--src/screens/Messages/Settings.tsx18
-rw-r--r--src/screens/Moderation/index.tsx59
-rw-r--r--src/screens/Settings/AboutSettings.tsx16
-rw-r--r--src/screens/Settings/AccountSettings.tsx22
-rw-r--r--src/screens/Settings/Settings.tsx3
-rw-r--r--src/state/ageAssurance/const.ts11
-rw-r--r--src/state/ageAssurance/index.tsx140
-rw-r--r--src/state/ageAssurance/types.ts33
-rw-r--r--src/state/ageAssurance/useAgeAssurance.ts45
-rw-r--r--src/state/ageAssurance/useInitAgeAssurance.ts85
-rw-r--r--src/state/ageAssurance/useIsAgeAssuranceEnabled.ts13
-rw-r--r--src/state/geolocation.tsx6
-rw-r--r--src/state/queries/nuxs/definitions.ts12
-rw-r--r--src/state/queries/nuxs/index.ts16
-rw-r--r--src/state/queries/post-feed.ts27
-rw-r--r--src/state/queries/preferences/index.ts15
-rw-r--r--src/storage/schema.ts1
-rw-r--r--src/view/shell/index.tsx2
-rw-r--r--src/view/shell/index.web.tsx2
-rw-r--r--yarn.lock171
50 files changed, 2204 insertions, 263 deletions
diff --git a/.eslintrc.js b/.eslintrc.js
index 726cc5607..59c1b4eb4 100644
--- a/.eslintrc.js
+++ b/.eslintrc.js
@@ -34,6 +34,7 @@ module.exports = {
           'P',
           'Admonition',
           'Admonition.Admonition',
+          'AgeAssuranceAdmonition',
           'Span',
         ],
         impliedTextProps: [],
diff --git a/bskyweb/cmd/bskyweb/server.go b/bskyweb/cmd/bskyweb/server.go
index ade7e1065..f3230c0d8 100644
--- a/bskyweb/cmd/bskyweb/server.go
+++ b/bskyweb/cmd/bskyweb/server.go
@@ -302,6 +302,7 @@ func serve(cctx *cli.Context) error {
 	e.GET("/support/copyright", server.WebGeneric)
 	e.GET("/intent/compose", server.WebGeneric)
 	e.GET("/intent/verify-email", server.WebGeneric)
+	e.GET("/intent/age-assurance", server.WebGeneric)
 	e.GET("/messages", server.WebGeneric)
 	e.GET("/messages/:conversation", server.WebGeneric)
 
diff --git a/package.json b/package.json
index 357879048..cd7c48215 100644
--- a/package.json
+++ b/package.json
@@ -69,7 +69,7 @@
     "icons:optimize": "svgo -f ./assets/icons"
   },
   "dependencies": {
-    "@atproto/api": "^0.15.21",
+    "@atproto/api": "^0.15.26",
     "@bitdrift/react-native": "^0.6.8",
     "@braintree/sanitize-url": "^6.0.2",
     "@discord/bottom-sheet": "bluesky-social/react-native-bottom-sheet",
@@ -218,7 +218,7 @@
     "zod": "^3.20.2"
   },
   "devDependencies": {
-    "@atproto/dev-env": "^0.3.150",
+    "@atproto/dev-env": "^0.3.155",
     "@babel/core": "^7.26.0",
     "@babel/preset-env": "^7.26.0",
     "@babel/runtime": "^7.26.0",
diff --git a/src/App.native.tsx b/src/App.native.tsx
index 2278b73de..87429d845 100644
--- a/src/App.native.tsx
+++ b/src/App.native.tsx
@@ -26,6 +26,7 @@ import I18nProvider from '#/locale/i18nProvider'
 import {logger} from '#/logger'
 import {isAndroid, isIOS} from '#/platform/detection'
 import {Provider as A11yProvider} from '#/state/a11y'
+import {Provider as AgeAssuranceProvider} from '#/state/ageAssurance'
 import {Provider as MutedThreadsProvider} from '#/state/cache/thread-mutes'
 import {Provider as DialogStateProvider} from '#/state/dialogs'
 import {listenSessionDropped} from '#/state/events'
@@ -95,7 +96,6 @@ function InnerApp() {
   const {resumeSession} = useSessionApi()
   const theme = useColorModeTheme()
   const {_} = useLingui()
-
   const hasCheckedReferrer = useStarterPackEntry()
 
   // init
@@ -137,47 +137,49 @@ function InnerApp() {
                   // Resets the entire tree below when it changes:
                   key={currentAccount?.did}>
                   <QueryProvider currentDid={currentAccount?.did}>
-                    <ComposerProvider>
-                      <StatsigProvider>
-                        <MessagesProvider>
-                          {/* LabelDefsProvider MUST come before ModerationOptsProvider */}
-                          <LabelDefsProvider>
-                            <ModerationOptsProvider>
-                              <LoggedOutViewProvider>
-                                <SelectedFeedProvider>
-                                  <HiddenRepliesProvider>
-                                    <HomeBadgeProvider>
-                                      <UnreadNotifsProvider>
-                                        <BackgroundNotificationPreferencesProvider>
-                                          <MutedThreadsProvider>
-                                            <ProgressGuideProvider>
-                                              <ServiceAccountManager>
-                                                <HideBottomBarBorderProvider>
-                                                  <GestureHandlerRootView
-                                                    style={s.h100pct}>
-                                                    <GlobalGestureEventsProvider>
-                                                      <IntentDialogProvider>
-                                                        <TestCtrls />
-                                                        <Shell />
-                                                        <NuxDialogs />
-                                                      </IntentDialogProvider>
-                                                    </GlobalGestureEventsProvider>
-                                                  </GestureHandlerRootView>
-                                                </HideBottomBarBorderProvider>
-                                              </ServiceAccountManager>
-                                            </ProgressGuideProvider>
-                                          </MutedThreadsProvider>
-                                        </BackgroundNotificationPreferencesProvider>
-                                      </UnreadNotifsProvider>
-                                    </HomeBadgeProvider>
-                                  </HiddenRepliesProvider>
-                                </SelectedFeedProvider>
-                              </LoggedOutViewProvider>
-                            </ModerationOptsProvider>
-                          </LabelDefsProvider>
-                        </MessagesProvider>
-                      </StatsigProvider>
-                    </ComposerProvider>
+                    <StatsigProvider>
+                      <AgeAssuranceProvider>
+                        <ComposerProvider>
+                          <MessagesProvider>
+                            {/* LabelDefsProvider MUST come before ModerationOptsProvider */}
+                            <LabelDefsProvider>
+                              <ModerationOptsProvider>
+                                <LoggedOutViewProvider>
+                                  <SelectedFeedProvider>
+                                    <HiddenRepliesProvider>
+                                      <HomeBadgeProvider>
+                                        <UnreadNotifsProvider>
+                                          <BackgroundNotificationPreferencesProvider>
+                                            <MutedThreadsProvider>
+                                              <ProgressGuideProvider>
+                                                <ServiceAccountManager>
+                                                  <HideBottomBarBorderProvider>
+                                                    <GestureHandlerRootView
+                                                      style={s.h100pct}>
+                                                      <GlobalGestureEventsProvider>
+                                                        <IntentDialogProvider>
+                                                          <TestCtrls />
+                                                          <Shell />
+                                                          <NuxDialogs />
+                                                        </IntentDialogProvider>
+                                                      </GlobalGestureEventsProvider>
+                                                    </GestureHandlerRootView>
+                                                  </HideBottomBarBorderProvider>
+                                                </ServiceAccountManager>
+                                              </ProgressGuideProvider>
+                                            </MutedThreadsProvider>
+                                          </BackgroundNotificationPreferencesProvider>
+                                        </UnreadNotifsProvider>
+                                      </HomeBadgeProvider>
+                                    </HiddenRepliesProvider>
+                                  </SelectedFeedProvider>
+                                </LoggedOutViewProvider>
+                              </ModerationOptsProvider>
+                            </LabelDefsProvider>
+                          </MessagesProvider>
+                        </ComposerProvider>
+                      </AgeAssuranceProvider>
+                    </StatsigProvider>
                   </QueryProvider>
                 </React.Fragment>
               </VideoVolumeProvider>
diff --git a/src/App.web.tsx b/src/App.web.tsx
index b706774fd..04de8529f 100644
--- a/src/App.web.tsx
+++ b/src/App.web.tsx
@@ -15,6 +15,7 @@ import {ThemeProvider} from '#/lib/ThemeContext'
 import I18nProvider from '#/locale/i18nProvider'
 import {logger} from '#/logger'
 import {Provider as A11yProvider} from '#/state/a11y'
+import {Provider as AgeAssuranceProvider} from '#/state/ageAssurance'
 import {Provider as MutedThreadsProvider} from '#/state/cache/thread-mutes'
 import {Provider as DialogStateProvider} from '#/state/dialogs'
 import {listenSessionDropped} from '#/state/events'
@@ -116,43 +117,45 @@ function InnerApp() {
                   // Resets the entire tree below when it changes:
                   key={currentAccount?.did}>
                   <QueryProvider currentDid={currentAccount?.did}>
-                    <ComposerProvider>
-                      <StatsigProvider>
-                        <MessagesProvider>
-                          {/* LabelDefsProvider MUST come before ModerationOptsProvider */}
-                          <LabelDefsProvider>
-                            <ModerationOptsProvider>
-                              <LoggedOutViewProvider>
-                                <SelectedFeedProvider>
-                                  <HiddenRepliesProvider>
-                                    <HomeBadgeProvider>
-                                      <UnreadNotifsProvider>
-                                        <BackgroundNotificationPreferencesProvider>
-                                          <MutedThreadsProvider>
-                                            <SafeAreaProvider>
-                                              <ProgressGuideProvider>
-                                                <ServiceConfigProvider>
-                                                  <HideBottomBarBorderProvider>
-                                                    <IntentDialogProvider>
-                                                      <Shell />
-                                                      <NuxDialogs />
-                                                    </IntentDialogProvider>
-                                                  </HideBottomBarBorderProvider>
-                                                </ServiceConfigProvider>
-                                              </ProgressGuideProvider>
-                                            </SafeAreaProvider>
-                                          </MutedThreadsProvider>
-                                        </BackgroundNotificationPreferencesProvider>
-                                      </UnreadNotifsProvider>
-                                    </HomeBadgeProvider>
-                                  </HiddenRepliesProvider>
-                                </SelectedFeedProvider>
-                              </LoggedOutViewProvider>
-                            </ModerationOptsProvider>
-                          </LabelDefsProvider>
-                        </MessagesProvider>
-                      </StatsigProvider>
-                    </ComposerProvider>
+                    <StatsigProvider>
+                      <AgeAssuranceProvider>
+                        <ComposerProvider>
+                          <MessagesProvider>
+                            {/* LabelDefsProvider MUST come before ModerationOptsProvider */}
+                            <LabelDefsProvider>
+                              <ModerationOptsProvider>
+                                <LoggedOutViewProvider>
+                                  <SelectedFeedProvider>
+                                    <HiddenRepliesProvider>
+                                      <HomeBadgeProvider>
+                                        <UnreadNotifsProvider>
+                                          <BackgroundNotificationPreferencesProvider>
+                                            <MutedThreadsProvider>
+                                              <SafeAreaProvider>
+                                                <ProgressGuideProvider>
+                                                  <ServiceConfigProvider>
+                                                    <HideBottomBarBorderProvider>
+                                                      <IntentDialogProvider>
+                                                        <Shell />
+                                                        <NuxDialogs />
+                                                      </IntentDialogProvider>
+                                                    </HideBottomBarBorderProvider>
+                                                  </ServiceConfigProvider>
+                                                </ProgressGuideProvider>
+                                              </SafeAreaProvider>
+                                            </MutedThreadsProvider>
+                                          </BackgroundNotificationPreferencesProvider>
+                                        </UnreadNotifsProvider>
+                                      </HomeBadgeProvider>
+                                    </HiddenRepliesProvider>
+                                  </SelectedFeedProvider>
+                                </LoggedOutViewProvider>
+                              </ModerationOptsProvider>
+                            </LabelDefsProvider>
+                          </MessagesProvider>
+                        </ComposerProvider>
+                      </AgeAssuranceProvider>
+                    </StatsigProvider>
                   </QueryProvider>
                   <ToastContainer />
                 </React.Fragment>
diff --git a/src/components/LanguageSelect.tsx b/src/components/LanguageSelect.tsx
new file mode 100644
index 000000000..2ad3949ae
--- /dev/null
+++ b/src/components/LanguageSelect.tsx
@@ -0,0 +1,50 @@
+import React from 'react'
+import {msg} from '@lingui/macro'
+import {useLingui} from '@lingui/react'
+
+import {sanitizeAppLanguageSetting} from '#/locale/helpers'
+import {APP_LANGUAGES} from '#/locale/languages'
+import * as Select from '#/components/Select'
+
+export function LanguageSelect({
+  value,
+  onChange,
+  items = APP_LANGUAGES.map(l => ({
+    label: l.name,
+    value: l.code2,
+  })),
+}: {
+  value?: string
+  onChange: (value: string) => void
+  items?: {label: string; value: string}[]
+}) {
+  const {_} = useLingui()
+
+  const handleOnChange = React.useCallback(
+    (value: string) => {
+      if (!value) return
+      onChange(sanitizeAppLanguageSetting(value))
+    },
+    [onChange],
+  )
+
+  return (
+    <Select.Root
+      value={value ? sanitizeAppLanguageSetting(value) : undefined}
+      onValueChange={handleOnChange}>
+      <Select.Trigger label={_(msg`Select language`)}>
+        <Select.ValueText placeholder={_(msg`Select language`)} />
+        <Select.Icon />
+      </Select.Trigger>
+      <Select.Content
+        renderItem={({label, value}) => (
+          <Select.Item value={value} label={label}>
+            <Select.ItemIndicator />
+            <Select.ItemText>{label}</Select.ItemText>
+          </Select.Item>
+        )}
+        items={items}
+      />
+    </Select.Root>
+  )
+}
diff --git a/src/components/PostControls/ShareMenu/ShareMenuItems.tsx b/src/components/PostControls/ShareMenu/ShareMenuItems.tsx
index 1c04f3174..03b113708 100644
--- a/src/components/PostControls/ShareMenu/ShareMenuItems.tsx
+++ b/src/components/PostControls/ShareMenu/ShareMenuItems.tsx
@@ -11,6 +11,7 @@ import {shareText, shareUrl} from '#/lib/sharing'
 import {toShareUrl} from '#/lib/strings/url-helpers'
 import {logger} from '#/logger'
 import {isIOS} from '#/platform/detection'
+import {useAgeAssurance} from '#/state/ageAssurance/useAgeAssurance'
 import {useProfileShadow} from '#/state/cache/profile-shadow'
 import {useSession} from '#/state/session'
 import * as Toast from '#/view/com/util/Toast'
@@ -36,6 +37,7 @@ let ShareMenuItems = ({
   const navigation = useNavigation<NavigationProp>()
   const sendViaChatControl = useDialogControl()
   const [devModeEnabled] = useDevMode()
+  const {isAgeRestricted} = useAgeAssurance()
 
   const postUri = post.uri
   const postAuthor = useProfileShadow(post.author)
@@ -89,7 +91,7 @@ let ShareMenuItems = ({
   return (
     <>
       <Menu.Outer>
-        {hasSession && (
+        {hasSession && !isAgeRestricted && (
           <Menu.Group>
             <Menu.ContainerItem>
               <RecentChats postUri={postUri} />
diff --git a/src/components/PostControls/ShareMenu/ShareMenuItems.web.tsx b/src/components/PostControls/ShareMenu/ShareMenuItems.web.tsx
index 8d52a2fdf..d074cdcf0 100644
--- a/src/components/PostControls/ShareMenu/ShareMenuItems.web.tsx
+++ b/src/components/PostControls/ShareMenu/ShareMenuItems.web.tsx
@@ -11,6 +11,7 @@ import {shareText, shareUrl} from '#/lib/sharing'
 import {toShareUrl} from '#/lib/strings/url-helpers'
 import {logger} from '#/logger'
 import {isWeb} from '#/platform/detection'
+import {useAgeAssurance} from '#/state/ageAssurance/useAgeAssurance'
 import {useProfileShadow} from '#/state/cache/profile-shadow'
 import {useSession} from '#/state/session'
 import {useBreakpoints} from '#/alf'
@@ -38,6 +39,7 @@ let ShareMenuItems = ({
   const embedPostControl = useDialogControl()
   const sendViaChatControl = useDialogControl()
   const [devModeEnabled] = useDevMode()
+  const {isAgeRestricted} = useAgeAssurance()
 
   const postUri = post.uri
   const postCid = post.cid
@@ -96,7 +98,7 @@ let ShareMenuItems = ({
       <Menu.Outer>
         {!hideInPWI && copyLinkItem}
 
-        {hasSession && (
+        {hasSession && !isAgeRestricted && (
           <Menu.Item
             testID="postDropdownSendViaDMBtn"
             label={_(msg`Send via direct message`)}
diff --git a/src/components/ageAssurance/AgeAssuranceAccountCard.tsx b/src/components/ageAssurance/AgeAssuranceAccountCard.tsx
new file mode 100644
index 000000000..530e43d44
--- /dev/null
+++ b/src/components/ageAssurance/AgeAssuranceAccountCard.tsx
@@ -0,0 +1,148 @@
+import {View} from 'react-native'
+import {msg, Trans} from '@lingui/macro'
+import {useLingui} from '@lingui/react'
+
+import {dateDiff, useGetTimeAgo} from '#/lib/hooks/useTimeAgo'
+import {useAgeAssurance} from '#/state/ageAssurance/useAgeAssurance'
+import {atoms as a, useBreakpoints, useTheme, type ViewStyleProp} from '#/alf'
+import {Admonition} from '#/components/Admonition'
+import {AgeAssuranceAppealDialog} from '#/components/ageAssurance/AgeAssuranceAppealDialog'
+import {AgeAssuranceBadge} from '#/components/ageAssurance/AgeAssuranceBadge'
+import {
+  AgeAssuranceInitDialog,
+  useDialogControl,
+} from '#/components/ageAssurance/AgeAssuranceInitDialog'
+import {useAgeAssuranceCopy} from '#/components/ageAssurance/useAgeAssuranceCopy'
+import {Button, ButtonText} from '#/components/Button'
+import * as Dialog from '#/components/Dialog'
+import {Divider} from '#/components/Divider'
+import {createStaticClick, InlineLinkText} from '#/components/Link'
+import {Text} from '#/components/Typography'
+
+export function AgeAssuranceAccountCard({style}: ViewStyleProp & {}) {
+  const {isReady, isAgeRestricted, isDeclaredUnderage} = useAgeAssurance()
+
+  if (!isReady) return null
+  if (isDeclaredUnderage) return null
+  if (!isAgeRestricted) return null
+
+  return <Inner style={style} />
+}
+
+function Inner({style}: ViewStyleProp & {}) {
+  const t = useTheme()
+  const {_, i18n} = useLingui()
+  const control = useDialogControl()
+  const appealControl = Dialog.useDialogControl()
+  const getTimeAgo = useGetTimeAgo()
+  const {gtPhone} = useBreakpoints()
+
+  const copy = useAgeAssuranceCopy()
+  const {status, lastInitiatedAt} = useAgeAssurance()
+  const isBlocked = status === 'blocked'
+  const hasInitiated = !!lastInitiatedAt
+  const timeAgo = lastInitiatedAt
+    ? getTimeAgo(lastInitiatedAt, new Date())
+    : null
+  const diff = lastInitiatedAt
+    ? dateDiff(lastInitiatedAt, new Date(), 'down')
+    : null
+
+  return (
+    <>
+      <AgeAssuranceInitDialog control={control} />
+      <AgeAssuranceAppealDialog control={appealControl} />
+
+      <View style={style}>
+        <View
+          style={[a.p_lg, a.rounded_md, a.border, t.atoms.border_contrast_low]}>
+          <View
+            style={[
+              a.flex_row,
+              a.justify_between,
+              a.align_center,
+              a.gap_lg,
+              a.pb_md,
+              a.z_10,
+            ]}>
+            <View style={[a.align_start]}>
+              <AgeAssuranceBadge />
+            </View>
+          </View>
+
+          <View style={[a.pb_md]}>
+            <Text style={[a.text_sm, a.leading_snug]}>{copy.notice}</Text>
+          </View>
+
+          {isBlocked ? (
+            <Admonition type="warning">
+              <Trans>
+                You are currently unable to access Bluesky's Age Assurance flow.
+                Please{' '}
+                <InlineLinkText
+                  label={_(msg`Contact our moderation team`)}
+                  {...createStaticClick(() => {
+                    appealControl.open()
+                  })}>
+                  contact our moderation team
+                </InlineLinkText>{' '}
+                if you believe this is an error.
+              </Trans>
+            </Admonition>
+          ) : (
+            <>
+              <Divider />
+              <View
+                style={[
+                  a.pt_md,
+                  gtPhone
+                    ? [
+                        a.flex_row_reverse,
+                        a.gap_xl,
+                        a.justify_between,
+                        a.align_center,
+                      ]
+                    : [a.gap_md],
+                ]}>
+                <Button
+                  label={_(msg`Verify now`)}
+                  size="small"
+                  variant="solid"
+                  color={hasInitiated ? 'secondary' : 'primary'}
+                  onPress={() => control.open()}>
+                  <ButtonText>
+                    {hasInitiated ? (
+                      <Trans>Verify again</Trans>
+                    ) : (
+                      <Trans>Verify now</Trans>
+                    )}
+                  </ButtonText>
+                </Button>
+
+                {lastInitiatedAt && timeAgo && diff ? (
+                  <Text
+                    style={[a.text_sm, a.italic, t.atoms.text_contrast_medium]}
+                    title={i18n.date(lastInitiatedAt, {
+                      dateStyle: 'medium',
+                      timeStyle: 'medium',
+                    })}>
+                    {diff.value === 0 ? (
+                      <Trans>Last initiated just now</Trans>
+                    ) : (
+                      <Trans>Last initiated {timeAgo} ago</Trans>
+                    )}
+                  </Text>
+                ) : (
+                  <Text
+                    style={[a.text_sm, a.italic, t.atoms.text_contrast_medium]}>
+                    <Trans>Age assurance only takes a few minutes</Trans>
+                  </Text>
+                )}
+              </View>
+            </>
+          )}
+        </View>
+      </View>
+    </>
+  )
+}
diff --git a/src/components/ageAssurance/AgeAssuranceAdmonition.tsx b/src/components/ageAssurance/AgeAssuranceAdmonition.tsx
new file mode 100644
index 000000000..d140b7873
--- /dev/null
+++ b/src/components/ageAssurance/AgeAssuranceAdmonition.tsx
@@ -0,0 +1,100 @@
+import {View} from 'react-native'
+import {msg, Trans} from '@lingui/macro'
+import {useLingui} from '@lingui/react'
+
+import {useAgeAssurance} from '#/state/ageAssurance/useAgeAssurance'
+import {atoms as a, select, useTheme, type ViewStyleProp} from '#/alf'
+import {useDialogControl} from '#/components/ageAssurance/AgeAssuranceInitDialog'
+import type * as Dialog from '#/components/Dialog'
+import {ShieldCheck_Stroke2_Corner0_Rounded as Shield} from '#/components/icons/Shield'
+import {InlineLinkText} from '#/components/Link'
+import {Text} from '#/components/Typography'
+
+export function AgeAssuranceAdmonition({
+  children,
+  style,
+}: ViewStyleProp & {children: React.ReactNode}) {
+  const control = useDialogControl()
+  const {isReady, isDeclaredUnderage, isAgeRestricted} = useAgeAssurance()
+
+  if (!isReady) return null
+  if (isDeclaredUnderage) return null
+  if (!isAgeRestricted) return null
+
+  return (
+    <Inner style={style} control={control}>
+      {children}
+    </Inner>
+  )
+}
+
+function Inner({
+  children,
+  style,
+}: ViewStyleProp & {
+  children: React.ReactNode
+  control: Dialog.DialogControlProps
+}) {
+  const t = useTheme()
+  const {_} = useLingui()
+
+  return (
+    <>
+      <View style={style}>
+        <View
+          style={[
+            a.p_md,
+            a.rounded_md,
+            a.border,
+            a.flex_row,
+            a.align_start,
+            a.gap_sm,
+            {
+              backgroundColor: select(t.name, {
+                light: t.palette.primary_25,
+                dark: t.palette.primary_25,
+                dim: t.palette.primary_25,
+              }),
+              borderColor: select(t.name, {
+                light: t.palette.primary_100,
+                dark: t.palette.primary_100,
+                dim: t.palette.primary_100,
+              }),
+            },
+          ]}>
+          <View
+            style={[
+              a.align_center,
+              a.justify_center,
+              a.rounded_full,
+              {
+                width: 32,
+                height: 32,
+                backgroundColor: select(t.name, {
+                  light: t.palette.primary_100,
+                  dark: t.palette.primary_100,
+                  dim: t.palette.primary_100,
+                }),
+              },
+            ]}>
+            <Shield size="md" />
+          </View>
+          <View style={[a.flex_1, a.gap_xs, a.pr_2xl]}>
+            <Text style={[a.text_sm, a.leading_snug]}>{children}</Text>
+            <Text style={[a.text_sm, a.leading_snug, a.font_bold]}>
+              <Trans>
+                Learn more in your{' '}
+                <InlineLinkText
+                  label={_(msg`Go to account settings`)}
+                  to={'/settings/account'}
+                  style={[a.text_sm, a.leading_snug, a.font_bold]}>
+                  account settings.
+                </InlineLinkText>
+              </Trans>
+            </Text>
+          </View>
+        </View>
+      </View>
+    </>
+  )
+}
diff --git a/src/components/ageAssurance/AgeAssuranceAppealDialog.tsx b/src/components/ageAssurance/AgeAssuranceAppealDialog.tsx
new file mode 100644
index 000000000..166f6c26d
--- /dev/null
+++ b/src/components/ageAssurance/AgeAssuranceAppealDialog.tsx
@@ -0,0 +1,140 @@
+import React from 'react'
+import {View} from 'react-native'
+import {BSKY_LABELER_DID, ComAtprotoModerationDefs} from '@atproto/api'
+import {msg, Trans} from '@lingui/macro'
+import {useLingui} from '@lingui/react'
+import {useMutation} from '@tanstack/react-query'
+
+import {logger} from '#/logger'
+import {useAgent, useSession} from '#/state/session'
+import * as Toast from '#/view/com/util/Toast'
+import {atoms as a, useBreakpoints, web} from '#/alf'
+import {AgeAssuranceBadge} from '#/components/ageAssurance/AgeAssuranceBadge'
+import {Button, ButtonIcon, ButtonText} from '#/components/Button'
+import * as Dialog from '#/components/Dialog'
+import {Loader} from '#/components/Loader'
+import {Text} from '#/components/Typography'
+
+export function AgeAssuranceAppealDialog({
+  control,
+}: {
+  control: Dialog.DialogControlProps
+}) {
+  const {_} = useLingui()
+  return (
+    <Dialog.Outer control={control}>
+      <Dialog.Handle />
+      <Dialog.ScrollableInner
+        label={_(msg`Contact our moderation team`)}
+        style={[web({maxWidth: 400})]}>
+        <Inner control={control} />
+        <Dialog.Close />
+      </Dialog.ScrollableInner>
+    </Dialog.Outer>
+  )
+}
+
+function Inner({control}: {control: Dialog.DialogControlProps}) {
+  const {_} = useLingui()
+  const {currentAccount} = useSession()
+  const {gtPhone} = useBreakpoints()
+  const agent = useAgent()
+
+  const [details, setDetails] = React.useState('')
+  const isInvalid = details.length > 1000
+
+  const {mutate, isPending} = useMutation({
+    mutationFn: async () => {
+      await agent.createModerationReport(
+        {
+          reasonType: ComAtprotoModerationDefs.REASONAPPEAL,
+          subject: {
+            $type: 'com.atproto.admin.defs#repoRef',
+            did: currentAccount?.did,
+          },
+          reason: `AGE_ASSURANCE_INQUIRY: ` + details,
+        },
+        {
+          encoding: 'application/json',
+          headers: {
+            'atproto-proxy': `${BSKY_LABELER_DID}#atproto_labeler`,
+          },
+        },
+      )
+    },
+    onError: err => {
+      logger.error('AgeAssuranceAppealDialog failed', {safeMessage: err})
+      Toast.show(
+        _(msg`Age assurance inquiry failed to send, please try again.`),
+        'xmark',
+      )
+    },
+    onSuccess: () => {
+      control.close()
+      Toast.show(
+        _(
+          msg({
+            message: 'Age assurance inquiry was submitted',
+            context: 'toast',
+          }),
+        ),
+      )
+    },
+  })
+
+  return (
+    <View>
+      <View style={[a.align_start]}>
+        <AgeAssuranceBadge />
+      </View>
+
+      <Text style={[a.text_2xl, a.font_heavy, a.pt_md, a.leading_tight]}>
+        <Trans>Contact us</Trans>
+      </Text>
+
+      <Text style={[a.text_sm, a.pt_sm, a.leading_snug]}>
+        <Trans>
+          Please provide any additional details you feel moderators may need in
+          order to properly assess your Age Assurance status.
+        </Trans>
+      </Text>
+
+      <View style={[a.pt_md]}>
+        <Dialog.Input
+          multiline
+          isInvalid={isInvalid}
+          value={details}
+          onChangeText={details => {
+            setDetails(details)
+          }}
+          label={_(msg`Additional details (limit 1000 characters)`)}
+          numberOfLines={4}
+          onSubmitEditing={() => mutate()}
+        />
+        <View style={[a.pt_md, a.gap_sm, gtPhone && [a.flex_row_reverse]]}>
+          <Button
+            label={_(msg`Submit`)}
+            size="small"
+            variant="solid"
+            color="primary"
+            onPress={() => mutate()}>
+            <ButtonText>
+              <Trans>Submit</Trans>
+            </ButtonText>
+            {isPending && <ButtonIcon icon={Loader} position="right" />}
+          </Button>
+          <Button
+            label={_(msg`Cancel`)}
+            size="small"
+            variant="solid"
+            color="secondary"
+            onPress={() => control.close()}>
+            <ButtonText>
+              <Trans>Cancel</Trans>
+            </ButtonText>
+          </Button>
+        </View>
+      </View>
+    </View>
+  )
+}
diff --git a/src/components/ageAssurance/AgeAssuranceBadge.tsx b/src/components/ageAssurance/AgeAssuranceBadge.tsx
new file mode 100644
index 000000000..030e30529
--- /dev/null
+++ b/src/components/ageAssurance/AgeAssuranceBadge.tsx
@@ -0,0 +1,46 @@
+import {View} from 'react-native'
+import {Trans} from '@lingui/macro'
+
+import {atoms as a, select, useTheme} from '#/alf'
+import {ShieldCheck_Stroke2_Corner0_Rounded as Shield} from '#/components/icons/Shield'
+import {Text} from '#/components/Typography'
+
+export function AgeAssuranceBadge() {
+  const t = useTheme()
+
+  return (
+    <View
+      style={[
+        a.flex_row,
+        a.align_center,
+        a.gap_xs,
+        a.px_sm,
+        a.py_xs,
+        a.pr_sm,
+        a.rounded_full,
+        {
+          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>
+  )
+}
diff --git a/src/components/ageAssurance/AgeAssuranceDismissibleHeaderButton.tsx b/src/components/ageAssurance/AgeAssuranceDismissibleHeaderButton.tsx
new file mode 100644
index 000000000..b6505fb0e
--- /dev/null
+++ b/src/components/ageAssurance/AgeAssuranceDismissibleHeaderButton.tsx
@@ -0,0 +1,95 @@
+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
new file mode 100644
index 000000000..30e2fbec4
--- /dev/null
+++ b/src/components/ageAssurance/AgeAssuranceDismissibleNotice.tsx
@@ -0,0 +1,59 @@
+import {View} from 'react-native'
+import {msg} 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, type ViewStyleProp} from '#/alf'
+import {AgeAssuranceAdmonition} from '#/components/ageAssurance/AgeAssuranceAdmonition'
+import {useAgeAssuranceCopy} from '#/components/ageAssurance/useAgeAssuranceCopy'
+import {Button, ButtonIcon} from '#/components/Button'
+import {TimesLarge_Stroke2_Corner0_Rounded as X} from '#/components/icons/Times'
+
+export function AgeAssuranceDismissibleNotice({style}: ViewStyleProp & {}) {
+  const {_} = useLingui()
+  const {isReady, isDeclaredUnderage, isAgeRestricted, lastInitiatedAt} =
+    useAgeAssurance()
+  const {nux} = useNux(Nux.AgeAssuranceDismissibleNotice)
+  const copy = useAgeAssuranceCopy()
+  const {mutate: save, variables} = useSaveNux()
+  const hidden = !!variables
+
+  if (!isReady) return null
+  if (isDeclaredUnderage) return null
+  if (!isAgeRestricted) return null
+  if (lastInitiatedAt) return null
+  if (hidden) return null
+  if (nux && nux.completed) return null
+
+  return (
+    <View style={style}>
+      <View>
+        <AgeAssuranceAdmonition>{copy.notice}</AgeAssuranceAdmonition>
+
+        <Button
+          label={_(msg`Don't show again`)}
+          size="tiny"
+          variant="solid"
+          color="secondary_inverted"
+          shape="round"
+          onPress={() =>
+            save({
+              id: Nux.AgeAssuranceDismissibleNotice,
+              completed: true,
+              data: undefined,
+            })
+          }
+          style={[
+            a.absolute,
+            {
+              top: 12,
+              right: 12,
+            },
+          ]}>
+          <ButtonIcon icon={X} />
+        </Button>
+      </View>
+    </View>
+  )
+}
diff --git a/src/components/ageAssurance/AgeAssuranceInitDialog.tsx b/src/components/ageAssurance/AgeAssuranceInitDialog.tsx
new file mode 100644
index 000000000..ad13cc1c2
--- /dev/null
+++ b/src/components/ageAssurance/AgeAssuranceInitDialog.tsx
@@ -0,0 +1,351 @@
+import {useState} from 'react'
+import {View} from 'react-native'
+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 {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 {useLanguagePrefs} from '#/state/preferences'
+import {useSession} from '#/state/session'
+import {atoms as a, useTheme, web} from '#/alf'
+import {Admonition} from '#/components/Admonition'
+import {AgeAssuranceBadge} from '#/components/ageAssurance/AgeAssuranceBadge'
+import {urls} from '#/components/ageAssurance/const'
+import {KWS_SUPPORTED_LANGS} from '#/components/ageAssurance/const'
+import {Button, ButtonIcon, ButtonText} from '#/components/Button'
+import * as Dialog from '#/components/Dialog'
+import {Divider} from '#/components/Divider'
+import * as TextField from '#/components/forms/TextField'
+import {ShieldCheck_Stroke2_Corner0_Rounded as Shield} from '#/components/icons/Shield'
+import {LanguageSelect} from '#/components/LanguageSelect'
+import {InlineLinkText} from '#/components/Link'
+import {Loader} from '#/components/Loader'
+import {Text} from '#/components/Typography'
+
+export {useDialogControl} from '#/components/Dialog/context'
+
+export function AgeAssuranceInitDialog({
+  control,
+}: {
+  control: Dialog.DialogControlProps
+}) {
+  const {_} = useLingui()
+  return (
+    <Dialog.Outer control={control}>
+      <Dialog.Handle />
+
+      <Dialog.ScrollableInner
+        label={_(
+          msg`Begin the age assurance process by completing the fields below.`,
+        )}
+        style={[
+          web({
+            maxWidth: 400,
+          }),
+        ]}>
+        <Inner />
+        <Dialog.Close />
+      </Dialog.ScrollableInner>
+    </Dialog.Outer>
+  )
+}
+
+function Inner() {
+  const t = useTheme()
+  const {_} = useLingui()
+  const {currentAccount} = useSession()
+  const langPrefs = useLanguagePrefs()
+  const cleanError = useCleanError()
+  const {close} = Dialog.useDialogContext()
+  const {lastInitiatedAt} = useAgeAssuranceContext()
+  const getTimeAgo = useGetTimeAgo()
+  const tlds = useTLDs()
+
+  const wasRecentlyInitiated =
+    lastInitiatedAt &&
+    new Date(lastInitiatedAt).getTime() > Date.now() - 5 * 60 * 1000 // 5 minutes
+
+  const [success, setSuccess] = useState(false)
+  const [email, setEmail] = useState(currentAccount?.email || '')
+  const [emailError, setEmailError] = useState<string>('')
+  const [languageError, setLanguageError] = useState(false)
+  const [disabled, setDisabled] = useState(false)
+  const [language, setLanguage] = useState<string | undefined>(
+    convertToKWSSupportedLanguage(langPrefs.appLanguage),
+  )
+  const [error, setError] = useState<string>('')
+
+  const {mutateAsync: init, isPending} = useInitAgeAssurance()
+
+  const runEmailValidation = () => {
+    if (validateEmail(email)) {
+      setEmailError('')
+      setDisabled(false)
+
+      if (tlds && isEmailMaybeInvalid(email, tlds)) {
+        setEmailError(
+          _(
+            msg`Please double-check that you have entered your email address correctly.`,
+          ),
+        )
+        return {status: 'maybe'}
+      }
+
+      return {status: 'valid'}
+    }
+
+    setEmailError(_(msg`Please enter a valid email address.`))
+    setDisabled(true)
+
+    return {status: 'invalid'}
+  }
+
+  const onSubmit = async () => {
+    setLanguageError(false)
+
+    try {
+      const {status} = runEmailValidation()
+
+      if (status === 'invalid') return
+      if (!language) {
+        setLanguageError(true)
+        return
+      }
+
+      await init({
+        email,
+        language,
+      })
+
+      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 = _(
+              msg`Please enter a valid, non-temporary email address. You may need to access this email in the future.`,
+            )
+          }
+        }
+
+        setError(message)
+      }
+    }
+  }
+
+  return (
+    <View>
+      <View style={[a.align_start]}>
+        <AgeAssuranceBadge />
+
+        <Text style={[a.text_xl, a.font_heavy, a.pt_xl, a.pb_md]}>
+          {success ? <Trans>Success!</Trans> : <Trans>Verify your age</Trans>}
+        </Text>
+
+        <View style={[a.pb_xl, a.gap_sm]}>
+          {success ? (
+            <Text style={[a.text_sm, a.leading_snug]}>
+              <Trans>
+                Please check your email inbox for further instructions. It may
+                take a minute or two to arrive.
+              </Trans>
+            </Text>
+          ) : (
+            <>
+              <Text style={[a.text_sm, a.leading_snug]}>
+                <Trans>
+                  We use{' '}
+                  <InlineLinkText
+                    overridePresentation
+                    disableMismatchWarning
+                    label={_(msg`KWS website`)}
+                    to={urls.kwsHome}
+                    style={[a.text_sm, a.leading_snug]}>
+                    KWS
+                  </InlineLinkText>{' '}
+                  to verify that you’re an adult. When you click "Begin" below,
+                  KWS will email you instructions for verifying your age. When
+                  you’re done, you'll be brought back to continue using Bluesky.
+                </Trans>
+              </Text>
+              <Text style={[a.text_sm, a.leading_snug]}>
+                <Trans>This should only take a few minutes.</Trans>
+              </Text>
+            </>
+          )}
+        </View>
+
+        {success ? (
+          <View style={[a.w_full]}>
+            <Button
+              label={_(msg`Close dialog`)}
+              size="large"
+              variant="solid"
+              color="secondary"
+              onPress={() => close()}>
+              <ButtonText>
+                <Trans>Close dialog</Trans>
+              </ButtonText>
+            </Button>
+          </View>
+        ) : (
+          <>
+            <Divider />
+
+            <View style={[a.w_full, a.pt_xl, a.gap_lg, a.pb_lg]}>
+              {wasRecentlyInitiated && (
+                <Admonition type="warning">
+                  <Trans>
+                    You initiated this flow already,{' '}
+                    {getTimeAgo(lastInitiatedAt, new Date(), {format: 'long'})}{' '}
+                    ago. It may take up to 5 minutes for emails to reach your
+                    inbox. Please consider waiting a few minutes before trying
+                    again.
+                  </Trans>
+                </Admonition>
+              )}
+
+              <View>
+                <TextField.LabelText>
+                  <Trans>Your email</Trans>
+                </TextField.LabelText>
+                <TextField.Root isInvalid={!!emailError}>
+                  <TextField.Input
+                    label={_(msg`Your email`)}
+                    placeholder={_(msg`Your email`)}
+                    value={email}
+                    onChangeText={setEmail}
+                    onFocus={() => setEmailError('')}
+                    onBlur={() => {
+                      runEmailValidation()
+                    }}
+                    returnKeyType="done"
+                    autoCapitalize="none"
+                    autoComplete="off"
+                    autoCorrect={false}
+                    onSubmitEditing={onSubmit}
+                  />
+                </TextField.Root>
+
+                {emailError ? (
+                  <Admonition type="error" style={[a.mt_sm]}>
+                    {emailError}
+                  </Admonition>
+                ) : (
+                  <Admonition type="tip" style={[a.mt_sm]}>
+                    <Trans>
+                      Use your account email address, or another real email
+                      address you control, in case KWS or Bluesky needs to
+                      contact you.
+                    </Trans>
+                  </Admonition>
+                )}
+              </View>
+
+              <View>
+                <TextField.LabelText>
+                  <Trans>Your preferred language</Trans>
+                </TextField.LabelText>
+                <LanguageSelect
+                  value={language}
+                  onChange={value => {
+                    setLanguage(value)
+                    setLanguageError(false)
+                  }}
+                  items={KWS_SUPPORTED_LANGS}
+                />
+
+                {languageError && (
+                  <Admonition type="error" style={[a.mt_sm]}>
+                    <Trans>Please select a language</Trans>
+                  </Admonition>
+                )}
+              </View>
+
+              {error && <Admonition type="error">{error}</Admonition>}
+
+              <Button
+                disabled={disabled}
+                label={_(msg`Begin age assurance process`)}
+                size="large"
+                variant="solid"
+                color="primary"
+                onPress={onSubmit}>
+                <ButtonText>
+                  <Trans>Begin</Trans>
+                </ButtonText>
+                <ButtonIcon
+                  icon={isPending ? Loader : Shield}
+                  position="right"
+                />
+              </Button>
+            </View>
+
+            <Text
+              style={[a.text_xs, a.leading_snug, t.atoms.text_contrast_medium]}>
+              <Trans>
+                By continuing, you agree to the{' '}
+                <InlineLinkText
+                  overridePresentation
+                  disableMismatchWarning
+                  label={_(msg`KWS Terms of Use`)}
+                  to={urls.kwsTermsOfUse}
+                  style={[a.text_xs, a.leading_snug]}>
+                  KWS Terms of Use
+                </InlineLinkText>{' '}
+                and acknowledge that KWS will store your verified status with
+                your hashed email address in accordance with the{' '}
+                <InlineLinkText
+                  overridePresentation
+                  disableMismatchWarning
+                  label={_(msg`KWS Privacy Policy`)}
+                  to={urls.kwsPrivacyPolicy}
+                  style={[a.text_xs, a.leading_snug]}>
+                  KWS Privacy Policy
+                </InlineLinkText>
+                . This means you won’t need to verify again the next time you
+                use this email for other apps, games, and services powered by
+                KWS technology.
+              </Trans>
+            </Text>
+          </>
+        )}
+      </View>
+    </View>
+  )
+}
+
+// best-effort mapping of our languages to KWS supported languages
+function convertToKWSSupportedLanguage(
+  appLanguage: string,
+): string | undefined {
+  // `${Enum}` is how you get a type of string union of the enum values (???) -sfn
+  switch (appLanguage as `${AppLanguage}`) {
+    // only en is supported
+    case 'en-GB':
+      return 'en'
+    // pt-PT is pt (pt-BR is supported independently)
+    case 'pt-PT':
+      return 'pt'
+    // only chinese (simplified) is supported, map all chinese variants
+    case 'zh-Hans-CN':
+    case 'zh-Hant-HK':
+    case 'zh-Hant-TW':
+      return 'zh-Hans'
+    default:
+      // try and map directly - if undefined, they will have to pick from the dropdown
+      return KWS_SUPPORTED_LANGS.find(v => v.value === appLanguage)?.value
+  }
+}
diff --git a/src/components/ageAssurance/AgeAssuranceRedirectDialog.tsx b/src/components/ageAssurance/AgeAssuranceRedirectDialog.tsx
new file mode 100644
index 000000000..41e706fee
--- /dev/null
+++ b/src/components/ageAssurance/AgeAssuranceRedirectDialog.tsx
@@ -0,0 +1,196 @@
+import {useEffect, useRef, useState} from 'react'
+import {View} from 'react-native'
+import {msg, Trans} from '@lingui/macro'
+import {useLingui} from '@lingui/react'
+
+import {retry} from '#/lib/async/retry'
+import {wait} from '#/lib/async/wait'
+import {isNative} from '#/platform/detection'
+import {useAgeAssuranceAPIContext} from '#/state/ageAssurance'
+import {useAgent} from '#/state/session'
+import {atoms as a, useTheme, web} from '#/alf'
+import {AgeAssuranceBadge} from '#/components/ageAssurance/AgeAssuranceBadge'
+import {Button, ButtonText} from '#/components/Button'
+import * as Dialog from '#/components/Dialog'
+import {useGlobalDialogsControlContext} from '#/components/dialogs/Context'
+import {CircleInfo_Stroke2_Corner0_Rounded as ErrorIcon} from '#/components/icons/CircleInfo'
+import {Loader} from '#/components/Loader'
+import {Text} from '#/components/Typography'
+
+export type AgeAssuranceRedirectDialogState = {
+  result: 'success' | 'unknown'
+  actorDid: string
+}
+
+/**
+ * Validate and parse the query parameters returned from the age assurance
+ * redirect. If not valid, returns `undefined` and the dialog will not open.
+ */
+export function parseAgeAssuranceRedirectDialogState(
+  state: {
+    result?: string
+    actorDid?: string
+  } = {},
+): AgeAssuranceRedirectDialogState | undefined {
+  let result: AgeAssuranceRedirectDialogState['result'] = 'unknown'
+  const actorDid = state.actorDid
+
+  switch (state.result) {
+    case 'success':
+      result = 'success'
+      break
+    case 'unknown':
+    default:
+      result = 'unknown'
+      break
+  }
+
+  if (result && actorDid) {
+    return {
+      result,
+      actorDid,
+    }
+  }
+}
+
+export function useAgeAssuranceRedirectDialogControl() {
+  return useGlobalDialogsControlContext().ageAssuranceRedirectDialogControl
+}
+
+export function AgeAssuranceRedirectDialog() {
+  const {_} = useLingui()
+  const control = useAgeAssuranceRedirectDialogControl()
+
+  // TODO for testing
+  // Dialog.useAutoOpen(control.control, 3e3)
+
+  return (
+    <Dialog.Outer control={control.control}>
+      <Dialog.Handle />
+
+      <Dialog.ScrollableInner
+        label={_(msg`Verifying your age assurance status`)}
+        style={[web({maxWidth: 400})]}>
+        <Inner optimisticState={control.value} />
+      </Dialog.ScrollableInner>
+    </Dialog.Outer>
+  )
+}
+
+export function Inner({}: {optimisticState?: AgeAssuranceRedirectDialogState}) {
+  const t = useTheme()
+  const {_} = useLingui()
+  const agent = useAgent()
+  const polling = useRef(false)
+  const unmounted = useRef(false)
+  const control = useAgeAssuranceRedirectDialogControl()
+  const [error, setError] = useState(false)
+  const {refetch: refreshAgeAssuranceState} = useAgeAssuranceAPIContext()
+
+  useEffect(() => {
+    if (polling.current) return
+
+    polling.current = true
+
+    wait(
+      3e3,
+      retry(
+        5,
+        () => true,
+        async () => {
+          if (!agent.session) return
+          if (unmounted.current) return
+
+          const {data} = await agent.app.bsky.unspecced.getAgeAssuranceState()
+
+          if (data.status !== 'assured') {
+            throw new Error(
+              `Polling for age assurance state did not receive assured status`,
+            )
+          }
+
+          return data
+        },
+        1e3,
+      ),
+    )
+      .then(async data => {
+        if (!data) return
+        if (!agent.session) return
+        if (unmounted.current) return
+
+        // success! update state
+        await refreshAgeAssuranceState()
+
+        control.clear()
+        control.control.close()
+      })
+      .catch(() => {
+        if (unmounted.current) return
+        setError(true)
+        // try a refetch anyway
+        refreshAgeAssuranceState()
+      })
+
+    return () => {
+      unmounted.current = true
+    }
+  }, [agent, control, refreshAgeAssuranceState])
+
+  return (
+    <>
+      <View style={[a.align_start, a.w_full]}>
+        <AgeAssuranceBadge />
+
+        <View
+          style={[
+            a.flex_row,
+            a.justify_between,
+            a.align_center,
+            a.gap_sm,
+            a.pt_lg,
+            a.pb_md,
+          ]}>
+          {error && <ErrorIcon size="md" fill={t.palette.negative_500} />}
+
+          <Text style={[a.text_xl, a.font_heavy]}>
+            {error ? <Trans>Connection issue</Trans> : <Trans>Verifying</Trans>}
+          </Text>
+
+          {!error && <Loader size="md" />}
+        </View>
+
+        <Text style={[a.text_md, a.leading_snug]}>
+          {error ? (
+            <Trans>
+              We were unable to receive the verification due to a connection
+              issue. It may arrive later. If it does, your account will update
+              automatically.
+            </Trans>
+          ) : (
+            <Trans>
+              We're confirming your status with our servers. This dialog should
+              close in a few seconds.
+            </Trans>
+          )}
+        </Text>
+
+        {error && isNative && (
+          <View style={[a.w_full, a.pt_lg]}>
+            <Button
+              label={_(msg`Close`)}
+              size="large"
+              variant="solid"
+              color="secondary">
+              <ButtonText>
+                <Trans>Close</Trans>
+              </ButtonText>
+            </Button>
+          </View>
+        )}
+      </View>
+
+      {error && <Dialog.Close />}
+    </>
+  )
+}
diff --git a/src/components/ageAssurance/AgeRestrictedScreen.tsx b/src/components/ageAssurance/AgeRestrictedScreen.tsx
new file mode 100644
index 000000000..2a9882415
--- /dev/null
+++ b/src/components/ageAssurance/AgeRestrictedScreen.tsx
@@ -0,0 +1,93 @@
+import {View} from 'react-native'
+import {msg, Trans} from '@lingui/macro'
+import {useLingui} from '@lingui/react'
+
+import {useAgeAssurance} from '#/state/ageAssurance/useAgeAssurance'
+import {atoms as a} from '#/alf'
+import {Admonition} from '#/components/Admonition'
+import {AgeAssuranceBadge} from '#/components/ageAssurance/AgeAssuranceBadge'
+import {useAgeAssuranceCopy} from '#/components/ageAssurance/useAgeAssuranceCopy'
+import {ButtonIcon, ButtonText} from '#/components/Button'
+import {ChevronRight_Stroke2_Corner0_Rounded as ChevronRight} from '#/components/icons/Chevron'
+import * as Layout from '#/components/Layout'
+import {Link} from '#/components/Link'
+import {Text} from '#/components/Typography'
+
+export function AgeRestrictedScreen({
+  children,
+  screenTitle,
+  infoText,
+}: {
+  children: React.ReactNode
+  screenTitle?: string
+  infoText?: string
+}) {
+  const {_} = useLingui()
+  const copy = useAgeAssuranceCopy()
+  const {isReady, isAgeRestricted} = useAgeAssurance()
+
+  if (!isReady) {
+    return (
+      <Layout.Screen>
+        <Layout.Header.Outer>
+          <Layout.Header.Content>
+            <Layout.Header.TitleText> </Layout.Header.TitleText>
+          </Layout.Header.Content>
+          <Layout.Header.Slot />
+        </Layout.Header.Outer>
+        <Layout.Content />
+      </Layout.Screen>
+    )
+  }
+  if (!isAgeRestricted) return children
+
+  return (
+    <Layout.Screen>
+      <Layout.Header.Outer>
+        <Layout.Header.BackButton />
+        <Layout.Header.Content>
+          <Layout.Header.TitleText>
+            {screenTitle ?? <Trans>Unavailable</Trans>}
+          </Layout.Header.TitleText>
+        </Layout.Header.Content>
+        <Layout.Header.Slot />
+      </Layout.Header.Outer>
+      <Layout.Content>
+        <View style={[a.p_lg]}>
+          <View style={[a.align_start, a.pb_lg]}>
+            <AgeAssuranceBadge />
+          </View>
+
+          <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.
+              </Trans>
+            </Text>
+
+            <Text style={[a.text_md, a.leading_snug]}>
+              <Trans>{copy.notice}</Trans>
+            </Text>
+          </View>
+
+          <View
+            style={[a.flex_row, a.justify_between, a.align_center, a.pb_xl]}>
+            <Link
+              label={_(msg`Go to account settings`)}
+              to="/settings/account"
+              size="small"
+              variant="solid"
+              color="primary">
+              <ButtonText>
+                <Trans>Go to account settings</Trans>
+              </ButtonText>
+              <ButtonIcon icon={ChevronRight} position="right" />
+            </Link>
+          </View>
+
+          {infoText && <Admonition type="tip">{infoText}</Admonition>}
+        </View>
+      </Layout.Content>
+    </Layout.Screen>
+  )
+}
diff --git a/src/components/ageAssurance/const.ts b/src/components/ageAssurance/const.ts
new file mode 100644
index 000000000..35f96e841
--- /dev/null
+++ b/src/components/ageAssurance/const.ts
@@ -0,0 +1,26 @@
+export const urls = {
+  kwsHome: 'https://www.kidswebservices.com/en-US',
+  kwsTermsOfUse: 'https://www.kidswebservices.com/en-US/terms-of-use',
+  kwsPrivacyPolicy: 'https://www.kidswebservices.com/en-US/privacy-policy',
+}
+
+export const KWS_SUPPORTED_LANGS = [
+  {value: 'en', label: 'English'},
+  {value: 'ar', label: 'العربية'},
+  {value: 'zh-Hans', label: '简体中文'},
+  {value: 'nl', label: 'Nederlands'},
+  {value: 'tl', label: 'Filipino'},
+  {value: 'fr', label: 'Français'},
+  {value: 'de', label: 'Deutsch'},
+  {value: 'id', label: 'Bahasa Indonesia'},
+  {value: 'it', label: 'Italiano'},
+  {value: 'ja', label: '日本語'},
+  {value: 'ko', label: '한국어'},
+  {value: 'pt', label: 'Português'},
+  {value: 'pt-BR', label: 'Português (Brasil)'},
+  {value: 'ru', label: 'Русский'},
+  {value: 'es', label: 'Español'},
+  {value: 'tr', label: 'Türkçe'},
+  {value: 'th', label: 'ภาษาไทย'},
+  {value: 'vi', label: 'Tiếng Việt'},
+]
diff --git a/src/components/ageAssurance/useAgeAssuranceCopy.ts b/src/components/ageAssurance/useAgeAssuranceCopy.ts
new file mode 100644
index 000000000..045806994
--- /dev/null
+++ b/src/components/ageAssurance/useAgeAssuranceCopy.ts
@@ -0,0 +1,18 @@
+import {useMemo} from 'react'
+import {msg} from '@lingui/macro'
+import {useLingui} from '@lingui/react'
+
+export function useAgeAssuranceCopy() {
+  const {_} = useLingui()
+
+  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.`,
+      ),
+      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.`,
+      ),
+    }
+  }, [_])
+}
diff --git a/src/components/dialogs/Context.tsx b/src/components/dialogs/Context.tsx
index 1ee4d2739..8c700cafe 100644
--- a/src/components/dialogs/Context.tsx
+++ b/src/components/dialogs/Context.tsx
@@ -1,5 +1,6 @@
 import {createContext, useContext, useMemo, useState} from 'react'
 
+import {type AgeAssuranceRedirectDialogState} from '#/components/ageAssurance/AgeAssuranceRedirectDialog'
 import * as Dialog from '#/components/Dialog'
 import {type Screen} from '#/components/dialogs/EmailDialog/types'
 
@@ -22,6 +23,7 @@ type ControlsContext = {
     displayText: string
     share?: boolean
   }>
+  ageAssuranceRedirectDialogControl: StatefulControl<AgeAssuranceRedirectDialogState>
 }
 
 const ControlsContext = createContext<ControlsContext | null>(null)
@@ -46,6 +48,8 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
     displayText: string
     share?: boolean
   }>()
+  const ageAssuranceRedirectDialogControl =
+    useStatefulDialogControl<AgeAssuranceRedirectDialogState>()
 
   const ctx = useMemo<ControlsContext>(
     () => ({
@@ -54,6 +58,7 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
       inAppBrowserConsentControl,
       emailDialogControl,
       linkWarningDialogControl,
+      ageAssuranceRedirectDialogControl,
     }),
     [
       mutedWordsDialogControl,
@@ -61,6 +66,7 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
       inAppBrowserConsentControl,
       emailDialogControl,
       linkWarningDialogControl,
+      ageAssuranceRedirectDialogControl,
     ],
   )
 
diff --git a/src/components/moderation/LabelPreference.tsx b/src/components/moderation/LabelPreference.tsx
index a951e5abf..edbb12d0c 100644
--- a/src/components/moderation/LabelPreference.tsx
+++ b/src/components/moderation/LabelPreference.tsx
@@ -1,8 +1,11 @@
-import React from 'react'
 import {View} from 'react-native'
-import {InterpretedLabelValueDefinition, LabelPreference} from '@atproto/api'
+import {
+  type InterpretedLabelValueDefinition,
+  type LabelPreference,
+} from '@atproto/api'
 import {msg, Trans} from '@lingui/macro'
 import {useLingui} from '@lingui/react'
+import type React from 'react'
 
 import {useGlobalLabelStrings} from '#/lib/moderation/useGlobalLabelStrings'
 import {useLabelBehaviorDescription} from '#/lib/moderation/useLabelBehaviorDescription'
@@ -65,6 +68,7 @@ export function Buttons({
   ignoreLabel,
   warnLabel,
   hideLabel,
+  disabled,
 }: {
   name: string
   values: ToggleButton.GroupProps['values']
@@ -72,12 +76,14 @@ export function Buttons({
   ignoreLabel?: string
   warnLabel?: string
   hideLabel?: string
+  disabled?: boolean
 }) {
   const {_} = useLingui()
 
   return (
     <View style={[{minHeight: 35}, a.w_full]}>
       <ToggleButton.Group
+        disabled={disabled}
         label={_(
           msg`Configure content filtering setting for category: ${name}`,
         )}
@@ -143,22 +149,21 @@ export function GlobalLabelPreference({
         name={labelStrings.name}
         description={labelStrings.description}
       />
-      {!disabled && (
-        <Buttons
-          name={labelStrings.name.toLowerCase()}
-          values={[pref]}
-          onChange={values => {
-            mutate({
-              label: identifier,
-              visibility: values[0] as LabelPreference,
-              labelerDid: undefined,
-            })
-          }}
-          ignoreLabel={labelOptions.ignore}
-          warnLabel={labelOptions.warn}
-          hideLabel={labelOptions.hide}
-        />
-      )}
+      <Buttons
+        name={labelStrings.name.toLowerCase()}
+        values={[pref]}
+        onChange={values => {
+          mutate({
+            label: identifier,
+            visibility: values[0] as LabelPreference,
+            labelerDid: undefined,
+          })
+        }}
+        ignoreLabel={labelOptions.ignore}
+        warnLabel={labelOptions.warn}
+        hideLabel={labelOptions.hide}
+        disabled={disabled}
+      />
     </Outer>
   )
 }
diff --git a/src/lib/constants.ts b/src/lib/constants.ts
index a21b92de5..3f0d49989 100644
--- a/src/lib/constants.ts
+++ b/src/lib/constants.ts
@@ -202,5 +202,8 @@ export const urls = {
   },
 }
 
+export const PUBLIC_APPVIEW = 'https://api.bsky.app'
 export const PUBLIC_APPVIEW_DID = 'did:web:api.bsky.app'
 export const PUBLIC_STAGING_APPVIEW_DID = 'did:web:api.staging.bsky.dev'
+
+export const DEV_ENV_APPVIEW = `http://localhost:2584` // always the same
diff --git a/src/lib/hooks/useIntentHandler.ts b/src/lib/hooks/useIntentHandler.ts
index 4a5653750..6b1083aa4 100644
--- a/src/lib/hooks/useIntentHandler.ts
+++ b/src/lib/hooks/useIntentHandler.ts
@@ -6,10 +6,14 @@ import {logEvent} from '#/lib/statsig/statsig'
 import {isNative} from '#/platform/detection'
 import {useSession} from '#/state/session'
 import {useCloseAllActiveElements} from '#/state/util'
+import {
+  parseAgeAssuranceRedirectDialogState,
+  useAgeAssuranceRedirectDialogControl,
+} from '#/components/ageAssurance/AgeAssuranceRedirectDialog'
 import {useIntentDialogs} from '#/components/intents/IntentDialogs'
 import {Referrer} from '../../../modules/expo-bluesky-swiss-army'
 
-type IntentType = 'compose' | 'verify-email'
+type IntentType = 'compose' | 'verify-email' | 'age-assurance'
 
 const VALID_IMAGE_REGEX = /^[\w.:\-_/]+\|\d+(\.\d+)?\|\d+(\.\d+)?$/
 
@@ -20,6 +24,9 @@ export function useIntentHandler() {
   const incomingUrl = Linking.useURL()
   const composeIntent = useComposeIntent()
   const verifyEmailIntent = useVerifyEmailIntent()
+  const ageAssuranceRedirectDialogControl =
+    useAgeAssuranceRedirectDialogControl()
+  const {currentAccount} = useSession()
 
   React.useEffect(() => {
     const handleIncomingURL = (url: string) => {
@@ -65,6 +72,26 @@ export function useIntentHandler() {
           verifyEmailIntent(code)
           return
         }
+        case 'age-assurance': {
+          const state = parseAgeAssuranceRedirectDialogState({
+            result: params.get('result') ?? undefined,
+            actorDid: params.get('actorDid') ?? undefined,
+          })
+
+          /*
+           * If we don't have an account or the account doesn't match, do
+           * nothing. By the time the user switches to their other account, AA
+           * state should be ready for them.
+           */
+          if (
+            state &&
+            currentAccount &&
+            state.actorDid === currentAccount.did
+          ) {
+            ageAssuranceRedirectDialogControl.open(state)
+          }
+          return
+        }
         default: {
           return
         }
@@ -78,7 +105,13 @@ export function useIntentHandler() {
       handleIncomingURL(incomingUrl)
       previousIntentUrl = incomingUrl
     }
-  }, [incomingUrl, composeIntent, verifyEmailIntent])
+  }, [
+    incomingUrl,
+    composeIntent,
+    verifyEmailIntent,
+    ageAssuranceRedirectDialogControl,
+    currentAccount,
+  ])
 }
 
 export function useComposeIntent() {
@@ -97,7 +130,6 @@ export function useComposeIntent() {
       videoUri: string | null
     }) => {
       if (!hasSession) return
-
       closeAllActiveElements()
 
       // Whenever a video URI is present, we don't support adding images right now.
diff --git a/src/lib/hooks/useTLDs.ts b/src/lib/hooks/useTLDs.ts
new file mode 100644
index 000000000..8ed872835
--- /dev/null
+++ b/src/lib/hooks/useTLDs.ts
@@ -0,0 +1,15 @@
+import {useEffect, useState} from 'react'
+import type tldts from 'tldts'
+
+export function useTLDs() {
+  const [tlds, setTlds] = useState<typeof tldts>()
+
+  useEffect(() => {
+    // @ts-expect-error - valid path
+    import('tldts/dist/index.cjs.min.js').then(tlds => {
+      setTlds(tlds)
+    })
+  }, [])
+
+  return tlds
+}
diff --git a/src/lib/notifications/notifications.ts b/src/lib/notifications/notifications.ts
index 94b3f6de3..0d2f9ed09 100644
--- a/src/lib/notifications/notifications.ts
+++ b/src/lib/notifications/notifications.ts
@@ -2,12 +2,13 @@ import {useCallback, useEffect} from 'react'
 import {Platform} from 'react-native'
 import * as Notifications from 'expo-notifications'
 import {getBadgeCountAsync, setBadgeCountAsync} from 'expo-notifications'
-import {type AtpAgent} from '@atproto/api'
+import {type AppBskyNotificationRegisterPush, type AtpAgent} from '@atproto/api'
 import debounce from 'lodash.debounce'
 
 import {PUBLIC_APPVIEW_DID, PUBLIC_STAGING_APPVIEW_DID} from '#/lib/constants'
 import {logger as notyLogger} from '#/lib/notifications/util'
 import {isNative} from '#/platform/detection'
+import {useAgeAssuranceContext} from '#/state/ageAssurance'
 import {type SessionAccount, useAgent, useSession} from '#/state/session'
 import BackgroundNotificationHandler from '#/../modules/expo-background-notification-handler'
 
@@ -19,25 +20,31 @@ async function _registerPushToken({
   agent,
   currentAccount,
   token,
+  extra = {},
 }: {
   agent: AtpAgent
   currentAccount: SessionAccount
   token: Notifications.DevicePushToken
+  extra?: {
+    ageRestricted?: boolean
+  }
 }) {
   try {
-    await agent.app.bsky.notification.registerPush({
+    const payload: AppBskyNotificationRegisterPush.InputSchema = {
       serviceDid: currentAccount.service?.includes('staging')
         ? PUBLIC_STAGING_APPVIEW_DID
         : PUBLIC_APPVIEW_DID,
       platform: Platform.OS,
       token: token.data,
       appId: 'xyz.blueskyweb.app',
-    })
+      ageRestricted: extra.ageRestricted ?? false,
+    }
 
-    notyLogger.debug(`registerPushToken: success`, {
-      tokenType: token.type,
-      token: token.data,
-    })
+    notyLogger.debug(`registerPushToken: registering`, {...payload})
+
+    await agent.app.bsky.notification.registerPush(payload)
+
+    notyLogger.debug(`registerPushToken: success`)
   } catch (error) {
     notyLogger.error(`registerPushToken: failed`, {safeMessage: error})
   }
@@ -61,12 +68,21 @@ export function useRegisterPushToken() {
   const {currentAccount} = useSession()
 
   return useCallback(
-    ({token}: {token: Notifications.DevicePushToken}) => {
+    ({
+      token,
+      isAgeRestricted,
+    }: {
+      token: Notifications.DevicePushToken
+      isAgeRestricted: boolean
+    }) => {
       if (!currentAccount) return
       return _registerPushTokenDebounced({
         agent,
         currentAccount,
         token,
+        extra: {
+          ageRestricted: isAgeRestricted,
+        },
       })
     },
     [agent, currentAccount],
@@ -100,33 +116,46 @@ async function getPushToken() {
  * it fires), so there's a possibility that multiple calls will be made, but
  * that is acceptable.
  *
- * @see https://github.com/bluesky-social/social-app/pull/4467
  * @see https://github.com/expo/expo/issues/28656
  * @see https://github.com/expo/expo/issues/29909
+ * @see https://github.com/bluesky-social/social-app/pull/4467
  */
 export function useGetAndRegisterPushToken() {
+  const {isAgeRestricted} = useAgeAssuranceContext()
   const registerPushToken = useRegisterPushToken()
-  return useCallback(async () => {
-    /**
-     * This will also fire the listener added via `addPushTokenListener`. That
-     * listener also handles registration.
-     */
-    const token = await getPushToken()
-
-    notyLogger.debug(`useGetAndRegisterPushToken`, {
-      token: token ?? 'undefined',
-    })
+  return useCallback(
+    async ({
+      isAgeRestricted: isAgeRestrictedOverride,
+    }: {
+      isAgeRestricted?: boolean
+    } = {}) => {
+      if (!isNative) return
 
-    if (token) {
       /**
-       * The listener should have registered the token already, but just in
-       * case, call the debounced function again.
+       * This will also fire the listener added via `addPushTokenListener`. That
+       * listener also handles registration.
        */
-      registerPushToken({token})
-    }
+      const token = await getPushToken()
 
-    return token
-  }, [registerPushToken])
+      notyLogger.debug(`useGetAndRegisterPushToken`, {
+        token: token ?? 'undefined',
+      })
+
+      if (token) {
+        /**
+         * The listener should have registered the token already, but just in
+         * case, call the debounced function again.
+         */
+        registerPushToken({
+          token,
+          isAgeRestricted: isAgeRestrictedOverride ?? isAgeRestricted,
+        })
+      }
+
+      return token
+    },
+    [registerPushToken, isAgeRestricted],
+  )
 }
 
 /**
@@ -140,12 +169,15 @@ export function useNotificationsRegistration() {
   const {currentAccount} = useSession()
   const registerPushToken = useRegisterPushToken()
   const getAndRegisterPushToken = useGetAndRegisterPushToken()
+  const {isReady: isAgeRestrictionReady, isAgeRestricted} =
+    useAgeAssuranceContext()
 
   useEffect(() => {
     /**
-     * We want this to init right away _after_ we have a logged in user.
+     * We want this to init right away _after_ we have a logged in user, and
+     * _after_ we've loaded their age assurance state.
      */
-    if (!currentAccount) return
+    if (!currentAccount || !isAgeRestrictionReady) return
 
     notyLogger.debug(`useNotificationsRegistration`)
 
@@ -167,14 +199,20 @@ export function useNotificationsRegistration() {
      * @see https://docs.expo.dev/versions/latest/sdk/notifications/#addpushtokenlistenerlistener
      */
     const subscription = Notifications.addPushTokenListener(async token => {
-      registerPushToken({token})
+      registerPushToken({token, isAgeRestricted: isAgeRestricted})
       notyLogger.debug(`addPushTokenListener callback`, {token})
     })
 
     return () => {
       subscription.remove()
     }
-  }, [currentAccount, getAndRegisterPushToken, registerPushToken])
+  }, [
+    currentAccount,
+    getAndRegisterPushToken,
+    registerPushToken,
+    isAgeRestrictionReady,
+    isAgeRestricted,
+  ])
 }
 
 export function useRequestNotificationsPermission() {
diff --git a/src/lib/statsig/gates.ts b/src/lib/statsig/gates.ts
index 3b1106480..efd7d605a 100644
--- a/src/lib/statsig/gates.ts
+++ b/src/lib/statsig/gates.ts
@@ -1,5 +1,6 @@
 export type Gate =
   // Keep this alphabetic please.
+  | 'age_assurance'
   | 'alt_share_icon'
   | 'debug_show_feedcontext'
   | 'debug_subscriptions'
diff --git a/src/logger/types.ts b/src/logger/types.ts
index 88d8d9d93..4743e866c 100644
--- a/src/logger/types.ts
+++ b/src/logger/types.ts
@@ -12,6 +12,7 @@ export enum LogContext {
   ReportDialog = 'report-dialog',
   FeedFeedback = 'feed-feedback',
   PostSource = 'post-source',
+  AgeAssurance = 'age-assurance',
 
   /**
    * METRIC IS FOR INTERNAL USE ONLY, don't create any other loggers using this
diff --git a/src/screens/Messages/ChatList.tsx b/src/screens/Messages/ChatList.tsx
index 388d23ec2..e13f0617b 100644
--- a/src/screens/Messages/ChatList.tsx
+++ b/src/screens/Messages/ChatList.tsx
@@ -23,6 +23,8 @@ import {useSession} from '#/state/session'
 import {List, type ListRef} from '#/view/com/util/List'
 import {ChatListLoadingPlaceholder} from '#/view/com/util/LoadingPlaceholder'
 import {atoms as a, useBreakpoints, useTheme} from '#/alf'
+import {AgeRestrictedScreen} from '#/components/ageAssurance/AgeRestrictedScreen'
+import {useAgeAssuranceCopy} from '#/components/ageAssurance/useAgeAssuranceCopy'
 import {Button, ButtonIcon, ButtonText} from '#/components/Button'
 import {type DialogControlProps, useDialogControl} from '#/components/Dialog'
 import {NewChat} from '#/components/dms/dialogs/NewChatDialog'
@@ -64,7 +66,21 @@ function keyExtractor(item: ListItem) {
 }
 
 type Props = NativeStackScreenProps<MessagesTabNavigatorParams, 'Messages'>
-export function MessagesScreen({navigation, route}: Props) {
+
+export function MessagesScreen(props: Props) {
+  const {_} = useLingui()
+  const aaCopy = useAgeAssuranceCopy()
+
+  return (
+    <AgeRestrictedScreen
+      screenTitle={_(msg`Chats`)}
+      infoText={aaCopy.chatsInfoText}>
+      <MessagesScreenInner {...props} />
+    </AgeRestrictedScreen>
+  )
+}
+
+export function MessagesScreenInner({navigation, route}: Props) {
   const {_} = useLingui()
   const t = useTheme()
   const {currentAccount} = useSession()
diff --git a/src/screens/Messages/Conversation.tsx b/src/screens/Messages/Conversation.tsx
index 90547a8d4..7f3b53b94 100644
--- a/src/screens/Messages/Conversation.tsx
+++ b/src/screens/Messages/Conversation.tsx
@@ -32,6 +32,8 @@ import {useProfileQuery} from '#/state/queries/profile'
 import {useSetMinimalShellMode} from '#/state/shell'
 import {MessagesList} from '#/screens/Messages/components/MessagesList'
 import {atoms as a, useBreakpoints, useTheme, web} from '#/alf'
+import {AgeRestrictedScreen} from '#/components/ageAssurance/AgeRestrictedScreen'
+import {useAgeAssuranceCopy} from '#/components/ageAssurance/useAgeAssuranceCopy'
 import {
   EmailDialogScreenID,
   useEmailDialogControl,
@@ -46,7 +48,20 @@ type Props = NativeStackScreenProps<
   CommonNavigatorParams,
   'MessagesConversation'
 >
-export function MessagesConversationScreen({route}: Props) {
+
+export function MessagesConversationScreen(props: Props) {
+  const {_} = useLingui()
+  const aaCopy = useAgeAssuranceCopy()
+  return (
+    <AgeRestrictedScreen
+      screenTitle={_(msg`Conversation`)}
+      infoText={aaCopy.chatsInfoText}>
+      <MessagesConversationScreenInner {...props} />
+    </AgeRestrictedScreen>
+  )
+}
+
+export function MessagesConversationScreenInner({route}: Props) {
   const {gtMobile} = useBreakpoints()
   const setMinimalShellMode = useSetMinimalShellMode()
 
diff --git a/src/screens/Messages/Inbox.tsx b/src/screens/Messages/Inbox.tsx
index 0f64d2014..8765cf0ba 100644
--- a/src/screens/Messages/Inbox.tsx
+++ b/src/screens/Messages/Inbox.tsx
@@ -1,17 +1,23 @@
 import {useCallback, useMemo, useState} from 'react'
 import {View} from 'react-native'
-import {ChatBskyConvoDefs, ChatBskyConvoListConvos} from '@atproto/api'
+import {
+  type ChatBskyConvoDefs,
+  type ChatBskyConvoListConvos,
+} from '@atproto/api'
 import {msg, Trans} from '@lingui/macro'
 import {useLingui} from '@lingui/react'
 import {useFocusEffect, useNavigation} from '@react-navigation/native'
-import {InfiniteData, UseInfiniteQueryResult} from '@tanstack/react-query'
+import {
+  type InfiniteData,
+  type UseInfiniteQueryResult,
+} from '@tanstack/react-query'
 
 import {useAppState} from '#/lib/hooks/useAppState'
 import {useInitialNumToRender} from '#/lib/hooks/useInitialNumToRender'
 import {
-  CommonNavigatorParams,
-  NativeStackScreenProps,
-  NavigationProp,
+  type CommonNavigatorParams,
+  type NativeStackScreenProps,
+  type NavigationProp,
 } from '#/lib/routes/types'
 import {cleanError} from '#/lib/strings/errors'
 import {logger} from '#/logger'
@@ -26,6 +32,8 @@ import {List} from '#/view/com/util/List'
 import {ChatListLoadingPlaceholder} from '#/view/com/util/LoadingPlaceholder'
 import * as Toast from '#/view/com/util/Toast'
 import {atoms as a, useBreakpoints, useTheme} from '#/alf'
+import {AgeRestrictedScreen} from '#/components/ageAssurance/AgeRestrictedScreen'
+import {useAgeAssuranceCopy} from '#/components/ageAssurance/useAgeAssuranceCopy'
 import {Button, ButtonIcon, ButtonText} from '#/components/Button'
 import {useRefreshOnFocus} from '#/components/hooks/useRefreshOnFocus'
 import {ArrowLeft_Stroke2_Corner0_Rounded as ArrowLeftIcon} from '#/components/icons/Arrow'
@@ -39,7 +47,20 @@ import {Text} from '#/components/Typography'
 import {RequestListItem} from './components/RequestListItem'
 
 type Props = NativeStackScreenProps<CommonNavigatorParams, 'MessagesInbox'>
-export function MessagesInboxScreen({}: Props) {
+
+export function MessagesInboxScreen(props: Props) {
+  const {_} = useLingui()
+  const aaCopy = useAgeAssuranceCopy()
+  return (
+    <AgeRestrictedScreen
+      screenTitle={_(msg`Chat requests`)}
+      infoText={aaCopy.chatsInfoText}>
+      <MessagesInboxScreenInner {...props} />
+    </AgeRestrictedScreen>
+  )
+}
+
+export function MessagesInboxScreenInner({}: Props) {
   const {gtTablet} = useBreakpoints()
 
   const listConvosQuery = useListConvosQuery({status: 'request'})
diff --git a/src/screens/Messages/Settings.tsx b/src/screens/Messages/Settings.tsx
index 0b8c88b9d..6015c07cd 100644
--- a/src/screens/Messages/Settings.tsx
+++ b/src/screens/Messages/Settings.tsx
@@ -12,6 +12,8 @@ import {useSession} from '#/state/session'
 import * as Toast from '#/view/com/util/Toast'
 import {atoms as a} from '#/alf'
 import {Admonition} from '#/components/Admonition'
+import {AgeRestrictedScreen} from '#/components/ageAssurance/AgeRestrictedScreen'
+import {useAgeAssuranceCopy} from '#/components/ageAssurance/useAgeAssuranceCopy'
 import {Divider} from '#/components/Divider'
 import * as Toggle from '#/components/forms/Toggle'
 import * as Layout from '#/components/Layout'
@@ -21,7 +23,21 @@ import {useBackgroundNotificationPreferences} from '../../../modules/expo-backgr
 type AllowIncoming = 'all' | 'none' | 'following'
 
 type Props = NativeStackScreenProps<CommonNavigatorParams, 'MessagesSettings'>
-export function MessagesSettingsScreen({}: Props) {
+
+export function MessagesSettingsScreen(props: Props) {
+  const {_} = useLingui()
+  const aaCopy = useAgeAssuranceCopy()
+
+  return (
+    <AgeRestrictedScreen
+      screenTitle={_(msg`Chat Settings`)}
+      infoText={aaCopy.chatsInfoText}>
+      <MessagesSettingsScreenInner {...props} />
+    </AgeRestrictedScreen>
+  )
+}
+
+export function MessagesSettingsScreenInner({}: Props) {
   const {_} = useLingui()
   const {currentAccount} = useSession()
   const {data: profile} = useProfileQuery({
diff --git a/src/screens/Moderation/index.tsx b/src/screens/Moderation/index.tsx
index 78b0a6ae9..a7b434e52 100644
--- a/src/screens/Moderation/index.tsx
+++ b/src/screens/Moderation/index.tsx
@@ -12,6 +12,7 @@ import {
 } from '#/lib/routes/types'
 import {logger} from '#/logger'
 import {isIOS} from '#/platform/detection'
+import {useAgeAssurance} from '#/state/ageAssurance/useAgeAssurance'
 import {
   useMyLabelersQuery,
   usePreferencesQuery,
@@ -20,8 +21,8 @@ import {
 } from '#/state/queries/preferences'
 import {isNonConfigurableModerationAuthority} from '#/state/session/additional-moderation-authorities'
 import {useSetMinimalShellMode} from '#/state/shell'
-import {ViewHeader} from '#/view/com/util/ViewHeader'
 import {atoms as a, useBreakpoints, useTheme, type ViewStyleProp} from '#/alf'
+import {AgeAssuranceAdmonition} from '#/components/ageAssurance/AgeAssuranceAdmonition'
 import {Button, ButtonText} from '#/components/Button'
 import * as Dialog from '#/components/Dialog'
 import {BirthDateSettingsDialog} from '#/components/dialogs/BirthDateSettings'
@@ -84,13 +85,22 @@ export function ModerationScreen(
     error: preferencesError,
     data: preferences,
   } = usePreferencesQuery()
+  const {isReady: isAgeInfoReady} = useAgeAssurance()
 
-  const isLoading = isPreferencesLoading
+  const isLoading = isPreferencesLoading || !isAgeInfoReady
   const error = preferencesError
 
   return (
     <Layout.Screen testID="moderationScreen">
-      <ViewHeader title={_(msg`Moderation`)} showOnDesktop />
+      <Layout.Header.Outer>
+        <Layout.Header.BackButton />
+        <Layout.Header.Content>
+          <Layout.Header.TitleText>
+            <Trans>Moderation</Trans>
+          </Layout.Header.TitleText>
+        </Layout.Header.Content>
+        <Layout.Header.Slot />
+      </Layout.Header.Outer>
       <Layout.Content>
         {isLoading ? (
           <ListMaybePlaceholder isLoading={true} sideBorders={false} />
@@ -157,6 +167,7 @@ export function ModerationScreenInner({
     data: labelers,
     error: labelersError,
   } = useMyLabelersQuery()
+  const {declaredAge, isDeclaredUnderage, isAgeRestricted} = useAgeAssurance()
 
   useFocusEffect(
     useCallback(() => {
@@ -170,8 +181,6 @@ export function ModerationScreenInner({
     (optimisticAdultContent && optimisticAdultContent.enabled) ||
     (!optimisticAdultContent && preferences.moderationPrefs.adultContentEnabled)
   )
-  const ageNotSet = !preferences.userAge
-  const isUnderage = (preferences.userAge || 0) < 18
 
   const onToggleAdultContentEnabled = useCallback(
     async (selected: boolean) => {
@@ -306,8 +315,14 @@ export function ModerationScreenInner({
         <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]}>
-        {ageNotSet && (
+        {declaredAge === undefined && (
           <>
             <Button
               label={_(msg`Confirm your birthdate`)}
@@ -336,7 +351,7 @@ export function ModerationScreenInner({
             a.overflow_hidden,
             t.atoms.bg_contrast_25,
           ]}>
-          {!ageNotSet && !isUnderage && (
+          {!isDeclaredUnderage && !isAgeRestricted && (
             <>
               <View
                 style={[
@@ -389,21 +404,25 @@ export function ModerationScreenInner({
                 </View>
               )}
               <Divider />
+
+              {adultContentEnabled && (
+                <>
+                  <GlobalLabelPreference labelDefinition={LABELS.porn} />
+                  <Divider />
+                  <GlobalLabelPreference labelDefinition={LABELS.sexual} />
+                  <Divider />
+                  <GlobalLabelPreference
+                    labelDefinition={LABELS['graphic-media']}
+                  />
+                  <Divider />
+                </>
+              )}
             </>
           )}
-          {!isUnderage && adultContentEnabled && (
-            <>
-              <GlobalLabelPreference labelDefinition={LABELS.porn} />
-              <Divider />
-              <GlobalLabelPreference labelDefinition={LABELS.sexual} />
-              <Divider />
-              <GlobalLabelPreference
-                labelDefinition={LABELS['graphic-media']}
-              />
-              <Divider />
-            </>
-          )}
-          <GlobalLabelPreference labelDefinition={LABELS.nudity} />
+          <GlobalLabelPreference
+            disabled={isDeclaredUnderage || isAgeRestricted}
+            labelDefinition={LABELS.nudity}
+          />
         </View>
       </View>
 
diff --git a/src/screens/Settings/AboutSettings.tsx b/src/screens/Settings/AboutSettings.tsx
index 0ce127ff3..6be881a88 100644
--- a/src/screens/Settings/AboutSettings.tsx
+++ b/src/screens/Settings/AboutSettings.tsx
@@ -20,9 +20,11 @@ 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'
@@ -179,6 +181,20 @@ 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/screens/Settings/AccountSettings.tsx b/src/screens/Settings/AccountSettings.tsx
index 393bad2f8..86652d277 100644
--- a/src/screens/Settings/AccountSettings.tsx
+++ b/src/screens/Settings/AccountSettings.tsx
@@ -7,6 +7,7 @@ import {useModalControls} from '#/state/modals'
 import {useSession} from '#/state/session'
 import * as SettingsList from '#/screens/Settings/components/SettingsList'
 import {atoms as a, useTheme} from '#/alf'
+import {AgeAssuranceAccountCard} from '#/components/ageAssurance/AgeAssuranceAccountCard'
 import {useDialogControl} from '#/components/Dialog'
 import {BirthDateSettingsDialog} from '#/components/dialogs/BirthDateSettings'
 import {
@@ -114,16 +115,6 @@ export function AccountSettingsScreen({}: Props) {
             <SettingsList.Chevron />
           </SettingsList.PressableItem>
           <SettingsList.Divider />
-          <SettingsList.Item>
-            <SettingsList.ItemIcon icon={BirthdayCakeIcon} />
-            <SettingsList.ItemText>
-              <Trans>Birthday</Trans>
-            </SettingsList.ItemText>
-            <SettingsList.BadgeButton
-              label={_(msg`Edit`)}
-              onPress={() => birthdayControl.open()}
-            />
-          </SettingsList.Item>
           <SettingsList.PressableItem
             label={_(msg`Password`)}
             onPress={() => openModal({name: 'change-password'})}>
@@ -143,6 +134,17 @@ export function AccountSettingsScreen({}: Props) {
             </SettingsList.ItemText>
             <SettingsList.Chevron />
           </SettingsList.PressableItem>
+          <SettingsList.Item>
+            <SettingsList.ItemIcon icon={BirthdayCakeIcon} />
+            <SettingsList.ItemText>
+              <Trans>Birthday</Trans>
+            </SettingsList.ItemText>
+            <SettingsList.BadgeButton
+              label={_(msg`Edit`)}
+              onPress={() => birthdayControl.open()}
+            />
+          </SettingsList.Item>
+          <AgeAssuranceAccountCard style={[a.px_xl, a.pt_xs, a.pb_md]} />
           <SettingsList.Divider />
           <SettingsList.PressableItem
             label={_(msg`Export my data`)}
diff --git a/src/screens/Settings/Settings.tsx b/src/screens/Settings/Settings.tsx
index aaba0b4b5..4d10a9d0d 100644
--- a/src/screens/Settings/Settings.tsx
+++ b/src/screens/Settings/Settings.tsx
@@ -32,6 +32,7 @@ import * as Toast from '#/view/com/util/Toast'
 import {UserAvatar} from '#/view/com/util/UserAvatar'
 import * as SettingsList from '#/screens/Settings/components/SettingsList'
 import {atoms as a, platform, tokens, useBreakpoints, useTheme} from '#/alf'
+import {AgeAssuranceDismissibleNotice} from '#/components/ageAssurance/AgeAssuranceDismissibleNotice'
 import {AvatarStackWithFetch} from '#/components/AvatarStack'
 import {useDialogControl} from '#/components/Dialog'
 import {SwitchAccountDialog} from '#/components/dialogs/SwitchAccount'
@@ -96,6 +97,8 @@ export function SettingsScreen({}: Props) {
       </Layout.Header.Outer>
       <Layout.Content>
         <SettingsList.Container>
+          <AgeAssuranceDismissibleNotice style={[a.px_lg, a.pt_xs, a.pb_xl]} />
+
           <View
             style={[
               a.px_xl,
diff --git a/src/state/ageAssurance/const.ts b/src/state/ageAssurance/const.ts
new file mode 100644
index 000000000..2f329582a
--- /dev/null
+++ b/src/state/ageAssurance/const.ts
@@ -0,0 +1,11 @@
+import {type ModerationPrefs} from '@atproto/api'
+
+import {DEFAULT_LOGGED_OUT_LABEL_PREFERENCES} from '#/state/queries/preferences/moderation'
+
+export const AGE_RESTRICTED_MODERATION_PREFS: ModerationPrefs = {
+  adultContentEnabled: false,
+  labels: DEFAULT_LOGGED_OUT_LABEL_PREFERENCES,
+  labelers: [],
+  mutedWords: [],
+  hiddenPosts: [],
+}
diff --git a/src/state/ageAssurance/index.tsx b/src/state/ageAssurance/index.tsx
new file mode 100644
index 000000000..aab954e6c
--- /dev/null
+++ b/src/state/ageAssurance/index.tsx
@@ -0,0 +1,140 @@
+import {createContext, useContext, useMemo} from 'react'
+import {type AppBskyUnspeccedDefs} from '@atproto/api'
+import {useQuery} from '@tanstack/react-query'
+
+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 {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
+
+const DEFAULT_AGE_ASSURANCE_STATE: AppBskyUnspeccedDefs.AgeAssuranceState = {
+  lastInitiatedAt: undefined,
+  status: 'unknown',
+}
+
+const AgeAssuranceContext = createContext<AgeAssuranceContextType>({
+  status: 'unknown',
+  isReady: false,
+  lastInitiatedAt: undefined,
+  isAgeRestricted: false,
+})
+
+const AgeAssuranceAPIContext = createContext<AgeAssuranceAPIContextType>({
+  // @ts-ignore can't be bothered to type this
+  refetch: () => Promise.resolve(),
+})
+
+/**
+ * Low-level provider for fetching age assurance state on app load. Do not add
+ * any other data fetching in here to avoid complications and reduced
+ * performance.
+ */
+export function Provider({children}: {children: React.ReactNode}) {
+  const gate = useGate()
+  const agent = useAgent()
+  const {geolocation} = useGeolocation()
+  const isAgeAssuranceEnabled = useIsAgeAssuranceEnabled()
+  const getAndRegisterPushToken = useGetAndRegisterPushToken()
+
+  const {data, isFetched, refetch} = useQuery({
+    /**
+     * This is load bearing. We always want this query to run and end in a
+     * "fetched" state, even if we fall back to defaults. This lets the rest of
+     * the app know that we've at least attempted to load the AA state.
+     *
+     * However, it only needs to run if AA is enabled.
+     */
+    enabled: isAgeAssuranceEnabled,
+    queryKey: createAgeAssuranceQueryKey(agent.session?.did ?? 'never'),
+    async queryFn() {
+      if (!agent.session) return null
+
+      try {
+        const {data} = await networkRetry(3, () =>
+          agent.app.bsky.unspecced.getAgeAssuranceState(),
+        )
+        // const {data} = {
+        //   data: {
+        //     lastInitiatedAt: new Date().toISOString(),
+        //     status: 'pending',
+        //   } as AppBskyUnspeccedDefs.AgeAssuranceState,
+        // }
+
+        logger.debug(`fetch`, {
+          data,
+          account: agent.session?.did,
+        })
+
+        if (gate('age_assurance')) {
+          await getAndRegisterPushToken({
+            isAgeRestricted:
+              !!geolocation?.isAgeRestrictedGeo && data.status !== 'assured',
+          })
+        }
+
+        return data
+      } catch (e) {
+        if (!isNetworkError(e)) {
+          logger.error(`ageAssurance: failed to fetch`, {safeMessage: e})
+        }
+        // don't re-throw error, we'll just fall back to defaults
+        return null
+      }
+    },
+  })
+
+  /**
+   * Derive state, or fall back to defaults
+   */
+  const ageAssuranceContext = useMemo<AgeAssuranceContextType>(() => {
+    const {status, lastInitiatedAt} = data || DEFAULT_AGE_ASSURANCE_STATE
+    const ctx: AgeAssuranceContextType = {
+      isReady: isFetched || !isAgeAssuranceEnabled,
+      status,
+      lastInitiatedAt,
+      isAgeRestricted: isAgeAssuranceEnabled ? status !== 'assured' : false,
+    }
+    logger.debug(`context`, ctx)
+    return ctx
+  }, [isFetched, data, isAgeAssuranceEnabled])
+
+  const ageAssuranceAPIContext = useMemo<AgeAssuranceAPIContextType>(
+    () => ({
+      refetch,
+    }),
+    [refetch],
+  )
+
+  return (
+    <AgeAssuranceAPIContext.Provider value={ageAssuranceAPIContext}>
+      <AgeAssuranceContext.Provider value={ageAssuranceContext}>
+        {children}
+      </AgeAssuranceContext.Provider>
+    </AgeAssuranceAPIContext.Provider>
+  )
+}
+
+/**
+ * Access to low-level AA state. Prefer using {@link useAgeInfo} for a
+ * more user-friendly interface.
+ */
+export function useAgeAssuranceContext() {
+  return useContext(AgeAssuranceContext)
+}
+
+export function useAgeAssuranceAPIContext() {
+  return useContext(AgeAssuranceAPIContext)
+}
diff --git a/src/state/ageAssurance/types.ts b/src/state/ageAssurance/types.ts
new file mode 100644
index 000000000..63febb3cf
--- /dev/null
+++ b/src/state/ageAssurance/types.ts
@@ -0,0 +1,33 @@
+import {type AppBskyUnspeccedDefs} from '@atproto/api'
+import {type QueryObserverBaseResult} from '@tanstack/react-query'
+
+export type AgeAssuranceContextType = {
+  /**
+   * Whether the age assurance state has been fetched from the server. If user
+   * is not in a region that requires AA, or AA is otherwise disabled, this
+   * will always be `true`.
+   */
+  isReady: boolean
+  /**
+   * The server-reported status of the user's age verification process.
+   */
+  status: AppBskyUnspeccedDefs.AgeAssuranceState['status']
+  /**
+   * The last time the age assurance state was attempted by the user.
+   */
+  lastInitiatedAt: AppBskyUnspeccedDefs.AgeAssuranceState['lastInitiatedAt']
+  /**
+   * Indicates the user is age restricted based on the requirements of their
+   * region, and their server-provided age assurance status. Does not factor in
+   * the user's declared age. If AA is otherise disabled, this will always be
+   * `false`.
+   */
+  isAgeRestricted: boolean
+}
+
+export type AgeAssuranceAPIContextType = {
+  /**
+   * Refreshes the age assurance state by fetching it from the server.
+   */
+  refetch: QueryObserverBaseResult['refetch']
+}
diff --git a/src/state/ageAssurance/useAgeAssurance.ts b/src/state/ageAssurance/useAgeAssurance.ts
new file mode 100644
index 000000000..455f38c92
--- /dev/null
+++ b/src/state/ageAssurance/useAgeAssurance.ts
@@ -0,0 +1,45 @@
+import {useMemo} from 'react'
+
+import {Logger} from '#/logger'
+import {useAgeAssuranceContext} from '#/state/ageAssurance'
+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.
+   */
+  declaredAge: number | undefined
+  /**
+   * Indicates whether the user has declared an age under 18.
+   */
+  isDeclaredUnderage: boolean
+}
+
+/**
+ * Computed age information based on age assurance status and the user's
+ * declared age. Use this instead of {@link useAgeAssuranceContext} to get a
+ * more user-friendly interface.
+ */
+export function useAgeAssurance(): AgeAssurance {
+  const aa = useAgeAssuranceContext()
+  const {isFetched: preferencesLoaded, data: preferences} =
+    usePreferencesQuery()
+  const declaredAge = preferences?.userAge
+
+  return useMemo(() => {
+    const isReady = aa.isReady && preferencesLoaded
+    const isDeclaredUnderage = (declaredAge || 0) < 18
+    const state: AgeAssurance = {
+      isReady,
+      status: aa.status,
+      lastInitiatedAt: aa.lastInitiatedAt,
+      isAgeRestricted: aa.isAgeRestricted,
+      declaredAge,
+      isDeclaredUnderage,
+    }
+    logger.debug(`state`, state)
+    return state
+  }, [aa, preferencesLoaded, declaredAge])
+}
diff --git a/src/state/ageAssurance/useInitAgeAssurance.ts b/src/state/ageAssurance/useInitAgeAssurance.ts
new file mode 100644
index 000000000..8776dd29c
--- /dev/null
+++ b/src/state/ageAssurance/useInitAgeAssurance.ts
@@ -0,0 +1,85 @@
+import {
+  type AppBskyUnspeccedDefs,
+  type AppBskyUnspeccedInitAgeAssurance,
+  AtpAgent,
+} from '@atproto/api'
+import {useMutation, useQueryClient} from '@tanstack/react-query'
+
+import {wait} from '#/lib/async/wait'
+import {
+  // DEV_ENV_APPVIEW,
+  PUBLIC_APPVIEW,
+  PUBLIC_APPVIEW_DID,
+} from '#/lib/constants'
+import {isNetworkError} from '#/lib/hooks/useCleanError'
+import {logger} from '#/logger'
+import {createAgeAssuranceQueryKey} from '#/state/ageAssurance'
+import {useGeolocation} from '#/state/geolocation'
+import {useAgent} from '#/state/session'
+
+let APPVIEW = PUBLIC_APPVIEW
+let APPVIEW_DID = PUBLIC_APPVIEW_DID
+
+/*
+ * Uncomment if using the local dev-env
+ */
+// if (__DEV__) {
+//   APPVIEW = DEV_ENV_APPVIEW
+//   /*
+//    * IMPORTANT: you need to get this value from `http://localhost:2581`
+//    * introspection endpoint and updated in `constants`, since it changes
+//    * every time you run the dev-env.
+//    */
+//   APPVIEW_DID = ``
+// }
+
+export function useInitAgeAssurance() {
+  const qc = useQueryClient()
+  const agent = useAgent()
+  const {geolocation} = useGeolocation()
+  return useMutation({
+    async mutationFn(
+      props: Omit<AppBskyUnspeccedInitAgeAssurance.InputSchema, 'countryCode'>,
+    ) {
+      if (!geolocation?.countryCode) {
+        throw new Error(`Geolocation not available, cannot init age assurance.`)
+      }
+
+      const {
+        data: {token},
+      } = await agent.com.atproto.server.getServiceAuth({
+        aud: APPVIEW_DID,
+        lxm: `app.bsky.unspecced.initAgeAssurance`,
+      })
+
+      const appView = new AtpAgent({service: APPVIEW})
+      appView.sessionManager.session = {...agent.session!}
+      appView.sessionManager.session.accessJwt = token
+      appView.sessionManager.session.refreshJwt = ''
+
+      /*
+       * 2s wait is good actually. Email sending takes a hot sec and this helps
+       * ensure the email is ready for the user once they open their inbox.
+       */
+      const {data} = await wait(
+        2e3,
+        appView.app.bsky.unspecced.initAgeAssurance({
+          ...props,
+          countryCode: geolocation?.countryCode?.toUpperCase(),
+        }),
+      )
+
+      qc.setQueryData<AppBskyUnspeccedDefs.AgeAssuranceState>(
+        createAgeAssuranceQueryKey(agent.session?.did ?? 'never'),
+        () => data,
+      )
+    },
+    onError(e) {
+      if (!isNetworkError(e)) {
+        logger.error(`useInitAgeAssurance failed`, {
+          safeMessage: e,
+        })
+      }
+    },
+  })
+}
diff --git a/src/state/ageAssurance/useIsAgeAssuranceEnabled.ts b/src/state/ageAssurance/useIsAgeAssuranceEnabled.ts
new file mode 100644
index 000000000..5c1a7b1c4
--- /dev/null
+++ b/src/state/ageAssurance/useIsAgeAssuranceEnabled.ts
@@ -0,0 +1,13 @@
+import {useMemo} from 'react'
+
+import {useGate} from '#/lib/statsig/statsig'
+import {useGeolocation} from '#/state/geolocation'
+
+export function useIsAgeAssuranceEnabled() {
+  const gate = useGate()
+  const {geolocation} = useGeolocation()
+
+  return useMemo(() => {
+    return gate('age_assurance') && !!geolocation?.isAgeRestrictedGeo
+  }, [geolocation, gate])
+}
diff --git a/src/state/geolocation.tsx b/src/state/geolocation.tsx
index 83a42f21d..20b161ffe 100644
--- a/src/state/geolocation.tsx
+++ b/src/state/geolocation.tsx
@@ -25,6 +25,7 @@ const onGeolocationUpdate = (
  */
 export const DEFAULT_GEOLOCATION: Device['geolocation'] = {
   countryCode: undefined,
+  isAgeRestrictedGeo: false,
 }
 
 async function getGeolocation(): Promise<Device['geolocation']> {
@@ -39,6 +40,7 @@ async function getGeolocation(): Promise<Device['geolocation']> {
   if (json.countryCode) {
     return {
       countryCode: json.countryCode,
+      isAgeRestrictedGeo: json.isAgeRestrictedGeo ?? false,
     }
   } else {
     return undefined
@@ -66,7 +68,9 @@ export function beginResolveGeolocation() {
    */
   if (__DEV__) {
     geolocationResolution = new Promise(y => y({success: true}))
-    device.set(['geolocation'], DEFAULT_GEOLOCATION)
+    if (!device.get(['geolocation'])) {
+      device.set(['geolocation'], DEFAULT_GEOLOCATION)
+    }
     return
   }
 
diff --git a/src/state/queries/nuxs/definitions.ts b/src/state/queries/nuxs/definitions.ts
index 1947f857f..61657992f 100644
--- a/src/state/queries/nuxs/definitions.ts
+++ b/src/state/queries/nuxs/definitions.ts
@@ -7,6 +7,8 @@ export enum Nux {
   ExploreInterestsCard = 'ExploreInterestsCard',
   InitialVerificationAnnouncement = 'InitialVerificationAnnouncement',
   ActivitySubscriptions = 'ActivitySubscriptions',
+  AgeAssuranceDismissibleNotice = 'AgeAssuranceDismissibleNotice',
+  AgeAssuranceDismissibleHeaderButton = 'AgeAssuranceDismissibleHeaderButton',
 }
 
 export const nuxNames = new Set(Object.values(Nux))
@@ -28,6 +30,14 @@ export type AppNux = BaseNux<
       id: Nux.ActivitySubscriptions
       data: undefined
     }
+  | {
+      id: Nux.AgeAssuranceDismissibleNotice
+      data: undefined
+    }
+  | {
+      id: Nux.AgeAssuranceDismissibleHeaderButton
+      data: undefined
+    }
 >
 
 export const NuxSchemas: Record<Nux, zod.ZodObject<any> | undefined> = {
@@ -35,4 +45,6 @@ export const NuxSchemas: Record<Nux, zod.ZodObject<any> | undefined> = {
   [Nux.ExploreInterestsCard]: undefined,
   [Nux.InitialVerificationAnnouncement]: undefined,
   [Nux.ActivitySubscriptions]: undefined,
+  [Nux.AgeAssuranceDismissibleNotice]: undefined,
+  [Nux.AgeAssuranceDismissibleHeaderButton]: undefined,
 }
diff --git a/src/state/queries/nuxs/index.ts b/src/state/queries/nuxs/index.ts
index 6ad59c7a4..b9650d057 100644
--- a/src/state/queries/nuxs/index.ts
+++ b/src/state/queries/nuxs/index.ts
@@ -1,6 +1,6 @@
 import {useMutation, useQueryClient} from '@tanstack/react-query'
 
-import {AppNux, Nux} from '#/state/queries/nuxs/definitions'
+import {type AppNux, type Nux} from '#/state/queries/nuxs/definitions'
 import {parseAppNux, serializeAppNux} from '#/state/queries/nuxs/util'
 import {
   preferencesQueryKey,
@@ -40,6 +40,20 @@ export function useNuxs():
     }
   }
 
+  // if (__DEV__) {
+  //   const queryClient = useQueryClient()
+  //   const agent = useAgent()
+
+  //   // @ts-ignore
+  //   window.clearNux = async (ids: string[]) => {
+  //     await agent.bskyAppRemoveNuxs(ids)
+  //     // triggers a refetch
+  //     await queryClient.invalidateQueries({
+  //       queryKey: preferencesQueryKey,
+  //     })
+  //   }
+  // }
+
   return {
     nuxs: undefined,
     status,
diff --git a/src/state/queries/post-feed.ts b/src/state/queries/post-feed.ts
index 361081e67..22e95fcd6 100644
--- a/src/state/queries/post-feed.ts
+++ b/src/state/queries/post-feed.ts
@@ -8,6 +8,7 @@ import {
   type BskyAgent,
   moderatePost,
   type ModerationDecision,
+  type ModerationPrefs,
 } from '@atproto/api'
 import {
   type InfiniteData,
@@ -31,6 +32,7 @@ import {FeedTuner, type FeedTunerFn} from '#/lib/api/feed-manip'
 import {DISCOVER_FEED_URI} from '#/lib/constants'
 import {BSKY_FEED_OWNER_DIDS} from '#/lib/constants'
 import {logger} from '#/logger'
+import {useAgeAssuranceContext} from '#/state/ageAssurance'
 import {STALE} from '#/state/queries'
 import {DEFAULT_LOGGED_OUT_PREFERENCES} from '#/state/queries/preferences/const'
 import {useAgent} from '#/state/session'
@@ -134,8 +136,18 @@ export function usePostFeedQuery(
   const feedTuners = useFeedTuners(feedDesc)
   const moderationOpts = useModerationOpts()
   const {data: preferences} = usePreferencesQuery()
+  /**
+   * Load bearing: we need to await AA state or risk FOUC. This marginally
+   * delays feeds, but AA state is fetched immediately on load and is then
+   * available for the remainder of the session, so this delay only affects cold
+   * loads. -esb
+   */
+  const {isReady: isAgeAssuranceReady} = useAgeAssuranceContext()
   const enabled =
-    opts?.enabled !== false && Boolean(moderationOpts) && Boolean(preferences)
+    opts?.enabled !== false &&
+    Boolean(moderationOpts) &&
+    Boolean(preferences) &&
+    isAgeAssuranceReady
   const userInterests = aggregateUserInterests(preferences)
   const followingPinnedIndex =
     preferences?.savedFeeds?.findIndex(
@@ -206,7 +218,11 @@ export function usePostFeedQuery(
          * some not.
          */
         if (!agent.session) {
-          assertSomePostsPassModeration(res.feed)
+          assertSomePostsPassModeration(
+            res.feed,
+            preferences?.moderationPrefs ||
+              DEFAULT_LOGGED_OUT_PREFERENCES.moderationPrefs,
+          )
         }
 
         return {
@@ -596,7 +612,10 @@ export function* findAllProfilesInQueryData(
   }
 }
 
-function assertSomePostsPassModeration(feed: AppBskyFeedDefs.FeedViewPost[]) {
+function assertSomePostsPassModeration(
+  feed: AppBskyFeedDefs.FeedViewPost[],
+  moderationPrefs: ModerationPrefs,
+) {
   // no posts in this feed
   if (feed.length === 0) return true
 
@@ -606,7 +625,7 @@ function assertSomePostsPassModeration(feed: AppBskyFeedDefs.FeedViewPost[]) {
   for (const item of feed) {
     const moderation = moderatePost(item.post, {
       userDid: undefined,
-      prefs: DEFAULT_LOGGED_OUT_PREFERENCES.moderationPrefs,
+      prefs: moderationPrefs,
     })
 
     if (!moderation.ui('contentList').filter) {
diff --git a/src/state/queries/preferences/index.ts b/src/state/queries/preferences/index.ts
index e64f117e6..44d63b55c 100644
--- a/src/state/queries/preferences/index.ts
+++ b/src/state/queries/preferences/index.ts
@@ -1,3 +1,4 @@
+import {useCallback} from 'react'
 import {
   type AppBskyActorDefs,
   type BskyFeedViewPreference,
@@ -9,6 +10,8 @@ import {PROD_DEFAULT_FEED} from '#/lib/constants'
 import {replaceEqualDeep} from '#/lib/functions'
 import {getAge} from '#/lib/strings/time'
 import {logger} from '#/logger'
+import {useAgeAssuranceContext} from '#/state/ageAssurance'
+import {AGE_RESTRICTED_MODERATION_PREFS} from '#/state/ageAssurance/const'
 import {STALE} from '#/state/queries'
 import {
   DEFAULT_HOME_FEED_PREFS,
@@ -31,6 +34,8 @@ export const preferencesQueryKey = [preferencesQueryKeyRoot]
 
 export function usePreferencesQuery() {
   const agent = useAgent()
+  const {isAgeRestricted} = useAgeAssuranceContext()
+
   return useQuery({
     staleTime: STALE.SECONDS.FIFTEEN,
     structuralSharing: replaceEqualDeep,
@@ -68,6 +73,16 @@ export function usePreferencesQuery() {
         return preferences
       }
     },
+    select: useCallback(
+      (data: UsePreferencesQueryResponse) => {
+        const isUnderage = (data.userAge || 0) < 18
+        if (isUnderage || isAgeRestricted) {
+          data.moderationPrefs = AGE_RESTRICTED_MODERATION_PREFS
+        }
+        return data
+      },
+      [isAgeRestricted],
+    ),
   })
 }
 
diff --git a/src/storage/schema.ts b/src/storage/schema.ts
index 19c31834b..c05a7531d 100644
--- a/src/storage/schema.ts
+++ b/src/storage/schema.ts
@@ -7,6 +7,7 @@ export type Device = {
   lastNuxDialog: string | undefined
   geolocation?: {
     countryCode: string | undefined
+    isAgeRestrictedGeo: boolean | undefined
   }
   trendingBetaEnabled: boolean
   devMode: boolean
diff --git a/src/view/shell/index.tsx b/src/view/shell/index.tsx
index 8c08ec0c0..4d1a8c51b 100644
--- a/src/view/shell/index.tsx
+++ b/src/view/shell/index.tsx
@@ -25,6 +25,7 @@ import {ModalsContainer} from '#/view/com/modals/Modal'
 import {ErrorBoundary} from '#/view/com/util/ErrorBoundary'
 import {atoms as a, select, useTheme} from '#/alf'
 import {setSystemUITheme} from '#/alf/util/systemUI'
+import {AgeAssuranceRedirectDialog} from '#/components/ageAssurance/AgeAssuranceRedirectDialog'
 import {EmailDialog} from '#/components/dialogs/EmailDialog'
 import {InAppBrowserConsentDialog} from '#/components/dialogs/InAppBrowserConsent'
 import {LinkWarningDialog} from '#/components/dialogs/LinkWarning'
@@ -155,6 +156,7 @@ function ShellInner() {
       <MutedWordsDialog />
       <SigninDialog />
       <EmailDialog />
+      <AgeAssuranceRedirectDialog />
       <InAppBrowserConsentDialog />
       <LinkWarningDialog />
       <Lightbox />
diff --git a/src/view/shell/index.web.tsx b/src/view/shell/index.web.tsx
index 8969d68f8..77c3f45f6 100644
--- a/src/view/shell/index.web.tsx
+++ b/src/view/shell/index.web.tsx
@@ -17,6 +17,7 @@ import {Lightbox} from '#/view/com/lightbox/Lightbox'
 import {ModalsContainer} from '#/view/com/modals/Modal'
 import {ErrorBoundary} from '#/view/com/util/ErrorBoundary'
 import {atoms as a, select, useTheme} from '#/alf'
+import {AgeAssuranceRedirectDialog} from '#/components/ageAssurance/AgeAssuranceRedirectDialog'
 import {EmailDialog} from '#/components/dialogs/EmailDialog'
 import {LinkWarningDialog} from '#/components/dialogs/LinkWarning'
 import {MutedWordsDialog} from '#/components/dialogs/MutedWords'
@@ -70,6 +71,7 @@ function ShellInner() {
       <MutedWordsDialog />
       <SigninDialog />
       <EmailDialog />
+      <AgeAssuranceRedirectDialog />
       <LinkWarningDialog />
       <Lightbox />
       <PortalOutlet />
diff --git a/yarn.lock b/yarn.lock
index 79cc5e057..8079c90bf 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -55,36 +55,36 @@
   resolved "https://registry.yarnpkg.com/@atproto-labs/simple-store/-/simple-store-0.2.0.tgz#f39098747dabf8a245d0ed6edc50f362aa4d95f8"
   integrity sha512-0bRbAlI8Ayh03wRwncAMEAyUKtZ+AuTS1jgPrfym1WVOAOiottI/ZmgccqLl6w5MbxVcClNQF7WYGKvGwGoIhA==
 
-"@atproto-labs/xrpc-utils@0.0.16":
-  version "0.0.16"
-  resolved "https://registry.yarnpkg.com/@atproto-labs/xrpc-utils/-/xrpc-utils-0.0.16.tgz#f76c4f615685c60997401f052cbd9f0145d12576"
-  integrity sha512-WvTQhGjIhFrd/0pMGecE7Xn8BtvvKAgVlNs8UaE6CVRifiCOIvIBwlx1vnslJAavK3FtwL1kKkUdxNtxHciZSQ==
+"@atproto-labs/xrpc-utils@0.0.17":
+  version "0.0.17"
+  resolved "https://registry.yarnpkg.com/@atproto-labs/xrpc-utils/-/xrpc-utils-0.0.17.tgz#c9ff68943a20957ec6e41ed347c73072c53d8755"
+  integrity sha512-2kEfhe3F4GxW5grpfXxMo4fxHuEdDhj5D10YDJ0aC2BvYab9Y/67DDor7a63IptTgJooKNSweXochCUqOw4I8w==
   dependencies:
-    "@atproto/xrpc" "^0.7.0"
-    "@atproto/xrpc-server" "^0.8.0"
+    "@atproto/xrpc" "^0.7.1"
+    "@atproto/xrpc-server" "^0.9.0"
 
-"@atproto/api@^0.15.21":
-  version "0.15.21"
-  resolved "https://registry.yarnpkg.com/@atproto/api/-/api-0.15.21.tgz#6cd450c49dc30ea7baca4905b9046abf69f9c1bd"
-  integrity sha512-/VsikzVqIjNrdCk3eoJAleNcPUAGOLW8GCU9ymQMyGg1bBOCDb2Gl4eCqvhJ7Zd/UUyU5o8bh2YwLsY8/ikkeA==
+"@atproto/api@^0.15.26":
+  version "0.15.26"
+  resolved "https://registry.yarnpkg.com/@atproto/api/-/api-0.15.26.tgz#452019d6d0753d4caa0f7941e8e87e9f8bfbee52"
+  integrity sha512-AdXGjeCpLZiP9YMGi4YOdK1ayqkBhklmGfSG8UefqR6tTHth59PZvYs5KiwLnFhedt2Xljt3eUlhkn14Y48wEA==
   dependencies:
     "@atproto/common-web" "^0.4.2"
-    "@atproto/lexicon" "^0.4.11"
+    "@atproto/lexicon" "^0.4.12"
     "@atproto/syntax" "^0.4.0"
-    "@atproto/xrpc" "^0.7.0"
+    "@atproto/xrpc" "^0.7.1"
     await-lock "^2.2.2"
     multiformats "^9.9.0"
     tlds "^1.234.0"
     zod "^3.23.8"
 
-"@atproto/aws@^0.2.24":
-  version "0.2.24"
-  resolved "https://registry.yarnpkg.com/@atproto/aws/-/aws-0.2.24.tgz#c8e7a804710d70be3aa2fa292c1ece4c05127891"
-  integrity sha512-4XZQGitPJR56tFt1bzPJKOqp3vTVcfVsEAFo9FGWp7Es+jj742aVgfWEe64O0VoZp3ZTiD7XhwsLJArz7NJTlQ==
+"@atproto/aws@^0.2.25":
+  version "0.2.25"
+  resolved "https://registry.yarnpkg.com/@atproto/aws/-/aws-0.2.25.tgz#d07265a656db990ffd54b254cae54388468d1dca"
+  integrity sha512-LT4uuda2mjXz2WT4xo7g2aWmWKl+JWusGzscqQpOlD/RFGFXKDmUcVWLVPKY+9Pys2F7X6tyDlm2aUx+/dYdYA==
   dependencies:
     "@atproto/common" "^0.4.11"
     "@atproto/crypto" "^0.4.4"
-    "@atproto/repo" "^0.8.4"
+    "@atproto/repo" "^0.8.5"
     "@aws-sdk/client-cloudfront" "^3.261.0"
     "@aws-sdk/client-kms" "^3.196.0"
     "@aws-sdk/client-s3" "^3.224.0"
@@ -94,31 +94,33 @@
     multiformats "^9.9.0"
     uint8arrays "3.0.0"
 
-"@atproto/bsky@^0.0.167":
-  version "0.0.167"
-  resolved "https://registry.yarnpkg.com/@atproto/bsky/-/bsky-0.0.167.tgz#583eb404ef4de409e34d7c2485bf325e5d1f3ff0"
-  integrity sha512-VLgaVsx0fYeoXcFHP1KM6joda9Ovhb7LsE3JdES6+hhsAF74DFwW57mVzRfYhy1bwWn/m9poUMs1RkCjOR9ZJA==
+"@atproto/bsky@^0.0.172":
+  version "0.0.172"
+  resolved "https://registry.yarnpkg.com/@atproto/bsky/-/bsky-0.0.172.tgz#963e3e2bc661e05c03fcb58788b2621d1afa6a3e"
+  integrity sha512-P/oDJ4i4TuRBV8pQg8hht147jl+OruDYAXdVmUaHRMWPnry+godnfp6vzph9W9Y03DN+G320Z63dCfvFZquSUQ==
   dependencies:
     "@atproto-labs/fetch-node" "0.1.9"
-    "@atproto-labs/xrpc-utils" "0.0.16"
-    "@atproto/api" "^0.15.21"
+    "@atproto-labs/xrpc-utils" "0.0.17"
+    "@atproto/api" "^0.15.26"
     "@atproto/common" "^0.4.11"
     "@atproto/crypto" "^0.4.4"
     "@atproto/did" "^0.1.5"
     "@atproto/identity" "^0.4.8"
-    "@atproto/lexicon" "^0.4.11"
-    "@atproto/repo" "^0.8.4"
-    "@atproto/sync" "^0.1.28"
+    "@atproto/lexicon" "^0.4.12"
+    "@atproto/repo" "^0.8.5"
+    "@atproto/sync" "^0.1.29"
     "@atproto/syntax" "^0.4.0"
-    "@atproto/xrpc-server" "^0.8.0"
+    "@atproto/xrpc-server" "^0.9.0"
     "@bufbuild/protobuf" "^1.5.0"
     "@connectrpc/connect" "^1.1.4"
     "@connectrpc/connect-express" "^1.1.4"
     "@connectrpc/connect-node" "^1.1.4"
     "@did-plc/lib" "^0.0.1"
+    "@hapi/address" "^5.1.1"
     "@types/http-errors" "^2.0.1"
     compression "^1.7.4"
     cors "^2.8.5"
+    disposable-email-domains-js "^1.5.0"
     etcd3 "^1.1.2"
     express "^4.17.2"
     http-errors "^2.0.0"
@@ -139,6 +141,7 @@
     typed-emitter "^2.1.0"
     uint8arrays "3.0.0"
     undici "^6.19.8"
+    zod "3.23.8"
 
 "@atproto/bsync@^0.0.20":
   version "0.0.20"
@@ -218,23 +221,23 @@
     "@noble/hashes" "^1.6.1"
     uint8arrays "3.0.0"
 
-"@atproto/dev-env@^0.3.150":
-  version "0.3.150"
-  resolved "https://registry.yarnpkg.com/@atproto/dev-env/-/dev-env-0.3.150.tgz#6443206352398be1e3dd8bcfe980e7a21d2cd93a"
-  integrity sha512-LOujaEmOVBCxSnKQqpJb238fe5vYGIgmTA+OMEFH3kZb+6Y6UXfW2Vhs79tP0DiX0VyoXwib/7PH3Lp5cC/ZFQ==
+"@atproto/dev-env@^0.3.155":
+  version "0.3.155"
+  resolved "https://registry.yarnpkg.com/@atproto/dev-env/-/dev-env-0.3.155.tgz#26f865b92e9241d3e39c1c81e38c8422f2df26e2"
+  integrity sha512-KNrArdTAfrZQsRsFnwLC1Vgqh27brD5G2ZI0E3qF9BXQw2iiCH1r46IAhhggugL6xo3VXYucmM6/HGX+bKyavw==
   dependencies:
-    "@atproto/api" "^0.15.21"
-    "@atproto/bsky" "^0.0.167"
+    "@atproto/api" "^0.15.26"
+    "@atproto/bsky" "^0.0.172"
     "@atproto/bsync" "^0.0.20"
     "@atproto/common-web" "^0.4.2"
     "@atproto/crypto" "^0.4.4"
     "@atproto/identity" "^0.4.8"
-    "@atproto/lexicon" "^0.4.11"
-    "@atproto/ozone" "^0.1.126"
-    "@atproto/pds" "^0.4.156"
-    "@atproto/sync" "^0.1.28"
+    "@atproto/lexicon" "^0.4.12"
+    "@atproto/ozone" "^0.1.131"
+    "@atproto/pds" "^0.4.161"
+    "@atproto/sync" "^0.1.29"
     "@atproto/syntax" "^0.4.0"
-    "@atproto/xrpc-server" "^0.8.0"
+    "@atproto/xrpc-server" "^0.9.0"
     "@did-plc/lib" "^0.0.1"
     "@did-plc/server" "^0.0.1"
     dotenv "^16.0.3"
@@ -275,10 +278,10 @@
     multiformats "^9.9.0"
     zod "^3.23.8"
 
-"@atproto/lexicon@^0.4.11":
-  version "0.4.11"
-  resolved "https://registry.yarnpkg.com/@atproto/lexicon/-/lexicon-0.4.11.tgz#d5d09be1faf1d28d1e57051dab4064101f8b1617"
-  integrity sha512-btefdnvNz2Ao2I+qbmj0F06HC8IlrM/IBz6qOBS50r0S6uDf5tOO+Mv2tSVdimFkdzyDdLtBI1sV36ONxz2cOw==
+"@atproto/lexicon@^0.4.12":
+  version "0.4.12"
+  resolved "https://registry.yarnpkg.com/@atproto/lexicon/-/lexicon-0.4.12.tgz#89a704789d983f8405a52095769b5b58d87f5af7"
+  integrity sha512-fcEvEQ1GpQYF5igZ4IZjPWEoWVpsEF22L9RexxLS3ptfySXLflEyH384e7HITzO/73McDeaJx3lqHIuqn9ulnw==
   dependencies:
     "@atproto/common-web" "^0.4.2"
     "@atproto/syntax" "^0.4.0"
@@ -347,19 +350,19 @@
     "@atproto/jwk" "0.4.0"
     zod "^3.23.8"
 
-"@atproto/ozone@^0.1.126":
-  version "0.1.126"
-  resolved "https://registry.yarnpkg.com/@atproto/ozone/-/ozone-0.1.126.tgz#a4502121b9732a494a8b25a04be89b7eb0a4e2dd"
-  integrity sha512-h1yP1NArjjHlOam9wamGIUSrG9tGynkZ0+Y6t21u7dwrg1o/TRpXSXemCYZhtz3zqdd4Yu5VyavoWPtEFdr+rQ==
+"@atproto/ozone@^0.1.131":
+  version "0.1.131"
+  resolved "https://registry.yarnpkg.com/@atproto/ozone/-/ozone-0.1.131.tgz#b097c0274f424afd6af4e891cf78c563395b65b3"
+  integrity sha512-xGp0KPK89SXwtXHYza5kYNkwizCdXvn1sq0+5gwR6U8qNrvw0ibxBVMgtHTmqo9633tGD0c0woza1j+r5adm1w==
   dependencies:
-    "@atproto/api" "^0.15.21"
+    "@atproto/api" "^0.15.26"
     "@atproto/common" "^0.4.11"
     "@atproto/crypto" "^0.4.4"
     "@atproto/identity" "^0.4.8"
-    "@atproto/lexicon" "^0.4.11"
+    "@atproto/lexicon" "^0.4.12"
     "@atproto/syntax" "^0.4.0"
-    "@atproto/xrpc" "^0.7.0"
-    "@atproto/xrpc-server" "^0.8.0"
+    "@atproto/xrpc" "^0.7.1"
+    "@atproto/xrpc-server" "^0.9.0"
     "@did-plc/lib" "^0.0.1"
     compression "^1.7.4"
     cors "^2.8.5"
@@ -377,24 +380,24 @@
     undici "^6.14.1"
     ws "^8.12.0"
 
-"@atproto/pds@^0.4.156":
-  version "0.4.156"
-  resolved "https://registry.yarnpkg.com/@atproto/pds/-/pds-0.4.156.tgz#1815ced4ab8b51cf9fe9a5712cd136a0b1d82392"
-  integrity sha512-/8j/ihTLRhCI1sxkEvs2kuX4ehPKvsnwDxhmhdVvYqbKrjmGRTsDIZDV1K7dVFcYdCypOEPXsgTReh2lVhcC8w==
+"@atproto/pds@^0.4.161":
+  version "0.4.161"
+  resolved "https://registry.yarnpkg.com/@atproto/pds/-/pds-0.4.161.tgz#1f74675d5d5e4ca56361c89926571646b4c641ee"
+  integrity sha512-az1PGUCwIEx/b1neWBh9lgv27nbtczinGazI/ccG5A0AOMps1MXpTIJgEz5yJ1c6mSrhZlP0sL1EO6uC89irNA==
   dependencies:
     "@atproto-labs/fetch-node" "0.1.9"
-    "@atproto-labs/xrpc-utils" "0.0.16"
-    "@atproto/api" "^0.15.21"
-    "@atproto/aws" "^0.2.24"
+    "@atproto-labs/xrpc-utils" "0.0.17"
+    "@atproto/api" "^0.15.26"
+    "@atproto/aws" "^0.2.25"
     "@atproto/common" "^0.4.11"
     "@atproto/crypto" "^0.4.4"
     "@atproto/identity" "^0.4.8"
-    "@atproto/lexicon" "^0.4.11"
+    "@atproto/lexicon" "^0.4.12"
     "@atproto/oauth-provider" "^0.9.3"
-    "@atproto/repo" "^0.8.4"
+    "@atproto/repo" "^0.8.5"
     "@atproto/syntax" "^0.4.0"
-    "@atproto/xrpc" "^0.7.0"
-    "@atproto/xrpc-server" "^0.8.0"
+    "@atproto/xrpc" "^0.7.1"
+    "@atproto/xrpc-server" "^0.9.0"
     "@did-plc/lib" "^0.0.4"
     "@hapi/address" "^5.1.1"
     better-sqlite3 "^10.0.0"
@@ -424,32 +427,32 @@
     undici "^6.19.8"
     zod "^3.23.8"
 
-"@atproto/repo@^0.8.4":
-  version "0.8.4"
-  resolved "https://registry.yarnpkg.com/@atproto/repo/-/repo-0.8.4.tgz#f6a1b4bce8cf86cd1825069f9cd2916a5f86e774"
-  integrity sha512-WgyARo6UcOnhbRsRVuNjXOH5MPTTHVDsaIavPeQl5erq5foE/pQKC7B7FLTJmhpC6GPZHJ5M2doAyXRXv5UHGA==
+"@atproto/repo@^0.8.5":
+  version "0.8.5"
+  resolved "https://registry.yarnpkg.com/@atproto/repo/-/repo-0.8.5.tgz#b1e8d49ac92b813a210aa6a696496220010c99f8"
+  integrity sha512-QZ4UWBWDyPMXgPhktmaRYRyCXIw7lIEAyGtaFy7UmCPpJ5TtFKw3GhGrEiNz/fY3/6lrkdDj44/Tzkud/eP/VQ==
   dependencies:
     "@atproto/common" "^0.4.11"
     "@atproto/common-web" "^0.4.2"
     "@atproto/crypto" "^0.4.4"
-    "@atproto/lexicon" "^0.4.11"
+    "@atproto/lexicon" "^0.4.12"
     "@ipld/dag-cbor" "^7.0.0"
     multiformats "^9.9.0"
     uint8arrays "3.0.0"
     varint "^6.0.0"
     zod "^3.23.8"
 
-"@atproto/sync@^0.1.28":
-  version "0.1.28"
-  resolved "https://registry.yarnpkg.com/@atproto/sync/-/sync-0.1.28.tgz#7c5c469dd899b4be86e5d993af66646c71d63eaf"
-  integrity sha512-faCsOwcYQHxHmNWRPykV0hTccXaG15XoUMZozfmoFOKFSliTgDETTovSAVe05mNSBUvMWUGl8fdEwHRzq1Q8sA==
+"@atproto/sync@^0.1.29":
+  version "0.1.29"
+  resolved "https://registry.yarnpkg.com/@atproto/sync/-/sync-0.1.29.tgz#fb628e9f8a3562caa72fab6d3888790fc08ab29e"
+  integrity sha512-tKIhbY4rfCCfinBapnGf7X276dron9A7NT7VFB1Wa9NqODjoJPGDBaRG8s9WmeeIknMgJquhRJOrYu0hQUupTQ==
   dependencies:
     "@atproto/common" "^0.4.11"
     "@atproto/identity" "^0.4.8"
-    "@atproto/lexicon" "^0.4.11"
-    "@atproto/repo" "^0.8.4"
+    "@atproto/lexicon" "^0.4.12"
+    "@atproto/repo" "^0.8.5"
     "@atproto/syntax" "^0.4.0"
-    "@atproto/xrpc-server" "^0.8.0"
+    "@atproto/xrpc-server" "^0.9.0"
     multiformats "^9.9.0"
     p-queue "^6.6.2"
     ws "^8.12.0"
@@ -459,15 +462,15 @@
   resolved "https://registry.yarnpkg.com/@atproto/syntax/-/syntax-0.4.0.tgz#bec71552087bb24c208a06ef418c0040b65542f2"
   integrity sha512-b9y5ceHS8YKOfP3mdKmwAx5yVj9294UN7FG2XzP6V5aKUdFazEYRnR9m5n5ZQFKa3GNvz7de9guZCJ/sUTcOAA==
 
-"@atproto/xrpc-server@^0.8.0":
-  version "0.8.0"
-  resolved "https://registry.yarnpkg.com/@atproto/xrpc-server/-/xrpc-server-0.8.0.tgz#a32c9c71411ec6ee476fcd0260d5e9e80be348bd"
-  integrity sha512-jDAEVHVhM4IvC0y491gXBuD4b1D9/XrM3HaEronRneAdNZ0qE0nsiJNqiHfQ6r4BvFdHnABM9KyHV9EQTvmxfg==
+"@atproto/xrpc-server@^0.9.0":
+  version "0.9.0"
+  resolved "https://registry.yarnpkg.com/@atproto/xrpc-server/-/xrpc-server-0.9.0.tgz#d4b2f4194327eac456381b313280ac5c1c27ddd3"
+  integrity sha512-vREyUFx4EiOtPYfPHVF8x6vQThi/72ZkGSwxfFkFpUZp5PXCjagk3vFw0NH8GbbtQeSAPfdgrcZunfJJgLt4SQ==
   dependencies:
     "@atproto/common" "^0.4.11"
     "@atproto/crypto" "^0.4.4"
-    "@atproto/lexicon" "^0.4.11"
-    "@atproto/xrpc" "^0.7.0"
+    "@atproto/lexicon" "^0.4.12"
+    "@atproto/xrpc" "^0.7.1"
     cbor-x "^1.5.1"
     express "^4.17.2"
     http-errors "^2.0.0"
@@ -477,12 +480,12 @@
     ws "^8.12.0"
     zod "^3.23.8"
 
-"@atproto/xrpc@^0.7.0":
-  version "0.7.0"
-  resolved "https://registry.yarnpkg.com/@atproto/xrpc/-/xrpc-0.7.0.tgz#7d1e497d682431fecd7085d7482e83d8a33821b0"
-  integrity sha512-SfhP9dGx2qclaScFDb58Jnrmim5nk4geZXCqg6sB0I/KZhZEkr9iIx1hLCp+sxkIfEsmEJjeWO4B0rjUIJW5cw==
+"@atproto/xrpc@^0.7.1":
+  version "0.7.1"
+  resolved "https://registry.yarnpkg.com/@atproto/xrpc/-/xrpc-0.7.1.tgz#51a8fc131eb21bd1229129d0a46384accc50ad65"
+  integrity sha512-ANHEzlskYlMEdH18m+Itp3a8d0pEJao2qoDybDoMupTnoeNkya4VKIaOgAi6ERQnqatBBZyn9asW+7rJmSt/8g==
   dependencies:
-    "@atproto/lexicon" "^0.4.11"
+    "@atproto/lexicon" "^0.4.12"
     zod "^3.23.8"
 
 "@aws-crypto/crc32@3.0.0":