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:
Eric Bailey
2025-12-04 15:20:00 -06:00
committed by GitHub
parent 7735183af4
commit c4aef9f668
91 changed files with 3016 additions and 1799 deletions
+1
View File
@@ -0,0 +1 @@
export const snoozeBirthdateUpdateAllowedForDid = () => {}
-11
View File
@@ -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,
})
-156
View File
@@ -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)
}
-33
View File
@@ -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']
}
-44
View File
@@ -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])
}
-3
View File
@@ -1,3 +0,0 @@
import {Logger} from '#/logger'
export const logger = Logger.create(Logger.Context.AgeAssurance)
+64
View File
@@ -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!)
},
})
}
-141
View File
@@ -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`)
}
}
}
-30
View File
@@ -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,
}
-19
View File
@@ -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)
}
}
-155
View File
@@ -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)
}
-3
View File
@@ -1,3 +0,0 @@
import {Logger} from '#/logger'
export const logger = Logger.create(Logger.Context.Geolocation)
-9
View File
@@ -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
}
-180
View File
@@ -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,
}
}
}
+1 -6
View File
@@ -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(
+2
View File
@@ -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
}
+1
View File
@@ -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
}
+9 -24
View File
@@ -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()
+1
View File
@@ -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,
+1
View File
@@ -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
View File
@@ -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 -4
View File
@@ -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],
)
+4 -1
View File
@@ -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
+1 -4
View File
@@ -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>
+1
View File
@@ -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()
}