import {useMemo} from 'react' import { type AppBskyAgeassuranceDefs, getAgeAssuranceRegionConfig, type ModerationPrefs, } from '@atproto/api' import {getAge} from '#/lib/strings/time' import {DEFAULT_LOGGED_OUT_LABEL_PREFERENCES} from '#/state/queries/preferences/const' import {FALLBACK_REGION_CONFIG, MIN_ACCESS_AGE} from '#/ageAssurance/const' import {useAgeAssuranceServerDataContext} from '#/ageAssurance/data' import { AgeAssuranceAccess, type AgeAssuranceConfigRegion, type AgeAssuranceDeviceSignals, type AgeAssuranceFlags, type AgeAssuranceMetadata, type AgeAssuranceState, type AgeAssuranceVerificationMethod, } from '#/ageAssurance/types' import {type Geolocation, useGeolocation} from '#/geolocation' /** * Get age assurance region config based on geolocation, with fallback to * app defaults if no region config is found. * * See {@link getAgeAssuranceRegionConfig} for the generic option, which can * return undefined if the geolocation does not match any AA region. */ export function getAgeAssuranceRegionConfigWithFallback( config: AppBskyAgeassuranceDefs.Config, geolocation: Geolocation, ): AppBskyAgeassuranceDefs.ConfigRegion { const region = getAgeAssuranceRegionConfig(config, { countryCode: geolocation.countryCode ?? '', regionCode: geolocation.regionCode, }) return region || FALLBACK_REGION_CONFIG } /** * Returns the verification methods permitted for a region, defaulting to * `['kws']` when the region doesn't specify any (the historical behavior). * * NOTE: `verificationMethods` is not yet part of the lexicon, so we read it via * {@link AgeAssuranceConfigRegion}. See that type for the migration note. */ export function getRegionVerificationMethods( region: AppBskyAgeassuranceDefs.ConfigRegion, ): AgeAssuranceVerificationMethod[] { const methods = (region as AgeAssuranceConfigRegion).verificationMethods return methods && methods.length > 0 ? methods : ['kws'] } /** * Whether a region permits satisfying age assurance via the native on-device * age APIs (Apple Declared Age Range / Google Play Age Signals). */ export function regionAllowsDeviceVerification( region: AppBskyAgeassuranceDefs.ConfigRegion, ): boolean { return getRegionVerificationMethods(region).includes('device') } /** * Whether two regions refer to the same country + region. Used to ensure device * signals are only applied within the region they were captured in. */ function isSameRegion( a: {countryCode: string; regionCode?: string}, b: {countryCode: string; regionCode?: string}, ): boolean { return a.countryCode === b.countryCode && a.regionCode === b.regionCode } /** * Derives an assured age from native device signals, but only when: * * 1. the current region permits device verification, and * 2. the signals were captured in this same region. * * Device assurance is region-bound (see {@link AgeAssuranceDeviceSignals}): a * grant captured in TX must not unlock another region. The OS-provided * `lowerBound` is the minimum age the platform will attest to, which maps * directly onto the `assuredAge` input of the rule engine (i.e. * `IfAssuredOverAge`/`IfAssuredUnderAge` rules). * * Returns undefined when device verification doesn't apply or the OS didn't * provide a usable lower bound. */ export function getAssuredAgeFromDeviceSignals( region: AppBskyAgeassuranceDefs.ConfigRegion, deviceSignals: AgeAssuranceDeviceSignals | undefined, ): number | undefined { if (!regionAllowsDeviceVerification(region)) return undefined if (!deviceSignals) return undefined if ( !isSameRegion(deviceSignals.originRegion, { countryCode: region.countryCode, regionCode: region.regionCode, }) ) { return undefined } const lowerBound = deviceSignals.signals.lowerBound return typeof lowerBound === 'number' ? lowerBound : undefined } /** * Hook to get the age assurance region config based on current geolocation. * Does not fall-back to our app defaults. If no config is found, returns * undefined, which indicates no regional age assurance rules apply. */ export function useAgeAssuranceRegionConfig() { const geolocation = useGeolocation() const {config} = useAgeAssuranceServerDataContext() return useMemo(() => { if (!config) return // use generic helper, we want to potentially return undefined return getAgeAssuranceRegionConfig(config, { countryCode: geolocation.countryCode ?? '', regionCode: geolocation.regionCode, }) }, [config, geolocation]) } /** * Hook to get the age assurance region config based on current geolocation. * Falls back to our app defaults if no region config is found. */ export function useAgeAssuranceRegionConfigWithFallback() { return useAgeAssuranceRegionConfig() || FALLBACK_REGION_CONFIG } /** * Some users may have erroneously set their birth date to the current date * if one wasn't set on their account. We previously didn't do validation on * the bday dialog, and it defaulted to the current date. This bug _has_ been * seen in production, so we need to check for it where possible. */ export function isLegacyBirthdateBug(birthDate: string) { return ['2025', '2024', '2023'].includes((birthDate || '').slice(0, 4)) } /** * Returns whether the date (converted to an age as a whole integer) is under * the provided minimum age. */ export function isUnderAge(birthDate: string, age: number) { return getAge(new Date(birthDate)) < age } export function getBirthdateStringFromAge(age: number) { const today = new Date() return new Date( today.getFullYear() - age, today.getMonth(), today.getDate() - 1, // set to day before to ensure age is reached ).toISOString() } export const makeAgeRestrictedModerationPrefs = ( prefs: ModerationPrefs, ): ModerationPrefs => ({ ...prefs, adultContentEnabled: false, labels: DEFAULT_LOGGED_OUT_LABEL_PREFERENCES, }) export function computeAgeAssuranceFlags({ state, regionConfig, metadata, }: { state: AgeAssuranceState regionConfig: AppBskyAgeassuranceDefs.ConfigRegion metadata?: AgeAssuranceMetadata }): AgeAssuranceFlags { const isAgeRestricted = state.access !== AgeAssuranceAccess.Full const chatDisabled = isAgeRestricted const isDeclaredUnderAdultAge = metadata?.declaredAge ? metadata.declaredAge < 18 : true const groupChatDisabled = chatDisabled || isDeclaredUnderAdultAge const isOverRegionMinAccessAge = metadata?.declaredAge ? metadata.declaredAge >= regionConfig.minAccessAge : false const isOverAppMinAccessAge = metadata?.declaredAge ? metadata.declaredAge >= MIN_ACCESS_AGE : false const adultContentDisabled = state.access !== AgeAssuranceAccess.Full || isDeclaredUnderAdultAge return { isAgeRestricted, adultContentDisabled, chatDisabled, groupChatDisabled, isDeclaredUnderAdultAge, isOverRegionMinAccessAge, isOverAppMinAccessAge, } }