From 8c2e4c6fadd28b7f8f4b255f778bc95a57a38505 Mon Sep 17 00:00:00 2001 From: DS Boyce <260543580+ds-boyce@users.noreply.github.com> Date: Mon, 20 Apr 2026 13:39:12 -0700 Subject: [PATCH] Write chat declaration record in response to different events (#10216) Co-authored-by: Eric Bailey --- src/ageAssurance/data.tsx | 35 ++- src/ageAssurance/index.tsx | 13 +- src/ageAssurance/state.ts | 211 ++++++++++++------ src/ageAssurance/util.ts | 21 +- src/components/dialogs/BirthDateSettings.tsx | 40 ++-- src/state/birthdate.ts | 6 + .../queries/messages/actor-declaration.ts | 24 +- .../queries/messages/restrictChatSettings.ts | 39 ++++ src/state/session/__tests__/session-test.ts | 3 + src/state/session/agent.ts | 31 +-- 10 files changed, 298 insertions(+), 125 deletions(-) create mode 100644 src/state/queries/messages/restrictChatSettings.ts diff --git a/src/ageAssurance/data.tsx b/src/ageAssurance/data.tsx index 2839d2b488..fc7f2c2b80 100644 --- a/src/ageAssurance/data.tsx +++ b/src/ageAssurance/data.tsx @@ -4,6 +4,7 @@ import { type AppBskyAgeassuranceGetConfig, type AppBskyAgeassuranceGetState, AtpAgent, + type ChatBskyActorDeclaration, getAgeAssuranceRegionConfig, } from '@atproto/api' import {createAsyncStoragePersister} from '@tanstack/query-async-storage-persister' @@ -19,6 +20,7 @@ import { hasSnoozedBirthdateUpdateForDid, snoozeBirthdateUpdateAllowedForDid, } from '#/state/birthdate' +import {fetchActorDeclarationRecord} from '#/state/queries/messages/actor-declaration' import {useAgent, useSession} from '#/state/session' import * as debug from '#/ageAssurance/debug' import {logger} from '#/ageAssurance/logger' @@ -53,7 +55,7 @@ const [, cacheHydrationPromise] = persistQueryClient({ persister, }) -function getDidFromAgentSession(agent: AtpAgent) { +export function getDidFromAgentSession(agent: AtpAgent) { const sessionManager = agent.sessionManager if (!sessionManager || !sessionManager.did) return return sessionManager.did @@ -329,19 +331,25 @@ export function useServerStateQuery() { export type OtherRequiredData = { birthdate: string | undefined + actorDeclaration?: ChatBskyActorDeclaration.Main } export function createOtherRequiredDataQueryKey({did}: {did: string}) { return ['otherRequiredData', did] } -export async function getOtherRequiredData({ +async function getOtherRequiredData({ agent, }: { agent: AtpAgent }): Promise { if (debug.enabled) return debug.resolve(debug.otherRequiredData) - const [prefs] = await Promise.all([agent.getPreferences()]) + const did = getDidFromAgentSession(agent) + const [prefs, actorDeclaration] = await Promise.all([ + agent.getPreferences(), + fetchActorDeclarationRecord({did, agent}), + ]) const data: OtherRequiredData = { birthdate: prefs.birthDate ? prefs.birthDate.toISOString() : undefined, + actorDeclaration, } /** @@ -359,7 +367,6 @@ export async function getOtherRequiredData({ } } - const did = getDidFromAgentSession(agent) if (data && did && birthdateCache.has(did)) { /* * If birthdate was just set, use the local cache value. On subsequent @@ -394,6 +401,26 @@ export function getOtherRequiredDataFromCache({ createOtherRequiredDataQueryKey({did}), ) } +export function setOtherRequiredDataActorDeclarationCache({ + did, + actorDeclaration, +}: { + did: string + actorDeclaration: ChatBskyActorDeclaration.Main +}) { + const prev = getOtherRequiredDataFromCache({did}) + const next: OtherRequiredData = { + birthdate: prev?.birthdate, + actorDeclaration: { + ...(prev?.actorDeclaration || {}), + ...actorDeclaration, + }, + } + qc.setQueryData( + createOtherRequiredDataQueryKey({did}), + next, + ) +} export async function prefetchOtherRequiredData({agent}: {agent: AtpAgent}) { const did = getDidFromAgentSession(agent) diff --git a/src/ageAssurance/index.tsx b/src/ageAssurance/index.tsx index 80b44f1dcf..c64492eff6 100644 --- a/src/ageAssurance/index.tsx +++ b/src/ageAssurance/index.tsx @@ -1,6 +1,7 @@ import {createContext, useCallback, useContext, useEffect, useMemo} from 'react' import {useGetAndRegisterPushToken} from '#/lib/notifications/notifications' +import {useAgent} from '#/state/session' import {Provider as RedirectOverlayProvider} from '#/ageAssurance/components/RedirectOverlay' import { AgeAssuranceDataProvider, @@ -18,6 +19,7 @@ import { } from '#/ageAssurance/types' import { isUnderAge, + maybeRestrictChatSettings, MIN_ACCESS_AGE, useAgeAssuranceRegionConfigWithFallback, } from '#/ageAssurance/util' @@ -78,6 +80,7 @@ export function Provider({children}: {children: React.ReactNode}) { } function InnerProvider({children}: {children: React.ReactNode}) { + const agent = useAgent() const state = useAgeAssuranceState() const {data} = useAgeAssuranceDataContext() const config = useAgeAssuranceRegionConfigWithFallback() @@ -85,11 +88,13 @@ function InnerProvider({children}: {children: React.ReactNode}) { const handleAccessUpdate = useCallback( (s: AgeAssuranceState) => { - void getAndRegisterPushToken({ - isAgeRestricted: s.access !== AgeAssuranceAccess.Full, - }) + const isAgeRestricted = s.access !== AgeAssuranceAccess.Full + if (isAgeRestricted) { + void getAndRegisterPushToken({isAgeRestricted}) + maybeRestrictChatSettings({agent}) + } }, - [getAndRegisterPushToken], + [agent, getAndRegisterPushToken], ) useOnAgeAssuranceAccessUpdate(handleAccessUpdate) diff --git a/src/ageAssurance/state.ts b/src/ageAssurance/state.ts index 6499a705e6..5aac40ef44 100644 --- a/src/ageAssurance/state.ts +++ b/src/ageAssurance/state.ts @@ -1,8 +1,15 @@ import {useEffect, useMemo, useState} from 'react' import {computeAgeAssuranceRegionAccess} from '@atproto/api' +import {getAge} from '#/lib/strings/time' import {useSession} from '#/state/session' -import {useAgeAssuranceDataContext} from '#/ageAssurance/data' +import { + type AgeAssuranceData, + getConfigFromCache, + getOtherRequiredDataFromCache, + getServerStateFromCache, + useAgeAssuranceDataContext, +} from '#/ageAssurance/data' import {logger} from '#/ageAssurance/logger' import { AgeAssuranceAccess, @@ -12,82 +19,144 @@ import { parseStatusFromString, } from '#/ageAssurance/types' import {getAgeAssuranceRegionConfigWithFallback} from '#/ageAssurance/util' -import {useGeolocation} from '#/geolocation' +import {type Geolocation, useGeolocation} from '#/geolocation' +import {device} from '#/storage' + +/** + * Get final evaluated age assurance state. Handles fallbacks and defers to + * server state before computing access based on AA config from the server + + * geolocation and other data. + */ +export function computeAgeAssuranceState({ + hasSession, + config, + geolocation, + state, + data, +}: { + hasSession: boolean + config: AgeAssuranceData['config'] + geolocation: Geolocation + state: AgeAssuranceData['state'] + data: AgeAssuranceData['data'] +}) { + /** + * This is where we control logged-out moderation prefs. It's all + * downstream of AA now. + */ + if (!hasSession) + return { + status: AgeAssuranceStatus.Unknown, + access: AgeAssuranceAccess.Safe, + } + + /** + * This can happen if the prefetch fails (such as due to network issues). + * The query handler will try it again, but if it continues to fail, of + * course we won't have config. + * + * In this case, fail open to avoid blocking users. + */ + if (!config) { + logger.warn('useAgeAssuranceState: missing config') + return { + status: AgeAssuranceStatus.Unknown, + access: AgeAssuranceAccess.Safe, + error: 'config' as const, + } + } + + const region = getAgeAssuranceRegionConfigWithFallback(config, geolocation) + const isAARequired = region.countryCode !== '*' + const isTerminalState = + state?.status === 'assured' || state?.status === 'blocked' + + /* + * If we are in a terminal state and AA is required for this region, + * we can trust the server state completely and avoid recomputing. + */ + if (isTerminalState && isAARequired) { + return { + lastInitiatedAt: state.lastInitiatedAt, + status: parseStatusFromString(state.status), + access: parseAccessFromString(state.access), + } + } + + /* + * Otherwise, we need to compute the access based on the latest data. For + * accounts with an accurate birthdate, our default fallback rules should + * ensure correct access. + */ + const result = computeAgeAssuranceRegionAccess(region, data) + const computed = { + lastInitiatedAt: state?.lastInitiatedAt, + // prefer server state + status: state?.status + ? parseStatusFromString(state?.status) + : AgeAssuranceStatus.Unknown, + // prefer server state + access: result + ? parseAccessFromString(result.access) + : AgeAssuranceAccess.Full, + } + logger.debug('debug useAgeAssuranceState', { + region, + state, + data, + computed, + }) + return computed +} + +/** + * This is a last-ditch helper for out-of-band reads of the AA state, such as + * during account creation. Don't use it for anything else. + */ +export function getAndComputeAgeAssuranceState({did}: {did: string}) { + const config = getConfigFromCache() + const state = getServerStateFromCache({did}) + const data = getOtherRequiredDataFromCache({did}) + const geolocation = device.get(['mergedGeolocation']) + + if (!geolocation || !config || !state || !data) { + return { + status: AgeAssuranceStatus.Unknown, + access: AgeAssuranceAccess.Safe, + } + } + + return computeAgeAssuranceState({ + hasSession: true, + config, + geolocation, + state: state.state, + data: { + accountCreatedAt: state.metadata?.accountCreatedAt, + declaredAge: data?.birthdate + ? getAge(new Date(data.birthdate)) + : undefined, + birthdate: data?.birthdate, + }, + }) +} export function useAgeAssuranceState(): AgeAssuranceState { const {hasSession} = useSession() const geolocation = useGeolocation() const {config, state, data} = useAgeAssuranceDataContext() - return useMemo(() => { - /** - * This is where we control logged-out moderation prefs. It's all - * downstream of AA now. - */ - if (!hasSession) - return { - status: AgeAssuranceStatus.Unknown, - access: AgeAssuranceAccess.Safe, - } - - /** - * This can happen if the prefetch fails (such as due to network issues). - * The query handler will try it again, but if it continues to fail, of - * course we won't have config. - * - * In this case, fail open to avoid blocking users. - */ - if (!config) { - logger.warn('useAgeAssuranceState: missing config') - return { - status: AgeAssuranceStatus.Unknown, - access: AgeAssuranceAccess.Safe, - error: 'config', - } - } - - const region = getAgeAssuranceRegionConfigWithFallback(config, geolocation) - const isAARequired = region.countryCode !== '*' - const isTerminalState = - state?.status === 'assured' || state?.status === 'blocked' - - /* - * If we are in a terminal state and AA is required for this region, - * we can trust the server state completely and avoid recomputing. - */ - if (isTerminalState && isAARequired) { - return { - lastInitiatedAt: state.lastInitiatedAt, - status: parseStatusFromString(state.status), - access: parseAccessFromString(state.access), - } - } - - /* - * Otherwise, we need to compute the access based on the latest data. For - * accounts with an accurate birthdate, our default fallback rules should - * ensure correct access. - */ - const result = computeAgeAssuranceRegionAccess(region, data) - const computed = { - lastInitiatedAt: state?.lastInitiatedAt, - // prefer server state - status: state?.status - ? parseStatusFromString(state?.status) - : AgeAssuranceStatus.Unknown, - // prefer server state - access: result - ? parseAccessFromString(result.access) - : AgeAssuranceAccess.Full, - } - logger.debug('debug useAgeAssuranceState', { - region, - state, - data, - computed, - }) - return computed - }, [hasSession, geolocation, config, state, data]) + return useMemo( + () => + computeAgeAssuranceState({ + hasSession, + config, + geolocation, + state, + data, + }), + [hasSession, geolocation, config, state, data], + ) } export function useOnAgeAssuranceAccessUpdate( diff --git a/src/ageAssurance/util.ts b/src/ageAssurance/util.ts index d55ec61762..310725db8f 100644 --- a/src/ageAssurance/util.ts +++ b/src/ageAssurance/util.ts @@ -2,13 +2,19 @@ import {useMemo} from 'react' import { ageAssuranceRuleIDs as ids, type AppBskyAgeassuranceDefs, + type AtpAgent, getAgeAssuranceRegionConfig, type ModerationPrefs, } from '@atproto/api' import {getAge} from '#/lib/strings/time' +import {restrictChatSettings} from '#/state/queries/messages/restrictChatSettings' import {DEFAULT_LOGGED_OUT_LABEL_PREFERENCES} from '#/state/queries/preferences/moderation' -import {useAgeAssuranceDataContext} from '#/ageAssurance/data' +import { + getDidFromAgentSession, + getOtherRequiredDataFromCache, + useAgeAssuranceDataContext, +} from '#/ageAssurance/data' import {AgeAssuranceAccess} from '#/ageAssurance/types' import {type Geolocation, useGeolocation} from '#/geolocation' @@ -109,3 +115,16 @@ export const makeAgeRestrictedModerationPrefs = ( adultContentEnabled: false, labels: DEFAULT_LOGGED_OUT_LABEL_PREFERENCES, }) + +/** + * Checks our cache of the actor's chat declaration record, and if it's not + * already restricted, restricts it. + */ +export function maybeRestrictChatSettings({agent}: {agent: AtpAgent}) { + const did = getDidFromAgentSession(agent) + if (!did) return + const data = getOtherRequiredDataFromCache({did}) + // ...update the chat setting record if allowIncoming is not already 'none'. + if (data?.actorDeclaration?.allowIncoming === 'none') return + restrictChatSettings({agent, did}) +} diff --git a/src/components/dialogs/BirthDateSettings.tsx b/src/components/dialogs/BirthDateSettings.tsx index d9de31dfdc..34d1952c70 100644 --- a/src/components/dialogs/BirthDateSettings.tsx +++ b/src/components/dialogs/BirthDateSettings.tsx @@ -1,8 +1,6 @@ import {useCallback, useMemo, useState} from 'react' import {View} from 'react-native' -import {msg} from '@lingui/core/macro' -import {useLingui} from '@lingui/react' -import {Trans} from '@lingui/react/macro' +import {Trans, useLingui} from '@lingui/react/macro' import {useCleanError} from '#/lib/hooks/useCleanError' import {isAppPassword} from '#/lib/jwt' @@ -34,7 +32,7 @@ export function BirthDateSettingsDialog({ control: Dialog.DialogControlProps }) { const t = useTheme() - const {_} = useLingui() + const {t: l} = useLingui() const {isLoading, error, data: preferences} = usePreferencesQuery() const isBirthdateUpdateAllowed = useIsBirthdateUpdateAllowed() const {currentAccount} = useSession() @@ -45,11 +43,11 @@ export function BirthDateSettingsDialog({ {isBirthdateUpdateAllowed ? ( - My Birthdate + My birthdate @@ -64,9 +62,7 @@ export function BirthDateSettingsDialog({ @@ -88,7 +84,7 @@ export function BirthDateSettingsDialog({ ) : ( { if (error) { - const {raw, clean} = cleanError(error) - return clean || raw || error.toString() + const e = error as Error + const {raw, clean} = cleanError(e) + return clean || raw || e.toString() } }, [error, cleanError]) @@ -146,7 +143,8 @@ function BirthdayInner({ await setBirthDate({birthDate: date}) } control.close() - } catch (e: any) { + } catch (error) { + const e = error as Error logger.error(`setBirthDate failed`, {message: e.message}) } }, [date, setBirthDate, control, hasChanged]) @@ -158,11 +156,10 @@ function BirthdayInner({ testID="birthdayInput" value={date} onChangeDate={newDate => setDate(new Date(newDate))} - label={_(msg`Birthdate`)} - accessibilityHint={_(msg`Enter your birthdate`)} + label={l`Birthdate`} + accessibilityHint={l`Enter your birthdate`} /> - {isUnder18 && hasChanged && ( @@ -171,30 +168,27 @@ function BirthdayInner({ )} - {isUnder13 && ( You must be at least 13 years old to use Bluesky. Read our{' '} + label={l`Terms of Service`}> Terms of Service {' '} for more information. )} - {errorMessage ? ( ) : undefined} -