diff --git a/.eslintrc.js b/.eslintrc.js
index 726cc56074..59c1b4eb4f 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 ade7e10653..f3230c0d87 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 357879048a..cd7c482154 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 2278b73de6..87429d8458 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}>
-
-
-
- {/* LabelDefsProvider MUST come before ModerationOptsProvider */}
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
+
+
+
+
+ {/* LabelDefsProvider MUST come before ModerationOptsProvider */}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/App.web.tsx b/src/App.web.tsx
index b706774fdc..04de8529ff 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}>
-
-
-
- {/* LabelDefsProvider MUST come before ModerationOptsProvider */}
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
+
+
+
+
+ {/* LabelDefsProvider MUST come before ModerationOptsProvider */}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/components/LanguageSelect.tsx b/src/components/LanguageSelect.tsx
new file mode 100644
index 0000000000..2ad3949aee
--- /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 (
+
+
+
+
+
+ (
+
+
+ {label}
+
+ )}
+ items={items}
+ />
+
+ )
+}
diff --git a/src/components/PostControls/ShareMenu/ShareMenuItems.tsx b/src/components/PostControls/ShareMenu/ShareMenuItems.tsx
index 1c04f3174c..03b1137088 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()
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 (
<>
- {hasSession && (
+ {hasSession && !isAgeRestricted && (
diff --git a/src/components/PostControls/ShareMenu/ShareMenuItems.web.tsx b/src/components/PostControls/ShareMenu/ShareMenuItems.web.tsx
index 8d52a2fdf8..d074cdcf0c 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 = ({
{!hideInPWI && copyLinkItem}
- {hasSession && (
+ {hasSession && !isAgeRestricted && (
+}
+
+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 (
+ <>
+
+
+
+
+
+
+
+
+
+
+
+
+ {copy.notice}
+
+
+ {isBlocked ? (
+
+
+ You are currently unable to access Bluesky's Age Assurance flow.
+ Please{' '}
+ {
+ appealControl.open()
+ })}>
+ contact our moderation team
+ {' '}
+ if you believe this is an error.
+
+
+ ) : (
+ <>
+
+
+
+
+ {lastInitiatedAt && timeAgo && diff ? (
+
+ {diff.value === 0 ? (
+ Last initiated just now
+ ) : (
+ Last initiated {timeAgo} ago
+ )}
+
+ ) : (
+
+ Age assurance only takes a few minutes
+
+ )}
+
+ >
+ )}
+
+
+ >
+ )
+}
diff --git a/src/components/ageAssurance/AgeAssuranceAdmonition.tsx b/src/components/ageAssurance/AgeAssuranceAdmonition.tsx
new file mode 100644
index 0000000000..d140b7873e
--- /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 (
+
+ {children}
+
+ )
+}
+
+function Inner({
+ children,
+ style,
+}: ViewStyleProp & {
+ children: React.ReactNode
+ control: Dialog.DialogControlProps
+}) {
+ const t = useTheme()
+ const {_} = useLingui()
+
+ return (
+ <>
+
+
+
+
+
+
+ {children}
+
+
+ Learn more in your{' '}
+
+ account settings.
+
+
+
+
+
+
+ >
+ )
+}
diff --git a/src/components/ageAssurance/AgeAssuranceAppealDialog.tsx b/src/components/ageAssurance/AgeAssuranceAppealDialog.tsx
new file mode 100644
index 0000000000..166f6c26d9
--- /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 (
+
+
+
+
+
+
+
+ )
+}
+
+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 (
+
+
+
+
+
+
+ Contact us
+
+
+
+
+ Please provide any additional details you feel moderators may need in
+ order to properly assess your Age Assurance status.
+
+
+
+
+ {
+ setDetails(details)
+ }}
+ label={_(msg`Additional details (limit 1000 characters)`)}
+ numberOfLines={4}
+ onSubmitEditing={() => mutate()}
+ />
+
+
+
+
+
+
+ )
+}
diff --git a/src/components/ageAssurance/AgeAssuranceBadge.tsx b/src/components/ageAssurance/AgeAssuranceBadge.tsx
new file mode 100644
index 0000000000..030e30529d
--- /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 (
+
+
+
+ Age Assurance
+
+
+ )
+}
diff --git a/src/components/ageAssurance/AgeAssuranceDismissibleHeaderButton.tsx b/src/components/ageAssurance/AgeAssuranceDismissibleHeaderButton.tsx
new file mode 100644
index 0000000000..b6505fb0e0
--- /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 (
+
+
+
+
+ Age Assurance
+
+
+
+ )
+}
diff --git a/src/components/ageAssurance/AgeAssuranceDismissibleNotice.tsx b/src/components/ageAssurance/AgeAssuranceDismissibleNotice.tsx
new file mode 100644
index 0000000000..30e2fbec44
--- /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 (
+
+
+ {copy.notice}
+
+
+
+
+ )
+}
diff --git a/src/components/ageAssurance/AgeAssuranceInitDialog.tsx b/src/components/ageAssurance/AgeAssuranceInitDialog.tsx
new file mode 100644
index 0000000000..ad13cc1c23
--- /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 (
+
+
+
+
+
+
+
+
+ )
+}
+
+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('')
+ const [languageError, setLanguageError] = useState(false)
+ const [disabled, setDisabled] = useState(false)
+ const [language, setLanguage] = useState(
+ convertToKWSSupportedLanguage(langPrefs.appLanguage),
+ )
+ const [error, setError] = useState('')
+
+ 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 (
+
+
+
+
+
+ {success ? Success! : Verify your age}
+
+
+
+ {success ? (
+
+
+ Please check your email inbox for further instructions. It may
+ take a minute or two to arrive.
+
+
+ ) : (
+ <>
+
+
+ We use{' '}
+
+ KWS
+ {' '}
+ 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.
+
+
+
+ This should only take a few minutes.
+
+ >
+ )}
+
+
+ {success ? (
+
+
+
+ ) : (
+ <>
+
+
+
+ {wasRecentlyInitiated && (
+
+
+ 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.
+
+
+ )}
+
+
+
+ Your email
+
+
+ setEmailError('')}
+ onBlur={() => {
+ runEmailValidation()
+ }}
+ returnKeyType="done"
+ autoCapitalize="none"
+ autoComplete="off"
+ autoCorrect={false}
+ onSubmitEditing={onSubmit}
+ />
+
+
+ {emailError ? (
+
+ {emailError}
+
+ ) : (
+
+
+ Use your account email address, or another real email
+ address you control, in case KWS or Bluesky needs to
+ contact you.
+
+
+ )}
+
+
+
+
+ Your preferred language
+
+ {
+ setLanguage(value)
+ setLanguageError(false)
+ }}
+ items={KWS_SUPPORTED_LANGS}
+ />
+
+ {languageError && (
+
+ Please select a language
+
+ )}
+
+
+ {error && {error}}
+
+
+
+
+
+
+ By continuing, you agree to the{' '}
+
+ KWS Terms of Use
+ {' '}
+ and acknowledge that KWS will store your verified status with
+ your hashed email address in accordance with the{' '}
+
+ KWS Privacy Policy
+
+ . 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.
+
+
+ >
+ )}
+
+
+ )
+}
+
+// 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 0000000000..41e706feed
--- /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 (
+
+
+
+
+
+
+
+ )
+}
+
+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 (
+ <>
+
+
+
+
+ {error && }
+
+
+ {error ? Connection issue : Verifying}
+
+
+ {!error && }
+
+
+
+ {error ? (
+
+ We were unable to receive the verification due to a connection
+ issue. It may arrive later. If it does, your account will update
+ automatically.
+
+ ) : (
+
+ We're confirming your status with our servers. This dialog should
+ close in a few seconds.
+
+ )}
+
+
+ {error && isNative && (
+
+
+
+ )}
+
+
+ {error && }
+ >
+ )
+}
diff --git a/src/components/ageAssurance/AgeRestrictedScreen.tsx b/src/components/ageAssurance/AgeRestrictedScreen.tsx
new file mode 100644
index 0000000000..2a98824154
--- /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 (
+
+
+
+
+
+
+
+
+
+ )
+ }
+ if (!isAgeRestricted) return children
+
+ return (
+
+
+
+
+
+ {screenTitle ?? Unavailable}
+
+
+
+
+
+
+
+
+
+
+
+
+
+ You must verify your age in order to access this screen.
+
+
+
+
+ {copy.notice}
+
+
+
+
+
+
+ Go to account settings
+
+
+
+
+
+ {infoText && {infoText}}
+
+
+
+ )
+}
diff --git a/src/components/ageAssurance/const.ts b/src/components/ageAssurance/const.ts
new file mode 100644
index 0000000000..35f96e8417
--- /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 0000000000..045806994e
--- /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 1ee4d27398..8c700cafea 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
}
const ControlsContext = createContext(null)
@@ -46,6 +48,8 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
displayText: string
share?: boolean
}>()
+ const ageAssuranceRedirectDialogControl =
+ useStatefulDialogControl()
const ctx = useMemo(
() => ({
@@ -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 a951e5abfd..edbb12d0cb 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 (
- {!disabled && (
- {
- mutate({
- label: identifier,
- visibility: values[0] as LabelPreference,
- labelerDid: undefined,
- })
- }}
- ignoreLabel={labelOptions.ignore}
- warnLabel={labelOptions.warn}
- hideLabel={labelOptions.hide}
- />
- )}
+ {
+ mutate({
+ label: identifier,
+ visibility: values[0] as LabelPreference,
+ labelerDid: undefined,
+ })
+ }}
+ ignoreLabel={labelOptions.ignore}
+ warnLabel={labelOptions.warn}
+ hideLabel={labelOptions.hide}
+ disabled={disabled}
+ />
)
}
diff --git a/src/lib/constants.ts b/src/lib/constants.ts
index a21b92de56..3f0d499894 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 4a56537500..6b1083aa4b 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 0000000000..8ed872835a
--- /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()
+
+ 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 94b3f6de39..0d2f9ed092 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()
+ return useCallback(
+ async ({
+ isAgeRestricted: isAgeRestrictedOverride,
+ }: {
+ isAgeRestricted?: boolean
+ } = {}) => {
+ if (!isNative) return
- 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.
+ * 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 3b1106480d..efd7d605a0 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 88d8d9d93d..4743e866c4 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 388d23ec24..e13f0617b8 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
-export function MessagesScreen({navigation, route}: Props) {
+
+export function MessagesScreen(props: Props) {
+ const {_} = useLingui()
+ const aaCopy = useAgeAssuranceCopy()
+
+ return (
+
+
+
+ )
+}
+
+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 90547a8d4d..7f3b53b94d 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 (
+
+
+
+ )
+}
+
+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 0f64d20140..8765cf0baf 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
-export function MessagesInboxScreen({}: Props) {
+
+export function MessagesInboxScreen(props: Props) {
+ const {_} = useLingui()
+ const aaCopy = useAgeAssuranceCopy()
+ return (
+
+
+
+ )
+}
+
+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 0b8c88b9dd..6015c07cd5 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
-export function MessagesSettingsScreen({}: Props) {
+
+export function MessagesSettingsScreen(props: Props) {
+ const {_} = useLingui()
+ const aaCopy = useAgeAssuranceCopy()
+
+ return (
+
+
+
+ )
+}
+
+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 78b0a6ae9d..a7b434e528 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 (
-
+
+
+
+
+ Moderation
+
+
+
+
{isLoading ? (
@@ -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({
Content filters
+
+
+ You must complete age assurance in order to access the settings below.
+
+
+
- {ageNotSet && (
+ {declaredAge === undefined && (
<>
diff --git a/src/screens/Settings/AboutSettings.tsx b/src/screens/Settings/AboutSettings.tsx
index 0ce127ff3f..6be881a882 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) {
)}
+
+ {
+ device.set(['geolocation'], {
+ countryCode: 'GB',
+ isAgeRestrictedGeo: true,
+ })
+ }}
+ label="Simulate age restriction">
+
+
+ Simulate age restriction
+
+
>
)}
diff --git a/src/screens/Settings/AccountSettings.tsx b/src/screens/Settings/AccountSettings.tsx
index 393bad2f86..86652d2779 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) {
-
-
-
- Birthday
-
- birthdayControl.open()}
- />
-
openModal({name: 'change-password'})}>
@@ -143,6 +134,17 @@ export function AccountSettingsScreen({}: Props) {
+
+
+
+ Birthday
+
+ birthdayControl.open()}
+ />
+
+
+
+
+ ['ageAssurance', did] as const
+
+const DEFAULT_AGE_ASSURANCE_STATE: AppBskyUnspeccedDefs.AgeAssuranceState = {
+ lastInitiatedAt: undefined,
+ status: 'unknown',
+}
+
+const AgeAssuranceContext = createContext({
+ status: 'unknown',
+ isReady: false,
+ lastInitiatedAt: undefined,
+ isAgeRestricted: false,
+})
+
+const AgeAssuranceAPIContext = createContext({
+ // @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(() => {
+ 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(
+ () => ({
+ refetch,
+ }),
+ [refetch],
+ )
+
+ return (
+
+
+ {children}
+
+
+ )
+}
+
+/**
+ * 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 0000000000..63febb3cff
--- /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 0000000000..455f38c926
--- /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 & {
+ /**
+ * 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 0000000000..8776dd29cc
--- /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,
+ ) {
+ 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(
+ 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 0000000000..5c1a7b1c48
--- /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 83a42f21d0..20b161ffe1 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 {
@@ -39,6 +40,7 @@ async function getGeolocation(): Promise {
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 1947f857f1..61657992f3 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 | undefined> = {
@@ -35,4 +45,6 @@ export const NuxSchemas: Record | 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 6ad59c7a46..b9650d0579 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 361081e67e..22e95fcd63 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 e64f117e61..44d63b55c0 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 19c31834bb..c05a7531df 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 8c08ec0c03..4d1a8c51b6 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() {
+
diff --git a/src/view/shell/index.web.tsx b/src/view/shell/index.web.tsx
index 8969d68f89..77c3f45f62 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() {
+
diff --git a/yarn.lock b/yarn.lock
index 79cc5e0570..8079c90bfc 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":