Age Assurance V2 (#9479)
* Age Assurance V2 * Tighten up test * Add todos for sdk migration * Align RQ versions * Use useEffect for side effect * Improve effects, memoize * Standarize on birthdate * Copy feedback * Copilot * Add support link * Reove double .. * Cleanup * Remove redirect dialog * Cleanup todos, add comments * Update splash in main template too * Mock some stuff * Exhaustive checks Co-authored-by: Samuel Newman <mozzius@protonmail.com> * Exhaustive checks Co-authored-by: Samuel Newman <mozzius@protonmail.com> * Small fix to bday handling * Add comment * onboarding style tweak sneaking this in sorry! * rm unreachable breaks * Put useIntentHandler back on web * Remove misleading success set * Align on birthdate --------- Co-authored-by: Samuel Newman <mozzius@protonmail.com>
This commit is contained in:
@@ -0,0 +1 @@
|
||||
export const snoozeBirthdateUpdateAllowedForDid = () => {}
|
||||
@@ -1,11 +0,0 @@
|
||||
import {type ModerationPrefs} from '@atproto/api'
|
||||
|
||||
import {DEFAULT_LOGGED_OUT_LABEL_PREFERENCES} from '#/state/queries/preferences/moderation'
|
||||
|
||||
export const makeAgeRestrictedModerationPrefs = (
|
||||
prefs: ModerationPrefs,
|
||||
): ModerationPrefs => ({
|
||||
...prefs,
|
||||
adultContentEnabled: false,
|
||||
labels: DEFAULT_LOGGED_OUT_LABEL_PREFERENCES,
|
||||
})
|
||||
@@ -1,156 +0,0 @@
|
||||
import {createContext, useContext, useMemo, useState} from 'react'
|
||||
import {type AppBskyUnspeccedDefs} from '@atproto/api'
|
||||
import {useQuery} from '@tanstack/react-query'
|
||||
|
||||
import {networkRetry} from '#/lib/async/retry'
|
||||
import {useGetAndRegisterPushToken} from '#/lib/notifications/notifications'
|
||||
import {isNetworkError} from '#/lib/strings/errors'
|
||||
import {
|
||||
type AgeAssuranceAPIContextType,
|
||||
type AgeAssuranceContextType,
|
||||
} from '#/state/ageAssurance/types'
|
||||
import {useIsAgeAssuranceEnabled} from '#/state/ageAssurance/useIsAgeAssuranceEnabled'
|
||||
import {logger} from '#/state/ageAssurance/util'
|
||||
import {useGeolocationStatus} from '#/state/geolocation'
|
||||
import {useAgent} from '#/state/session'
|
||||
|
||||
export const createAgeAssuranceQueryKey = (did: string) =>
|
||||
['ageAssurance', did] as const
|
||||
|
||||
const DEFAULT_AGE_ASSURANCE_STATE: AppBskyUnspeccedDefs.AgeAssuranceState = {
|
||||
lastInitiatedAt: undefined,
|
||||
status: 'unknown',
|
||||
}
|
||||
|
||||
const AgeAssuranceContext = createContext<AgeAssuranceContextType>({
|
||||
status: 'unknown',
|
||||
isReady: false,
|
||||
lastInitiatedAt: undefined,
|
||||
isAgeRestricted: false,
|
||||
})
|
||||
AgeAssuranceContext.displayName = 'AgeAssuranceContext'
|
||||
|
||||
const AgeAssuranceAPIContext = createContext<AgeAssuranceAPIContextType>({
|
||||
// @ts-ignore can't be bothered to type this
|
||||
refetch: () => Promise.resolve(),
|
||||
})
|
||||
AgeAssuranceAPIContext.displayName = 'AgeAssuranceAPIContext'
|
||||
|
||||
/**
|
||||
* 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 agent = useAgent()
|
||||
const {status: geolocation} = useGeolocationStatus()
|
||||
const isAgeAssuranceEnabled = useIsAgeAssuranceEnabled()
|
||||
const getAndRegisterPushToken = useGetAndRegisterPushToken()
|
||||
const [refetchWhilePending, setRefetchWhilePending] = useState(false)
|
||||
|
||||
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,
|
||||
refetchOnWindowFocus: refetchWhilePending,
|
||||
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,
|
||||
})
|
||||
|
||||
await getAndRegisterPushToken({
|
||||
isAgeRestricted:
|
||||
!!geolocation?.isAgeRestrictedGeo && data.status !== 'assured',
|
||||
})
|
||||
|
||||
return data
|
||||
} catch (e) {
|
||||
if (!isNetworkError(e)) {
|
||||
logger.error(`ageAssurance: failed to fetch`, {safeMessage: e})
|
||||
}
|
||||
// don't re-throw error, we'll just fall back to defaults
|
||||
return null
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
/**
|
||||
* Derive state, or fall back to defaults
|
||||
*/
|
||||
const ageAssuranceContext = useMemo<AgeAssuranceContextType>(() => {
|
||||
const {status, lastInitiatedAt} = data || DEFAULT_AGE_ASSURANCE_STATE
|
||||
const ctx: AgeAssuranceContextType = {
|
||||
isReady: isFetched || !isAgeAssuranceEnabled,
|
||||
status,
|
||||
lastInitiatedAt,
|
||||
isAgeRestricted: isAgeAssuranceEnabled ? status !== 'assured' : false,
|
||||
}
|
||||
logger.debug(`context`, ctx)
|
||||
return ctx
|
||||
}, [isFetched, data, isAgeAssuranceEnabled])
|
||||
|
||||
if (
|
||||
!!ageAssuranceContext.lastInitiatedAt &&
|
||||
ageAssuranceContext.status === 'pending' &&
|
||||
!refetchWhilePending
|
||||
) {
|
||||
/*
|
||||
* If we have a pending state, we want to refetch on window focus to ensure
|
||||
* that we get the latest state when the user returns to the app.
|
||||
*/
|
||||
setRefetchWhilePending(true)
|
||||
} else if (
|
||||
!!ageAssuranceContext.lastInitiatedAt &&
|
||||
ageAssuranceContext.status !== 'pending' &&
|
||||
refetchWhilePending
|
||||
) {
|
||||
setRefetchWhilePending(false)
|
||||
}
|
||||
|
||||
const ageAssuranceAPIContext = useMemo<AgeAssuranceAPIContextType>(
|
||||
() => ({
|
||||
refetch,
|
||||
}),
|
||||
[refetch],
|
||||
)
|
||||
|
||||
return (
|
||||
<AgeAssuranceAPIContext.Provider value={ageAssuranceAPIContext}>
|
||||
<AgeAssuranceContext.Provider value={ageAssuranceContext}>
|
||||
{children}
|
||||
</AgeAssuranceContext.Provider>
|
||||
</AgeAssuranceAPIContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Access to low-level AA state. Prefer using {@link useAgeInfo} for a
|
||||
* more user-friendly interface.
|
||||
*/
|
||||
export function useAgeAssuranceContext() {
|
||||
return useContext(AgeAssuranceContext)
|
||||
}
|
||||
|
||||
export function useAgeAssuranceAPIContext() {
|
||||
return useContext(AgeAssuranceAPIContext)
|
||||
}
|
||||
@@ -1,33 +0,0 @@
|
||||
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']
|
||||
}
|
||||
@@ -1,44 +0,0 @@
|
||||
import {useMemo} from 'react'
|
||||
|
||||
import {useAgeAssuranceContext} from '#/state/ageAssurance'
|
||||
import {logger} from '#/state/ageAssurance/util'
|
||||
import {usePreferencesQuery} from '#/state/queries/preferences'
|
||||
|
||||
type AgeAssurance = ReturnType<typeof useAgeAssuranceContext> & {
|
||||
/**
|
||||
* The age the user has declared in their preferences, if any.
|
||||
*/
|
||||
declaredAge: number | undefined
|
||||
/**
|
||||
* Indicates whether the user has declared an age under 18.
|
||||
*/
|
||||
isDeclaredUnderage: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Computed age information based on age assurance status and the user's
|
||||
* declared age. Use this instead of {@link useAgeAssuranceContext} to get a
|
||||
* more user-friendly interface.
|
||||
*/
|
||||
export function useAgeAssurance(): AgeAssurance {
|
||||
const aa = useAgeAssuranceContext()
|
||||
const {isFetched: preferencesLoaded, data: preferences} =
|
||||
usePreferencesQuery()
|
||||
const declaredAge = preferences?.userAge
|
||||
|
||||
return useMemo(() => {
|
||||
const isReady = aa.isReady && preferencesLoaded
|
||||
const isDeclaredUnderage =
|
||||
declaredAge !== undefined ? declaredAge < 18 : false
|
||||
const state: AgeAssurance = {
|
||||
isReady,
|
||||
status: aa.status,
|
||||
lastInitiatedAt: aa.lastInitiatedAt,
|
||||
isAgeRestricted: aa.isAgeRestricted,
|
||||
declaredAge,
|
||||
isDeclaredUnderage,
|
||||
}
|
||||
logger.debug(`state`, state)
|
||||
return state
|
||||
}, [aa, preferencesLoaded, declaredAge])
|
||||
}
|
||||
@@ -1,102 +0,0 @@
|
||||
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 {type DeviceLocation, useGeolocationStatus} 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 = ``
|
||||
// }
|
||||
|
||||
/**
|
||||
* Creates an ISO country code string from the given geolocation data.
|
||||
* Examples: `GB` or `GB-ENG`
|
||||
*/
|
||||
function createISOCountryCode(
|
||||
geolocation: Omit<DeviceLocation, 'countryCode'> & {
|
||||
countryCode: string
|
||||
},
|
||||
): string {
|
||||
return geolocation.countryCode.toUpperCase()
|
||||
}
|
||||
|
||||
export function useInitAgeAssurance() {
|
||||
const qc = useQueryClient()
|
||||
const agent = useAgent()
|
||||
const {status: geolocation} = useGeolocationStatus()
|
||||
return useMutation({
|
||||
async mutationFn(
|
||||
props: Omit<AppBskyUnspeccedInitAgeAssurance.InputSchema, 'countryCode'>,
|
||||
) {
|
||||
const countryCode = geolocation?.countryCode
|
||||
const regionCode = geolocation?.regionCode
|
||||
if (!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: createISOCountryCode({
|
||||
countryCode,
|
||||
regionCode,
|
||||
}),
|
||||
}),
|
||||
)
|
||||
|
||||
qc.setQueryData<AppBskyUnspeccedDefs.AgeAssuranceState>(
|
||||
createAgeAssuranceQueryKey(agent.session?.did ?? 'never'),
|
||||
() => data,
|
||||
)
|
||||
},
|
||||
onError(e) {
|
||||
if (!isNetworkError(e)) {
|
||||
logger.error(`useInitAgeAssurance failed`, {
|
||||
safeMessage: e,
|
||||
})
|
||||
}
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -1,11 +0,0 @@
|
||||
import {useMemo} from 'react'
|
||||
|
||||
import {useGeolocationStatus} from '#/state/geolocation'
|
||||
|
||||
export function useIsAgeAssuranceEnabled() {
|
||||
const {status: geolocation} = useGeolocationStatus()
|
||||
|
||||
return useMemo(() => {
|
||||
return !!geolocation?.isAgeRestrictedGeo
|
||||
}, [geolocation])
|
||||
}
|
||||
@@ -1,3 +0,0 @@
|
||||
import {Logger} from '#/logger'
|
||||
|
||||
export const logger = Logger.create(Logger.Context.AgeAssurance)
|
||||
@@ -0,0 +1,64 @@
|
||||
import {useMemo} from 'react'
|
||||
import {useMutation, useQueryClient} from '@tanstack/react-query'
|
||||
|
||||
import {preferencesQueryKey} from '#/state/queries/preferences'
|
||||
import {useAgent, useSession} from '#/state/session'
|
||||
import {usePatchAgeAssuranceOtherRequiredData} from '#/ageAssurance'
|
||||
import {IS_DEV} from '#/env'
|
||||
import {account} from '#/storage'
|
||||
|
||||
// 6s in dev, 48h in prod
|
||||
const BIRTHDATE_DELAY_HOURS = IS_DEV ? 0.001 : 48
|
||||
|
||||
/**
|
||||
* Stores the timestamp of the birthday update locally. This is used to
|
||||
* debounce birthday updates globally.
|
||||
*
|
||||
* Use {@link useIsBirthDateUpdateAllowed} to check if an update is allowed.
|
||||
*/
|
||||
export function snoozeBirthdateUpdateAllowedForDid(did: string) {
|
||||
account.set([did, 'birthdateLastUpdatedAt'], new Date().toISOString())
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns whether a birthdate update is currently allowed, based on the
|
||||
* last update timestamp stored locally.
|
||||
*/
|
||||
export function useIsBirthdateUpdateAllowed() {
|
||||
const {currentAccount} = useSession()
|
||||
return useMemo(() => {
|
||||
if (!currentAccount) return false
|
||||
const lastUpdated = account.get([
|
||||
currentAccount.did,
|
||||
'birthdateLastUpdatedAt',
|
||||
])
|
||||
if (!lastUpdated) return true
|
||||
const lastUpdatedDate = new Date(lastUpdated)
|
||||
const diffMs = Date.now() - lastUpdatedDate.getTime()
|
||||
const diffHours = diffMs / (1000 * 60 * 60)
|
||||
return diffHours >= BIRTHDATE_DELAY_HOURS
|
||||
}, [currentAccount])
|
||||
}
|
||||
|
||||
export function useBirthdateMutation() {
|
||||
const queryClient = useQueryClient()
|
||||
const agent = useAgent()
|
||||
const patchOtherRequiredData = usePatchAgeAssuranceOtherRequiredData()
|
||||
|
||||
return useMutation<void, unknown, {birthDate: Date}>({
|
||||
mutationFn: async ({birthDate}: {birthDate: Date}) => {
|
||||
const bday = birthDate.toISOString()
|
||||
await agent.setPersonalDetails({birthDate: bday})
|
||||
// triggers a refetch
|
||||
await queryClient.invalidateQueries({
|
||||
queryKey: preferencesQueryKey,
|
||||
})
|
||||
/**
|
||||
* Also patch the age assurance other required data with the new
|
||||
* birthdate, which may change the user's age assurance access level.
|
||||
*/
|
||||
patchOtherRequiredData({birthdate: bday})
|
||||
snoozeBirthdateUpdateAllowedForDid(agent.sessionManager.did!)
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -1,141 +0,0 @@
|
||||
import {networkRetry} from '#/lib/async/retry'
|
||||
import {
|
||||
DEFAULT_GEOLOCATION_CONFIG,
|
||||
GEOLOCATION_CONFIG_URL,
|
||||
} from '#/state/geolocation/const'
|
||||
import {emitGeolocationConfigUpdate} from '#/state/geolocation/events'
|
||||
import {logger} from '#/state/geolocation/logger'
|
||||
import {BAPP_CONFIG_DEV_BYPASS_SECRET, IS_DEV} from '#/env'
|
||||
import {type Device, device} from '#/storage'
|
||||
|
||||
async function getGeolocationConfig(
|
||||
url: string,
|
||||
): Promise<Device['geolocation']> {
|
||||
const res = await fetch(url, {
|
||||
headers: IS_DEV
|
||||
? {
|
||||
'x-dev-bypass-secret': BAPP_CONFIG_DEV_BYPASS_SECRET,
|
||||
}
|
||||
: undefined,
|
||||
})
|
||||
|
||||
if (!res.ok) {
|
||||
throw new Error(`config: fetch failed ${res.status}`)
|
||||
}
|
||||
|
||||
const json = await res.json()
|
||||
|
||||
if (json.countryCode) {
|
||||
/**
|
||||
* Only construct known values here, ignore any extras.
|
||||
*/
|
||||
const config: Device['geolocation'] = {
|
||||
countryCode: json.countryCode,
|
||||
regionCode: json.regionCode ?? undefined,
|
||||
ageRestrictedGeos: json.ageRestrictedGeos ?? [],
|
||||
ageBlockedGeos: json.ageBlockedGeos ?? [],
|
||||
}
|
||||
logger.debug(`config: success`)
|
||||
return config
|
||||
} else {
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Local promise used within this file only.
|
||||
*/
|
||||
let geolocationConfigResolution: Promise<{success: boolean}> | undefined
|
||||
|
||||
/**
|
||||
* Begin the process of resolving geolocation config. This should be called
|
||||
* once at app start.
|
||||
*
|
||||
* THIS METHOD SHOULD NEVER THROW.
|
||||
*
|
||||
* This method is otherwise not used for any purpose. To ensure geolocation
|
||||
* config is resolved, use {@link ensureGeolocationConfigIsResolved}
|
||||
*/
|
||||
export function beginResolveGeolocationConfig() {
|
||||
/**
|
||||
* Here for debug purposes. Uncomment to prevent hitting the remote geo service, and apply whatever data you require for testing.
|
||||
*/
|
||||
// if (__DEV__) {
|
||||
// geolocationConfigResolution = new Promise(y => y({success: true}))
|
||||
// device.set(['deviceGeolocation'], undefined) // clears GPS data
|
||||
// device.set(['geolocation'], DEFAULT_GEOLOCATION_CONFIG) // clears bapp-config data
|
||||
// return
|
||||
// }
|
||||
|
||||
geolocationConfigResolution = new Promise(async resolve => {
|
||||
let success = true
|
||||
|
||||
try {
|
||||
// Try once, fail fast
|
||||
const config = await getGeolocationConfig(GEOLOCATION_CONFIG_URL)
|
||||
if (config) {
|
||||
device.set(['geolocation'], config)
|
||||
emitGeolocationConfigUpdate(config)
|
||||
} else {
|
||||
// endpoint should throw on all failures, this is insurance
|
||||
throw new Error(
|
||||
`geolocation config: nothing returned from initial request`,
|
||||
)
|
||||
}
|
||||
} catch (e: any) {
|
||||
success = false
|
||||
|
||||
logger.debug(`config: failed initial request`, {
|
||||
safeMessage: e.message,
|
||||
})
|
||||
|
||||
// set to default
|
||||
device.set(['geolocation'], DEFAULT_GEOLOCATION_CONFIG)
|
||||
|
||||
// retry 3 times, but don't await, proceed with default
|
||||
networkRetry(3, () => getGeolocationConfig(GEOLOCATION_CONFIG_URL))
|
||||
.then(config => {
|
||||
if (config) {
|
||||
device.set(['geolocation'], config)
|
||||
emitGeolocationConfigUpdate(config)
|
||||
success = true
|
||||
} else {
|
||||
// endpoint should throw on all failures, this is insurance
|
||||
throw new Error(`config: nothing returned from retries`)
|
||||
}
|
||||
})
|
||||
.catch((e: any) => {
|
||||
// complete fail closed
|
||||
logger.debug(`config: failed retries`, {
|
||||
safeMessage: e.message,
|
||||
})
|
||||
})
|
||||
} finally {
|
||||
resolve({success})
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensure that geolocation config has been resolved, or at the very least attempted
|
||||
* once. Subsequent retries will not be captured by this `await`. Those will be
|
||||
* reported via {@link emitGeolocationConfigUpdate}.
|
||||
*/
|
||||
export async function ensureGeolocationConfigIsResolved() {
|
||||
if (!geolocationConfigResolution) {
|
||||
throw new Error(`config: beginResolveGeolocationConfig not called yet`)
|
||||
}
|
||||
|
||||
const cached = device.get(['geolocation'])
|
||||
if (cached) {
|
||||
logger.debug(`config: using cache`)
|
||||
} else {
|
||||
logger.debug(`config: no cache`)
|
||||
const {success} = await geolocationConfigResolution
|
||||
if (success) {
|
||||
logger.debug(`config: resolved`)
|
||||
} else {
|
||||
logger.info(`config: failed to resolve`)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,30 +0,0 @@
|
||||
import {type GeolocationStatus} from '#/state/geolocation/types'
|
||||
import {BAPP_CONFIG_DEV_URL, IS_DEV} from '#/env'
|
||||
import {type Device} from '#/storage'
|
||||
|
||||
export const IPCC_URL = `https://bsky.app/ipcc`
|
||||
export const BAPP_CONFIG_URL_PROD = `https://ip.bsky.app/config`
|
||||
export const BAPP_CONFIG_URL = IS_DEV
|
||||
? (BAPP_CONFIG_DEV_URL ?? BAPP_CONFIG_URL_PROD)
|
||||
: BAPP_CONFIG_URL_PROD
|
||||
export const GEOLOCATION_CONFIG_URL = BAPP_CONFIG_URL
|
||||
|
||||
/**
|
||||
* Default geolocation config.
|
||||
*/
|
||||
export const DEFAULT_GEOLOCATION_CONFIG: Device['geolocation'] = {
|
||||
countryCode: undefined,
|
||||
regionCode: undefined,
|
||||
ageRestrictedGeos: [],
|
||||
ageBlockedGeos: [],
|
||||
}
|
||||
|
||||
/**
|
||||
* Default geolocation status.
|
||||
*/
|
||||
export const DEFAULT_GEOLOCATION_STATUS: GeolocationStatus = {
|
||||
countryCode: undefined,
|
||||
regionCode: undefined,
|
||||
isAgeRestrictedGeo: false,
|
||||
isAgeBlockedGeo: false,
|
||||
}
|
||||
@@ -1,19 +0,0 @@
|
||||
import EventEmitter from 'eventemitter3'
|
||||
|
||||
import {type Device} from '#/storage'
|
||||
|
||||
const events = new EventEmitter()
|
||||
const EVENT = 'geolocation-config-updated'
|
||||
|
||||
export const emitGeolocationConfigUpdate = (config: Device['geolocation']) => {
|
||||
events.emit(EVENT, config)
|
||||
}
|
||||
|
||||
export const onGeolocationConfigUpdate = (
|
||||
listener: (config: Device['geolocation']) => void,
|
||||
) => {
|
||||
events.on(EVENT, listener)
|
||||
return () => {
|
||||
events.off(EVENT, listener)
|
||||
}
|
||||
}
|
||||
@@ -1,155 +0,0 @@
|
||||
import React from 'react'
|
||||
|
||||
import {
|
||||
DEFAULT_GEOLOCATION_CONFIG,
|
||||
DEFAULT_GEOLOCATION_STATUS,
|
||||
} from '#/state/geolocation/const'
|
||||
import {onGeolocationConfigUpdate} from '#/state/geolocation/events'
|
||||
import {logger} from '#/state/geolocation/logger'
|
||||
import {
|
||||
type DeviceLocation,
|
||||
type GeolocationStatus,
|
||||
} from '#/state/geolocation/types'
|
||||
import {useSyncedDeviceGeolocation} from '#/state/geolocation/useSyncedDeviceGeolocation'
|
||||
import {
|
||||
computeGeolocationStatus,
|
||||
mergeGeolocation,
|
||||
} from '#/state/geolocation/util'
|
||||
import {type Device, device} from '#/storage'
|
||||
|
||||
export * from '#/state/geolocation/config'
|
||||
export * from '#/state/geolocation/types'
|
||||
export * from '#/state/geolocation/util'
|
||||
|
||||
type DeviceGeolocationContext = {
|
||||
deviceGeolocation: DeviceLocation | undefined
|
||||
}
|
||||
|
||||
type DeviceGeolocationAPIContext = {
|
||||
setDeviceGeolocation(deviceGeolocation: DeviceLocation): void
|
||||
}
|
||||
|
||||
type GeolocationConfigContext = {
|
||||
config: Device['geolocation']
|
||||
}
|
||||
|
||||
type GeolocationStatusContext = {
|
||||
/**
|
||||
* Merged geolocation from config and device GPS (if available).
|
||||
*/
|
||||
location: DeviceLocation
|
||||
/**
|
||||
* Computed geolocation status based on the merged location and config.
|
||||
*/
|
||||
status: GeolocationStatus
|
||||
}
|
||||
|
||||
const DeviceGeolocationContext = React.createContext<DeviceGeolocationContext>({
|
||||
deviceGeolocation: undefined,
|
||||
})
|
||||
DeviceGeolocationContext.displayName = 'DeviceGeolocationContext'
|
||||
|
||||
const DeviceGeolocationAPIContext =
|
||||
React.createContext<DeviceGeolocationAPIContext>({
|
||||
setDeviceGeolocation: () => {},
|
||||
})
|
||||
DeviceGeolocationAPIContext.displayName = 'DeviceGeolocationAPIContext'
|
||||
|
||||
const GeolocationConfigContext = React.createContext<GeolocationConfigContext>({
|
||||
config: DEFAULT_GEOLOCATION_CONFIG,
|
||||
})
|
||||
GeolocationConfigContext.displayName = 'GeolocationConfigContext'
|
||||
|
||||
const GeolocationStatusContext = React.createContext<GeolocationStatusContext>({
|
||||
location: {
|
||||
countryCode: undefined,
|
||||
regionCode: undefined,
|
||||
},
|
||||
status: DEFAULT_GEOLOCATION_STATUS,
|
||||
})
|
||||
GeolocationStatusContext.displayName = 'GeolocationStatusContext'
|
||||
|
||||
/**
|
||||
* Provider of geolocation config and computed geolocation status.
|
||||
*/
|
||||
export function GeolocationStatusProvider({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode
|
||||
}) {
|
||||
const {deviceGeolocation} = React.useContext(DeviceGeolocationContext)
|
||||
const [config, setConfig] = React.useState(() => {
|
||||
const initial = device.get(['geolocation']) || DEFAULT_GEOLOCATION_CONFIG
|
||||
return initial
|
||||
})
|
||||
|
||||
React.useEffect(() => {
|
||||
return onGeolocationConfigUpdate(config => {
|
||||
setConfig(config!)
|
||||
})
|
||||
}, [])
|
||||
|
||||
const configContext = React.useMemo(() => ({config}), [config])
|
||||
const statusContext = React.useMemo(() => {
|
||||
if (deviceGeolocation?.countryCode) {
|
||||
logger.debug('has device geolocation available')
|
||||
}
|
||||
const geolocation = mergeGeolocation(deviceGeolocation, config)
|
||||
const status = computeGeolocationStatus(geolocation, config)
|
||||
// ensure this remains debug and never leaves device
|
||||
logger.debug('result', {deviceGeolocation, geolocation, status, config})
|
||||
return {location: geolocation, status}
|
||||
}, [config, deviceGeolocation])
|
||||
|
||||
return (
|
||||
<GeolocationConfigContext.Provider value={configContext}>
|
||||
<GeolocationStatusContext.Provider value={statusContext}>
|
||||
{children}
|
||||
</GeolocationStatusContext.Provider>
|
||||
</GeolocationConfigContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Provider of providers. Provides device geolocation data to lower-level
|
||||
* `GeolocationStatusProvider`, and device geolocation APIs to children.
|
||||
*/
|
||||
export function Provider({children}: {children: React.ReactNode}) {
|
||||
const [deviceGeolocation, setDeviceGeolocation] = useSyncedDeviceGeolocation()
|
||||
|
||||
const handleSetDeviceGeolocation = React.useCallback(
|
||||
(location: DeviceLocation) => {
|
||||
logger.debug('setting device geolocation')
|
||||
setDeviceGeolocation({
|
||||
countryCode: location.countryCode ?? undefined,
|
||||
regionCode: location.regionCode ?? undefined,
|
||||
})
|
||||
},
|
||||
[setDeviceGeolocation],
|
||||
)
|
||||
|
||||
return (
|
||||
<DeviceGeolocationAPIContext.Provider
|
||||
value={React.useMemo(
|
||||
() => ({setDeviceGeolocation: handleSetDeviceGeolocation}),
|
||||
[handleSetDeviceGeolocation],
|
||||
)}>
|
||||
<DeviceGeolocationContext.Provider
|
||||
value={React.useMemo(() => ({deviceGeolocation}), [deviceGeolocation])}>
|
||||
<GeolocationStatusProvider>{children}</GeolocationStatusProvider>
|
||||
</DeviceGeolocationContext.Provider>
|
||||
</DeviceGeolocationAPIContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
export function useDeviceGeolocationApi() {
|
||||
return React.useContext(DeviceGeolocationAPIContext)
|
||||
}
|
||||
|
||||
export function useGeolocationConfig() {
|
||||
return React.useContext(GeolocationConfigContext)
|
||||
}
|
||||
|
||||
export function useGeolocationStatus() {
|
||||
return React.useContext(GeolocationStatusContext)
|
||||
}
|
||||
@@ -1,3 +0,0 @@
|
||||
import {Logger} from '#/logger'
|
||||
|
||||
export const logger = Logger.create(Logger.Context.Geolocation)
|
||||
@@ -1,9 +0,0 @@
|
||||
export type DeviceLocation = {
|
||||
countryCode: string | undefined
|
||||
regionCode: string | undefined
|
||||
}
|
||||
|
||||
export type GeolocationStatus = DeviceLocation & {
|
||||
isAgeRestrictedGeo: boolean
|
||||
isAgeBlockedGeo: boolean
|
||||
}
|
||||
@@ -1,43 +0,0 @@
|
||||
import {useCallback} from 'react'
|
||||
import * as Location from 'expo-location'
|
||||
|
||||
import {type DeviceLocation} from '#/state/geolocation/types'
|
||||
import {getDeviceGeolocation} from '#/state/geolocation/util'
|
||||
|
||||
export {PermissionStatus} from 'expo-location'
|
||||
|
||||
export function useRequestDeviceLocation(): () => Promise<
|
||||
| {
|
||||
granted: true
|
||||
location: DeviceLocation | undefined
|
||||
}
|
||||
| {
|
||||
granted: false
|
||||
status: {
|
||||
canAskAgain: boolean
|
||||
/**
|
||||
* Enum, use `PermissionStatus` export for comparisons
|
||||
*/
|
||||
permissionStatus: Location.PermissionStatus
|
||||
}
|
||||
}
|
||||
> {
|
||||
return useCallback(async () => {
|
||||
const status = await Location.requestForegroundPermissionsAsync()
|
||||
|
||||
if (status.granted) {
|
||||
return {
|
||||
granted: true,
|
||||
location: await getDeviceGeolocation(),
|
||||
}
|
||||
} else {
|
||||
return {
|
||||
granted: false,
|
||||
status: {
|
||||
canAskAgain: status.canAskAgain,
|
||||
permissionStatus: status.status,
|
||||
},
|
||||
}
|
||||
}
|
||||
}, [])
|
||||
}
|
||||
@@ -1,93 +0,0 @@
|
||||
import {useEffect, useRef} from 'react'
|
||||
import * as Location from 'expo-location'
|
||||
import {createPermissionHook} from 'expo-modules-core'
|
||||
|
||||
import {logger} from '#/state/geolocation/logger'
|
||||
import {getDeviceGeolocation} from '#/state/geolocation/util'
|
||||
import {device, useStorage} from '#/storage'
|
||||
|
||||
/**
|
||||
* Location.useForegroundPermissions on web just errors if the navigator.permissions API is not available.
|
||||
* We need to catch and ignore it, since it's effectively denied.
|
||||
* @see https://github.com/expo/expo/blob/72f1562ed9cce5ff6dfe04aa415b71632a3d4b87/packages/expo-location/src/Location.ts#L290-L293
|
||||
*/
|
||||
const useForegroundPermissions = createPermissionHook({
|
||||
getMethod: () =>
|
||||
Location.getForegroundPermissionsAsync().catch(error => {
|
||||
logger.debug(
|
||||
'useForegroundPermission: error getting location permissions',
|
||||
{safeMessage: error},
|
||||
)
|
||||
return {
|
||||
status: Location.PermissionStatus.DENIED,
|
||||
granted: false,
|
||||
canAskAgain: false,
|
||||
expires: 0,
|
||||
}
|
||||
}),
|
||||
requestMethod: () =>
|
||||
Location.requestForegroundPermissionsAsync().catch(error => {
|
||||
logger.debug(
|
||||
'useForegroundPermission: error requesting location permissions',
|
||||
{safeMessage: error},
|
||||
)
|
||||
return {
|
||||
status: Location.PermissionStatus.DENIED,
|
||||
granted: false,
|
||||
canAskAgain: false,
|
||||
expires: 0,
|
||||
}
|
||||
}),
|
||||
})
|
||||
|
||||
/**
|
||||
* Hook to get and sync the device geolocation from the device GPS and store it
|
||||
* using device storage. If permissions are not granted, it will clear any cached
|
||||
* storage value.
|
||||
*/
|
||||
export function useSyncedDeviceGeolocation() {
|
||||
const synced = useRef(false)
|
||||
const [status] = useForegroundPermissions()
|
||||
const [deviceGeolocation, setDeviceGeolocation] = useStorage(device, [
|
||||
'deviceGeolocation',
|
||||
])
|
||||
|
||||
useEffect(() => {
|
||||
async function get() {
|
||||
// no need to set this more than once per session
|
||||
if (synced.current) return
|
||||
|
||||
logger.debug('useSyncedDeviceGeolocation: checking perms')
|
||||
|
||||
if (status?.granted) {
|
||||
const location = await getDeviceGeolocation()
|
||||
if (location) {
|
||||
logger.debug('useSyncedDeviceGeolocation: syncing location')
|
||||
setDeviceGeolocation(location)
|
||||
synced.current = true
|
||||
}
|
||||
} else {
|
||||
const hasCachedValue = device.get(['deviceGeolocation']) !== undefined
|
||||
|
||||
/**
|
||||
* If we have a cached value, but user has revoked permissions,
|
||||
* quietly (will take effect lazily) clear this out.
|
||||
*/
|
||||
if (hasCachedValue) {
|
||||
logger.debug(
|
||||
'useSyncedDeviceGeolocation: clearing cached location, perms revoked',
|
||||
)
|
||||
device.set(['deviceGeolocation'], undefined)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
get().catch(e => {
|
||||
logger.error('useSyncedDeviceGeolocation: failed to sync', {
|
||||
safeMessage: e,
|
||||
})
|
||||
})
|
||||
}, [status, setDeviceGeolocation])
|
||||
|
||||
return [deviceGeolocation, setDeviceGeolocation] as const
|
||||
}
|
||||
@@ -1,180 +0,0 @@
|
||||
import {
|
||||
getCurrentPositionAsync,
|
||||
type LocationGeocodedAddress,
|
||||
reverseGeocodeAsync,
|
||||
} from 'expo-location'
|
||||
|
||||
import {logger} from '#/state/geolocation/logger'
|
||||
import {type DeviceLocation} from '#/state/geolocation/types'
|
||||
import {type Device} from '#/storage'
|
||||
|
||||
/**
|
||||
* Maps full US region names to their short codes.
|
||||
*
|
||||
* Context: in some cases, like on Android, we get the full region name instead
|
||||
* of the short code. We may need to expand this in the future to other
|
||||
* countries, hence the prefix.
|
||||
*/
|
||||
export const USRegionNameToRegionCode: {
|
||||
[regionName: string]: string
|
||||
} = {
|
||||
Alabama: 'AL',
|
||||
Alaska: 'AK',
|
||||
Arizona: 'AZ',
|
||||
Arkansas: 'AR',
|
||||
California: 'CA',
|
||||
Colorado: 'CO',
|
||||
Connecticut: 'CT',
|
||||
Delaware: 'DE',
|
||||
Florida: 'FL',
|
||||
Georgia: 'GA',
|
||||
Hawaii: 'HI',
|
||||
Idaho: 'ID',
|
||||
Illinois: 'IL',
|
||||
Indiana: 'IN',
|
||||
Iowa: 'IA',
|
||||
Kansas: 'KS',
|
||||
Kentucky: 'KY',
|
||||
Louisiana: 'LA',
|
||||
Maine: 'ME',
|
||||
Maryland: 'MD',
|
||||
Massachusetts: 'MA',
|
||||
Michigan: 'MI',
|
||||
Minnesota: 'MN',
|
||||
Mississippi: 'MS',
|
||||
Missouri: 'MO',
|
||||
Montana: 'MT',
|
||||
Nebraska: 'NE',
|
||||
Nevada: 'NV',
|
||||
['New Hampshire']: 'NH',
|
||||
['New Jersey']: 'NJ',
|
||||
['New Mexico']: 'NM',
|
||||
['New York']: 'NY',
|
||||
['North Carolina']: 'NC',
|
||||
['North Dakota']: 'ND',
|
||||
Ohio: 'OH',
|
||||
Oklahoma: 'OK',
|
||||
Oregon: 'OR',
|
||||
Pennsylvania: 'PA',
|
||||
['Rhode Island']: 'RI',
|
||||
['South Carolina']: 'SC',
|
||||
['South Dakota']: 'SD',
|
||||
Tennessee: 'TN',
|
||||
Texas: 'TX',
|
||||
Utah: 'UT',
|
||||
Vermont: 'VT',
|
||||
Virginia: 'VA',
|
||||
Washington: 'WA',
|
||||
['West Virginia']: 'WV',
|
||||
Wisconsin: 'WI',
|
||||
Wyoming: 'WY',
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalizes a `LocationGeocodedAddress` into a `DeviceLocation`.
|
||||
*
|
||||
* We don't want or care about the full location data, so we trim it down and
|
||||
* normalize certain fields, like region, into the format we need.
|
||||
*/
|
||||
export function normalizeDeviceLocation(
|
||||
location: LocationGeocodedAddress,
|
||||
): DeviceLocation {
|
||||
let {isoCountryCode, region} = location
|
||||
|
||||
if (region) {
|
||||
if (isoCountryCode === 'US') {
|
||||
region = USRegionNameToRegionCode[region] ?? region
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
countryCode: isoCountryCode ?? undefined,
|
||||
regionCode: region ?? undefined,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Combines precise location data with the geolocation config fetched from the
|
||||
* IP service, with preference to the precise data.
|
||||
*/
|
||||
export function mergeGeolocation(
|
||||
location?: DeviceLocation,
|
||||
config?: Device['geolocation'],
|
||||
): DeviceLocation {
|
||||
if (location?.countryCode) return location
|
||||
return {
|
||||
countryCode: config?.countryCode,
|
||||
regionCode: config?.regionCode,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Computes the geolocation status (age-restricted, age-blocked) based on the
|
||||
* given location and geolocation config. `location` here should be merged with
|
||||
* `mergeGeolocation()` ahead of time if needed.
|
||||
*/
|
||||
export function computeGeolocationStatus(
|
||||
location: DeviceLocation,
|
||||
config: Device['geolocation'],
|
||||
) {
|
||||
/**
|
||||
* We can't do anything if we don't have this data.
|
||||
*/
|
||||
if (!location.countryCode) {
|
||||
return {
|
||||
...location,
|
||||
isAgeRestrictedGeo: false,
|
||||
isAgeBlockedGeo: false,
|
||||
}
|
||||
}
|
||||
|
||||
const isAgeRestrictedGeo = config?.ageRestrictedGeos?.some(rule => {
|
||||
if (rule.countryCode === location.countryCode) {
|
||||
if (!rule.regionCode) {
|
||||
return true // whole country is blocked
|
||||
} else if (rule.regionCode === location.regionCode) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
const isAgeBlockedGeo = config?.ageBlockedGeos?.some(rule => {
|
||||
if (rule.countryCode === location.countryCode) {
|
||||
if (!rule.regionCode) {
|
||||
return true // whole country is blocked
|
||||
} else if (rule.regionCode === location.regionCode) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
return {
|
||||
...location,
|
||||
isAgeRestrictedGeo: !!isAgeRestrictedGeo,
|
||||
isAgeBlockedGeo: !!isAgeBlockedGeo,
|
||||
}
|
||||
}
|
||||
|
||||
export async function getDeviceGeolocation(): Promise<DeviceLocation> {
|
||||
try {
|
||||
const geocode = await getCurrentPositionAsync()
|
||||
const locations = await reverseGeocodeAsync({
|
||||
latitude: geocode.coords.latitude,
|
||||
longitude: geocode.coords.longitude,
|
||||
})
|
||||
const location = locations.at(0)
|
||||
const normalized = location ? normalizeDeviceLocation(location) : undefined
|
||||
return {
|
||||
countryCode: normalized?.countryCode ?? undefined,
|
||||
regionCode: normalized?.regionCode ?? undefined,
|
||||
}
|
||||
} catch (e) {
|
||||
logger.error('getDeviceGeolocation: failed', {
|
||||
safeMessage: e,
|
||||
})
|
||||
return {
|
||||
countryCode: undefined,
|
||||
regionCode: undefined,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -31,7 +31,6 @@ import {aggregateUserInterests} from '#/lib/api/feed/utils'
|
||||
import {FeedTuner, type FeedTunerFn} from '#/lib/api/feed-manip'
|
||||
import {DISCOVER_FEED_URI} 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'
|
||||
@@ -141,12 +140,8 @@ export function usePostFeedQuery(
|
||||
* 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) &&
|
||||
isAgeAssuranceReady
|
||||
opts?.enabled !== false && Boolean(moderationOpts) && Boolean(preferences)
|
||||
const userInterests = aggregateUserInterests(preferences)
|
||||
const followingPinnedIndex =
|
||||
preferences?.savedFeeds?.findIndex(
|
||||
|
||||
@@ -26,6 +26,7 @@ export function usePostQuery(uri: string | undefined) {
|
||||
const res = await agent.resolveHandle({
|
||||
handle: urip.host,
|
||||
})
|
||||
// @ts-expect-error TODO new-sdk-migration
|
||||
urip.host = res.data.did
|
||||
}
|
||||
|
||||
@@ -54,6 +55,7 @@ export function useGetPost() {
|
||||
const res = await agent.resolveHandle({
|
||||
handle: urip.host,
|
||||
})
|
||||
// @ts-expect-error TODO new-sdk-migration
|
||||
urip.host = res.data.did
|
||||
}
|
||||
|
||||
|
||||
@@ -36,6 +36,7 @@ export async function getPostgateRecord({
|
||||
const res = await agent.resolveHandle({
|
||||
handle: urip.host,
|
||||
})
|
||||
// @ts-expect-error TODO new-sdk-migration
|
||||
urip.host = res.data.did
|
||||
}
|
||||
|
||||
|
||||
@@ -10,8 +10,6 @@ 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 {makeAgeRestrictedModerationPrefs} from '#/state/ageAssurance/const'
|
||||
import {STALE} from '#/state/queries'
|
||||
import {
|
||||
DEFAULT_HOME_FEED_PREFS,
|
||||
@@ -24,6 +22,7 @@ import {
|
||||
} from '#/state/queries/preferences/types'
|
||||
import {useAgent} from '#/state/session'
|
||||
import {saveLabelers} from '#/state/session/agent-config'
|
||||
import {useAgeAssurance} from '#/ageAssurance'
|
||||
|
||||
export * from '#/state/queries/preferences/const'
|
||||
export * from '#/state/queries/preferences/moderation'
|
||||
@@ -34,7 +33,7 @@ export const preferencesQueryKey = [preferencesQueryKeyRoot]
|
||||
|
||||
export function usePreferencesQuery() {
|
||||
const agent = useAgent()
|
||||
const {isAgeRestricted} = useAgeAssuranceContext()
|
||||
const aa = useAgeAssurance()
|
||||
|
||||
return useQuery({
|
||||
staleTime: STALE.SECONDS.FIFTEEN,
|
||||
@@ -75,18 +74,19 @@ export function usePreferencesQuery() {
|
||||
},
|
||||
select: useCallback(
|
||||
(data: UsePreferencesQueryResponse) => {
|
||||
const isUnderage = (data.userAge || 0) < 18
|
||||
if (isUnderage || isAgeRestricted) {
|
||||
/**
|
||||
* Prefs are all downstream of age assurance now. For logged-out
|
||||
* users, we override moderation prefs based on AA state.
|
||||
*/
|
||||
if (aa.state.access !== aa.Access.Full) {
|
||||
data = {
|
||||
...data,
|
||||
moderationPrefs: makeAgeRestrictedModerationPrefs(
|
||||
data.moderationPrefs,
|
||||
),
|
||||
moderationPrefs: DEFAULT_LOGGED_OUT_PREFERENCES.moderationPrefs,
|
||||
}
|
||||
}
|
||||
return data
|
||||
},
|
||||
[isAgeRestricted],
|
||||
[aa],
|
||||
),
|
||||
})
|
||||
}
|
||||
@@ -168,21 +168,6 @@ export function usePreferencesSetAdultContentMutation() {
|
||||
})
|
||||
}
|
||||
|
||||
export function usePreferencesSetBirthDateMutation() {
|
||||
const queryClient = useQueryClient()
|
||||
const agent = useAgent()
|
||||
|
||||
return useMutation<void, unknown, {birthDate: Date}>({
|
||||
mutationFn: async ({birthDate}: {birthDate: Date}) => {
|
||||
await agent.setPersonalDetails({birthDate: birthDate.toISOString()})
|
||||
// triggers a refetch
|
||||
await queryClient.invalidateQueries({
|
||||
queryKey: preferencesQueryKey,
|
||||
})
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
export function useSetFeedViewPreferencesMutation() {
|
||||
const queryClient = useQueryClient()
|
||||
const agent = useAgent()
|
||||
|
||||
@@ -17,6 +17,7 @@ export function useResolveUriQuery(uri: string | undefined): UriUseQueryResult {
|
||||
const urip = new AtUri(uri || '')
|
||||
const res = useResolveDidQuery(urip.host)
|
||||
if (res.data) {
|
||||
// @ts-expect-error TODO new-sdk-migration
|
||||
urip.host = res.data
|
||||
return {
|
||||
...res,
|
||||
|
||||
@@ -97,6 +97,7 @@ export async function getThreadgateRecord({
|
||||
const res = await agent.resolveHandle({
|
||||
handle: urip.host,
|
||||
})
|
||||
// @ts-expect-error TODO new-sdk-migration
|
||||
urip.host = res.data.did
|
||||
}
|
||||
|
||||
|
||||
@@ -10,6 +10,9 @@ jest.mock('jwt-decode', () => ({
|
||||
},
|
||||
}))
|
||||
|
||||
jest.mock('../../birthdate')
|
||||
jest.mock('../../../ageAssurance/data')
|
||||
|
||||
describe('session', () => {
|
||||
it('can log in and out', () => {
|
||||
let state = getInitialState([])
|
||||
|
||||
+143
-40
@@ -1,10 +1,12 @@
|
||||
import {
|
||||
Agent as BaseAgent,
|
||||
type AppBskyActorProfile,
|
||||
type AtprotoServiceType,
|
||||
type AtpSessionData,
|
||||
type AtpSessionEvent,
|
||||
BskyAgent,
|
||||
type Did,
|
||||
type Un$Typed,
|
||||
} from '@atproto/api'
|
||||
import {type FetchHandler} from '@atproto/api/dist/agent'
|
||||
import {type SessionManager} from '@atproto/api/dist/session-manager'
|
||||
@@ -23,7 +25,13 @@ import {
|
||||
import {tryFetchGates} from '#/lib/statsig/statsig'
|
||||
import {getAge} from '#/lib/strings/time'
|
||||
import {logger} from '#/logger'
|
||||
import {snoozeBirthdateUpdateAllowedForDid} from '#/state/birthdate'
|
||||
import {snoozeEmailConfirmationPrompt} from '#/state/shell/reminders'
|
||||
import {
|
||||
prefetchAgeAssuranceData,
|
||||
setBirthdateForDid,
|
||||
setCreatedAtForDid,
|
||||
} from '#/ageAssurance/data'
|
||||
import {emitNetworkConfirmed, emitNetworkLost} from '../events'
|
||||
import {addSessionErrorLog} from './logging'
|
||||
import {
|
||||
@@ -77,9 +85,15 @@ export async function createAgentAndResume(
|
||||
}
|
||||
}
|
||||
|
||||
// after session is attached
|
||||
const aa = prefetchAgeAssuranceData({agent})
|
||||
|
||||
agent.configureProxy(BLUESKY_PROXY_HEADER.get())
|
||||
|
||||
return agent.prepare(gates, moderation, onSessionChange)
|
||||
return agent.prepare({
|
||||
resolvers: [gates, moderation, aa],
|
||||
onSessionChange,
|
||||
})
|
||||
}
|
||||
|
||||
export async function createAgentAndLogin(
|
||||
@@ -111,10 +125,14 @@ export async function createAgentAndLogin(
|
||||
const account = agentToSessionAccountOrThrow(agent)
|
||||
const gates = tryFetchGates(account.did, 'prefer-fresh-gates')
|
||||
const moderation = configureModerationForAccount(agent, account)
|
||||
const aa = prefetchAgeAssuranceData({agent})
|
||||
|
||||
agent.configureProxy(BLUESKY_PROXY_HEADER.get())
|
||||
|
||||
return agent.prepare(gates, moderation, onSessionChange)
|
||||
return agent.prepare({
|
||||
resolvers: [gates, moderation, aa],
|
||||
onSessionChange,
|
||||
})
|
||||
}
|
||||
|
||||
export async function createAgentAndCreateAccount(
|
||||
@@ -156,42 +174,122 @@ export async function createAgentAndCreateAccount(
|
||||
const gates = tryFetchGates(account.did, 'prefer-fresh-gates')
|
||||
const moderation = configureModerationForAccount(agent, account)
|
||||
|
||||
const createdAt = new Date().toISOString()
|
||||
const birthdate = birthDate.toISOString()
|
||||
|
||||
/*
|
||||
* Since we have a race with account creation, profile creation, and AA
|
||||
* state, set these values locally to ensure sync reads. Values are written
|
||||
* to the server in the next step, so on subsequent reloads, the server will
|
||||
* be the source of truth.
|
||||
*/
|
||||
setCreatedAtForDid({did: account.did, createdAt})
|
||||
setBirthdateForDid({did: account.did, birthdate})
|
||||
snoozeBirthdateUpdateAllowedForDid(account.did)
|
||||
// do this last
|
||||
const aa = prefetchAgeAssuranceData({agent})
|
||||
|
||||
// Not awaited so that we can still get into onboarding.
|
||||
// This is OK because we won't let you toggle adult stuff until you set the date.
|
||||
if (IS_PROD_SERVICE(service)) {
|
||||
try {
|
||||
networkRetry(1, async () => {
|
||||
await agent.setPersonalDetails({birthDate: birthDate.toISOString()})
|
||||
await agent.overwriteSavedFeeds([
|
||||
{
|
||||
...DISCOVER_SAVED_FEED,
|
||||
id: TID.nextStr(),
|
||||
},
|
||||
{
|
||||
...TIMELINE_SAVED_FEED,
|
||||
id: TID.nextStr(),
|
||||
},
|
||||
])
|
||||
|
||||
if (getAge(birthDate) < 18) {
|
||||
await agent.api.com.atproto.repo.putRecord({
|
||||
repo: account.did,
|
||||
collection: 'chat.bsky.actor.declaration',
|
||||
rkey: 'self',
|
||||
record: {
|
||||
$type: 'chat.bsky.actor.declaration',
|
||||
allowIncoming: 'none',
|
||||
},
|
||||
Promise.allSettled(
|
||||
[
|
||||
networkRetry(3, () => {
|
||||
return agent.setPersonalDetails({
|
||||
birthDate: birthdate,
|
||||
})
|
||||
}
|
||||
})
|
||||
} catch (e: any) {
|
||||
logger.error(e, {
|
||||
message: `session: createAgentAndCreateAccount failed to save personal details and feeds`,
|
||||
})
|
||||
}
|
||||
}).catch(e => {
|
||||
logger.info(`createAgentAndCreateAccount: failed to set birthDate`)
|
||||
throw e
|
||||
}),
|
||||
networkRetry(3, () => {
|
||||
return agent.upsertProfile(prev => {
|
||||
const next: Un$Typed<AppBskyActorProfile.Record> = prev || {}
|
||||
next.displayName = handle
|
||||
next.createdAt = createdAt
|
||||
return next
|
||||
})
|
||||
}).catch(e => {
|
||||
logger.info(
|
||||
`createAgentAndCreateAccount: failed to set initial profile`,
|
||||
)
|
||||
throw e
|
||||
}),
|
||||
networkRetry(1, () => {
|
||||
return agent.overwriteSavedFeeds([
|
||||
{
|
||||
...DISCOVER_SAVED_FEED,
|
||||
id: TID.nextStr(),
|
||||
},
|
||||
{
|
||||
...TIMELINE_SAVED_FEED,
|
||||
id: TID.nextStr(),
|
||||
},
|
||||
])
|
||||
}).catch(e => {
|
||||
logger.info(
|
||||
`createAgentAndCreateAccount: failed to set initial feeds`,
|
||||
)
|
||||
throw e
|
||||
}),
|
||||
getAge(birthDate) < 18 &&
|
||||
networkRetry(3, () => {
|
||||
return agent.com.atproto.repo.putRecord({
|
||||
repo: account.did,
|
||||
collection: 'chat.bsky.actor.declaration',
|
||||
rkey: 'self',
|
||||
record: {
|
||||
$type: 'chat.bsky.actor.declaration',
|
||||
allowIncoming: 'none',
|
||||
},
|
||||
})
|
||||
}).catch(e => {
|
||||
logger.info(
|
||||
`createAgentAndCreateAccount: failed to set chat declaration`,
|
||||
)
|
||||
throw e
|
||||
}),
|
||||
].filter(Boolean),
|
||||
).then(promises => {
|
||||
const rejected = promises.filter(p => p.status === 'rejected')
|
||||
if (rejected.length > 0) {
|
||||
logger.error(
|
||||
`session: createAgentAndCreateAccount failed to save personal details and feeds`,
|
||||
)
|
||||
}
|
||||
})
|
||||
} else {
|
||||
agent.setPersonalDetails({birthDate: birthDate.toISOString()})
|
||||
Promise.allSettled(
|
||||
[
|
||||
networkRetry(3, () => {
|
||||
return agent.setPersonalDetails({
|
||||
birthDate: birthDate.toISOString(),
|
||||
})
|
||||
}).catch(e => {
|
||||
logger.info(`createAgentAndCreateAccount: failed to set birthDate`)
|
||||
throw e
|
||||
}),
|
||||
networkRetry(3, () => {
|
||||
return agent.upsertProfile(prev => {
|
||||
const next: Un$Typed<AppBskyActorProfile.Record> = prev || {}
|
||||
next.createdAt = prev?.createdAt || new Date().toISOString()
|
||||
return next
|
||||
})
|
||||
}).catch(e => {
|
||||
logger.info(
|
||||
`createAgentAndCreateAccount: failed to set initial profile`,
|
||||
)
|
||||
throw e
|
||||
}),
|
||||
].filter(Boolean),
|
||||
).then(promises => {
|
||||
const rejected = promises.filter(p => p.status === 'rejected')
|
||||
if (rejected.length > 0) {
|
||||
logger.error(
|
||||
`session: createAgentAndCreateAccount failed to save personal details and feeds`,
|
||||
)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
try {
|
||||
@@ -203,7 +301,10 @@ export async function createAgentAndCreateAccount(
|
||||
|
||||
agent.configureProxy(BLUESKY_PROXY_HEADER.get())
|
||||
|
||||
return agent.prepare(gates, moderation, onSessionChange)
|
||||
return agent.prepare({
|
||||
resolvers: [gates, moderation, aa],
|
||||
onSessionChange,
|
||||
})
|
||||
}
|
||||
|
||||
export function agentToSessionAccountOrThrow(agent: BskyAgent): SessionAccount {
|
||||
@@ -306,18 +407,20 @@ class BskyAppAgent extends BskyAgent {
|
||||
})
|
||||
}
|
||||
|
||||
async prepare(
|
||||
async prepare({
|
||||
resolvers,
|
||||
onSessionChange,
|
||||
}: {
|
||||
// Not awaited in the calling code so we can delay blocking on them.
|
||||
gates: Promise<void>,
|
||||
moderation: Promise<void>,
|
||||
resolvers: Promise<unknown>[]
|
||||
onSessionChange: (
|
||||
agent: BskyAgent,
|
||||
did: string,
|
||||
event: AtpSessionEvent,
|
||||
) => void,
|
||||
) {
|
||||
) => void
|
||||
}) {
|
||||
// There's nothing else left to do, so block on them here.
|
||||
await Promise.all([gates, moderation])
|
||||
await Promise.all(resolvers)
|
||||
|
||||
// Now the agent is ready.
|
||||
const account = agentToSessionAccountOrThrow(this)
|
||||
|
||||
@@ -24,6 +24,11 @@ import {
|
||||
type SessionApiContext,
|
||||
type SessionStateContext,
|
||||
} from '#/state/session/types'
|
||||
import {useOnboardingDispatch} from '#/state/shell/onboarding'
|
||||
import {
|
||||
clearAgeAssuranceData,
|
||||
clearAgeAssuranceDataForDid,
|
||||
} from '#/ageAssurance/data'
|
||||
|
||||
const StateContext = React.createContext<SessionStateContext>({
|
||||
accounts: [],
|
||||
@@ -91,6 +96,7 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
|
||||
const cancelPendingTask = useOneTaskAtATime()
|
||||
const [store] = React.useState(() => new SessionStore())
|
||||
const state = React.useSyncExternalStore(store.subscribe, store.getState)
|
||||
const onboardingDispatch = useOnboardingDispatch()
|
||||
|
||||
const onAgentSessionChange = React.useCallback(
|
||||
(agent: BskyAgent, accountDid: string, sessionEvent: AtpSessionEvent) => {
|
||||
@@ -166,6 +172,7 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
|
||||
logContext => {
|
||||
addSessionDebugLog({type: 'method:start', method: 'logout'})
|
||||
cancelPendingTask()
|
||||
const prevState = store.getState()
|
||||
store.dispatch({
|
||||
type: 'logged-out-current-account',
|
||||
})
|
||||
@@ -175,8 +182,13 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
|
||||
{statsig: true},
|
||||
)
|
||||
addSessionDebugLog({type: 'method:end', method: 'logout'})
|
||||
if (prevState.currentAgentState.did) {
|
||||
clearAgeAssuranceDataForDid({did: prevState.currentAgentState.did})
|
||||
}
|
||||
// reset onboarding flow on logout
|
||||
onboardingDispatch({type: 'skip'})
|
||||
},
|
||||
[store, cancelPendingTask],
|
||||
[store, cancelPendingTask, onboardingDispatch],
|
||||
)
|
||||
|
||||
const logoutEveryAccount = React.useCallback<
|
||||
@@ -194,12 +206,15 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
|
||||
{statsig: true},
|
||||
)
|
||||
addSessionDebugLog({type: 'method:end', method: 'logout'})
|
||||
clearAgeAssuranceData()
|
||||
// reset onboarding flow on logout
|
||||
onboardingDispatch({type: 'skip'})
|
||||
},
|
||||
[store, cancelPendingTask],
|
||||
[store, cancelPendingTask, onboardingDispatch],
|
||||
)
|
||||
|
||||
const resumeSession = React.useCallback<SessionApiContext['resumeSession']>(
|
||||
async storedAccount => {
|
||||
async (storedAccount, isSwitchingAccounts = false) => {
|
||||
addSessionDebugLog({
|
||||
type: 'method:start',
|
||||
method: 'resumeSession',
|
||||
@@ -220,8 +235,12 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
|
||||
newAccount: account,
|
||||
})
|
||||
addSessionDebugLog({type: 'method:end', method: 'resumeSession', account})
|
||||
if (isSwitchingAccounts) {
|
||||
// reset onboarding flow on switch account
|
||||
onboardingDispatch({type: 'skip'})
|
||||
}
|
||||
},
|
||||
[store, onAgentSessionChange, cancelPendingTask],
|
||||
[store, onAgentSessionChange, cancelPendingTask, onboardingDispatch],
|
||||
)
|
||||
|
||||
const partialRefreshSession = React.useCallback<
|
||||
@@ -254,6 +273,7 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
|
||||
accountDid: account.did,
|
||||
})
|
||||
addSessionDebugLog({type: 'method:end', method: 'removeAccount', account})
|
||||
clearAgeAssuranceDataForDid({did: account.did})
|
||||
},
|
||||
[store, cancelPendingTask],
|
||||
)
|
||||
|
||||
@@ -38,7 +38,10 @@ export type SessionApiContext = {
|
||||
logoutEveryAccount: (
|
||||
logContext: LogEvents['account:loggedOut']['logContext'],
|
||||
) => void
|
||||
resumeSession: (account: SessionAccount) => Promise<void>
|
||||
resumeSession: (
|
||||
account: SessionAccount,
|
||||
isSwitchingAccounts?: boolean,
|
||||
) => Promise<void>
|
||||
removeAccount: (account: SessionAccount) => void
|
||||
/**
|
||||
* Calls `getSession` and updates select fields on the current account and
|
||||
|
||||
@@ -2,7 +2,6 @@ import {Provider as ColorModeProvider} from './color-mode'
|
||||
import {Provider as DrawerOpenProvider} from './drawer-open'
|
||||
import {Provider as DrawerSwipableProvider} from './drawer-swipe-disabled'
|
||||
import {Provider as MinimalModeProvider} from './minimal-mode'
|
||||
import {Provider as OnboardingProvider} from './onboarding'
|
||||
import {Provider as ShellLayoutProvder} from './shell-layout'
|
||||
import {Provider as TickEveryMinuteProvider} from './tick-every-minute'
|
||||
|
||||
@@ -23,9 +22,7 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
|
||||
<DrawerSwipableProvider>
|
||||
<MinimalModeProvider>
|
||||
<ColorModeProvider>
|
||||
<OnboardingProvider>
|
||||
<TickEveryMinuteProvider>{children}</TickEveryMinuteProvider>
|
||||
</OnboardingProvider>
|
||||
<TickEveryMinuteProvider>{children}</TickEveryMinuteProvider>
|
||||
</ColorModeProvider>
|
||||
</MinimalModeProvider>
|
||||
</DrawerSwipableProvider>
|
||||
|
||||
@@ -81,6 +81,7 @@ export function useUnstablePostSource(key: string) {
|
||||
*/
|
||||
export function buildPostSourceKey(key: string, handle: string) {
|
||||
const urip = new AtUri(key)
|
||||
// @ts-expect-error TODO new-sdk-migration
|
||||
urip.host = handle
|
||||
return urip.toString()
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user