diff options
Diffstat (limited to 'src/state')
-rw-r--r-- | src/state/ageAssurance/const.ts | 11 | ||||
-rw-r--r-- | src/state/ageAssurance/index.tsx | 140 | ||||
-rw-r--r-- | src/state/ageAssurance/types.ts | 33 | ||||
-rw-r--r-- | src/state/ageAssurance/useAgeAssurance.ts | 45 | ||||
-rw-r--r-- | src/state/ageAssurance/useInitAgeAssurance.ts | 85 | ||||
-rw-r--r-- | src/state/ageAssurance/useIsAgeAssuranceEnabled.ts | 13 | ||||
-rw-r--r-- | src/state/geolocation.tsx | 6 | ||||
-rw-r--r-- | src/state/queries/nuxs/definitions.ts | 12 | ||||
-rw-r--r-- | src/state/queries/nuxs/index.ts | 16 | ||||
-rw-r--r-- | src/state/queries/post-feed.ts | 27 | ||||
-rw-r--r-- | src/state/queries/preferences/index.ts | 15 |
11 files changed, 397 insertions, 6 deletions
diff --git a/src/state/ageAssurance/const.ts b/src/state/ageAssurance/const.ts new file mode 100644 index 000000000..2f329582a --- /dev/null +++ b/src/state/ageAssurance/const.ts @@ -0,0 +1,11 @@ +import {type ModerationPrefs} from '@atproto/api' + +import {DEFAULT_LOGGED_OUT_LABEL_PREFERENCES} from '#/state/queries/preferences/moderation' + +export const AGE_RESTRICTED_MODERATION_PREFS: ModerationPrefs = { + adultContentEnabled: false, + labels: DEFAULT_LOGGED_OUT_LABEL_PREFERENCES, + labelers: [], + mutedWords: [], + hiddenPosts: [], +} diff --git a/src/state/ageAssurance/index.tsx b/src/state/ageAssurance/index.tsx new file mode 100644 index 000000000..aab954e6c --- /dev/null +++ b/src/state/ageAssurance/index.tsx @@ -0,0 +1,140 @@ +import {createContext, useContext, useMemo} 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 {useGate} from '#/lib/statsig/statsig' +import {isNetworkError} from '#/lib/strings/errors' +import {Logger} from '#/logger' +import { + type AgeAssuranceAPIContextType, + type AgeAssuranceContextType, +} from '#/state/ageAssurance/types' +import {useIsAgeAssuranceEnabled} from '#/state/ageAssurance/useIsAgeAssuranceEnabled' +import {useGeolocation} from '#/state/geolocation' +import {useAgent} from '#/state/session' + +const logger = Logger.create(Logger.Context.AgeAssurance) + +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, +}) + +const AgeAssuranceAPIContext = createContext<AgeAssuranceAPIContextType>({ + // @ts-ignore can't be bothered to type this + refetch: () => Promise.resolve(), +}) + +/** + * Low-level provider for fetching age assurance state on app load. Do not add + * any other data fetching in here to avoid complications and reduced + * performance. + */ +export function Provider({children}: {children: React.ReactNode}) { + const gate = useGate() + const agent = useAgent() + const {geolocation} = useGeolocation() + const isAgeAssuranceEnabled = useIsAgeAssuranceEnabled() + const getAndRegisterPushToken = useGetAndRegisterPushToken() + + const {data, isFetched, refetch} = useQuery({ + /** + * This is load bearing. We always want this query to run and end in a + * "fetched" state, even if we fall back to defaults. This lets the rest of + * the app know that we've at least attempted to load the AA state. + * + * However, it only needs to run if AA is enabled. + */ + enabled: isAgeAssuranceEnabled, + queryKey: createAgeAssuranceQueryKey(agent.session?.did ?? 'never'), + async queryFn() { + if (!agent.session) return null + + try { + const {data} = await networkRetry(3, () => + agent.app.bsky.unspecced.getAgeAssuranceState(), + ) + // const {data} = { + // data: { + // lastInitiatedAt: new Date().toISOString(), + // status: 'pending', + // } as AppBskyUnspeccedDefs.AgeAssuranceState, + // } + + logger.debug(`fetch`, { + data, + account: agent.session?.did, + }) + + if (gate('age_assurance')) { + await getAndRegisterPushToken({ + isAgeRestricted: + !!geolocation?.isAgeRestrictedGeo && data.status !== 'assured', + }) + } + + return data + } catch (e) { + if (!isNetworkError(e)) { + logger.error(`ageAssurance: failed to fetch`, {safeMessage: e}) + } + // don't re-throw error, we'll just fall back to defaults + return null + } + }, + }) + + /** + * Derive state, or fall back to defaults + */ + const ageAssuranceContext = useMemo<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]) + + 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) +} diff --git a/src/state/ageAssurance/types.ts b/src/state/ageAssurance/types.ts new file mode 100644 index 000000000..63febb3cf --- /dev/null +++ b/src/state/ageAssurance/types.ts @@ -0,0 +1,33 @@ +import {type AppBskyUnspeccedDefs} from '@atproto/api' +import {type QueryObserverBaseResult} from '@tanstack/react-query' + +export type AgeAssuranceContextType = { + /** + * Whether the age assurance state has been fetched from the server. If user + * is not in a region that requires AA, or AA is otherwise disabled, this + * will always be `true`. + */ + isReady: boolean + /** + * The server-reported status of the user's age verification process. + */ + status: AppBskyUnspeccedDefs.AgeAssuranceState['status'] + /** + * The last time the age assurance state was attempted by the user. + */ + lastInitiatedAt: AppBskyUnspeccedDefs.AgeAssuranceState['lastInitiatedAt'] + /** + * Indicates the user is age restricted based on the requirements of their + * region, and their server-provided age assurance status. Does not factor in + * the user's declared age. If AA is otherise disabled, this will always be + * `false`. + */ + isAgeRestricted: boolean +} + +export type AgeAssuranceAPIContextType = { + /** + * Refreshes the age assurance state by fetching it from the server. + */ + refetch: QueryObserverBaseResult['refetch'] +} diff --git a/src/state/ageAssurance/useAgeAssurance.ts b/src/state/ageAssurance/useAgeAssurance.ts new file mode 100644 index 000000000..455f38c92 --- /dev/null +++ b/src/state/ageAssurance/useAgeAssurance.ts @@ -0,0 +1,45 @@ +import {useMemo} from 'react' + +import {Logger} from '#/logger' +import {useAgeAssuranceContext} from '#/state/ageAssurance' +import {usePreferencesQuery} from '#/state/queries/preferences' + +const logger = Logger.create(Logger.Context.AgeAssurance) + +type AgeAssurance = ReturnType<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 || 0) < 18 + const state: AgeAssurance = { + isReady, + status: aa.status, + lastInitiatedAt: aa.lastInitiatedAt, + isAgeRestricted: aa.isAgeRestricted, + declaredAge, + isDeclaredUnderage, + } + logger.debug(`state`, state) + return state + }, [aa, preferencesLoaded, declaredAge]) +} diff --git a/src/state/ageAssurance/useInitAgeAssurance.ts b/src/state/ageAssurance/useInitAgeAssurance.ts new file mode 100644 index 000000000..8776dd29c --- /dev/null +++ b/src/state/ageAssurance/useInitAgeAssurance.ts @@ -0,0 +1,85 @@ +import { + type AppBskyUnspeccedDefs, + type AppBskyUnspeccedInitAgeAssurance, + AtpAgent, +} from '@atproto/api' +import {useMutation, useQueryClient} from '@tanstack/react-query' + +import {wait} from '#/lib/async/wait' +import { + // DEV_ENV_APPVIEW, + PUBLIC_APPVIEW, + PUBLIC_APPVIEW_DID, +} from '#/lib/constants' +import {isNetworkError} from '#/lib/hooks/useCleanError' +import {logger} from '#/logger' +import {createAgeAssuranceQueryKey} from '#/state/ageAssurance' +import {useGeolocation} from '#/state/geolocation' +import {useAgent} from '#/state/session' + +let APPVIEW = PUBLIC_APPVIEW +let APPVIEW_DID = PUBLIC_APPVIEW_DID + +/* + * Uncomment if using the local dev-env + */ +// if (__DEV__) { +// APPVIEW = DEV_ENV_APPVIEW +// /* +// * IMPORTANT: you need to get this value from `http://localhost:2581` +// * introspection endpoint and updated in `constants`, since it changes +// * every time you run the dev-env. +// */ +// APPVIEW_DID = `` +// } + +export function useInitAgeAssurance() { + const qc = useQueryClient() + const agent = useAgent() + const {geolocation} = useGeolocation() + return useMutation({ + async mutationFn( + props: Omit<AppBskyUnspeccedInitAgeAssurance.InputSchema, 'countryCode'>, + ) { + if (!geolocation?.countryCode) { + throw new Error(`Geolocation not available, cannot init age assurance.`) + } + + const { + data: {token}, + } = await agent.com.atproto.server.getServiceAuth({ + aud: APPVIEW_DID, + lxm: `app.bsky.unspecced.initAgeAssurance`, + }) + + const appView = new AtpAgent({service: APPVIEW}) + appView.sessionManager.session = {...agent.session!} + appView.sessionManager.session.accessJwt = token + appView.sessionManager.session.refreshJwt = '' + + /* + * 2s wait is good actually. Email sending takes a hot sec and this helps + * ensure the email is ready for the user once they open their inbox. + */ + const {data} = await wait( + 2e3, + appView.app.bsky.unspecced.initAgeAssurance({ + ...props, + countryCode: geolocation?.countryCode?.toUpperCase(), + }), + ) + + qc.setQueryData<AppBskyUnspeccedDefs.AgeAssuranceState>( + createAgeAssuranceQueryKey(agent.session?.did ?? 'never'), + () => data, + ) + }, + onError(e) { + if (!isNetworkError(e)) { + logger.error(`useInitAgeAssurance failed`, { + safeMessage: e, + }) + } + }, + }) +} diff --git a/src/state/ageAssurance/useIsAgeAssuranceEnabled.ts b/src/state/ageAssurance/useIsAgeAssuranceEnabled.ts new file mode 100644 index 000000000..5c1a7b1c4 --- /dev/null +++ b/src/state/ageAssurance/useIsAgeAssuranceEnabled.ts @@ -0,0 +1,13 @@ +import {useMemo} from 'react' + +import {useGate} from '#/lib/statsig/statsig' +import {useGeolocation} from '#/state/geolocation' + +export function useIsAgeAssuranceEnabled() { + const gate = useGate() + const {geolocation} = useGeolocation() + + return useMemo(() => { + return gate('age_assurance') && !!geolocation?.isAgeRestrictedGeo + }, [geolocation, gate]) +} diff --git a/src/state/geolocation.tsx b/src/state/geolocation.tsx index 83a42f21d..20b161ffe 100644 --- a/src/state/geolocation.tsx +++ b/src/state/geolocation.tsx @@ -25,6 +25,7 @@ const onGeolocationUpdate = ( */ export const DEFAULT_GEOLOCATION: Device['geolocation'] = { countryCode: undefined, + isAgeRestrictedGeo: false, } async function getGeolocation(): Promise<Device['geolocation']> { @@ -39,6 +40,7 @@ async function getGeolocation(): Promise<Device['geolocation']> { if (json.countryCode) { return { countryCode: json.countryCode, + isAgeRestrictedGeo: json.isAgeRestrictedGeo ?? false, } } else { return undefined @@ -66,7 +68,9 @@ export function beginResolveGeolocation() { */ if (__DEV__) { geolocationResolution = new Promise(y => y({success: true})) - device.set(['geolocation'], DEFAULT_GEOLOCATION) + if (!device.get(['geolocation'])) { + device.set(['geolocation'], DEFAULT_GEOLOCATION) + } return } diff --git a/src/state/queries/nuxs/definitions.ts b/src/state/queries/nuxs/definitions.ts index 1947f857f..61657992f 100644 --- a/src/state/queries/nuxs/definitions.ts +++ b/src/state/queries/nuxs/definitions.ts @@ -7,6 +7,8 @@ export enum Nux { ExploreInterestsCard = 'ExploreInterestsCard', InitialVerificationAnnouncement = 'InitialVerificationAnnouncement', ActivitySubscriptions = 'ActivitySubscriptions', + AgeAssuranceDismissibleNotice = 'AgeAssuranceDismissibleNotice', + AgeAssuranceDismissibleHeaderButton = 'AgeAssuranceDismissibleHeaderButton', } export const nuxNames = new Set(Object.values(Nux)) @@ -28,6 +30,14 @@ export type AppNux = BaseNux< id: Nux.ActivitySubscriptions data: undefined } + | { + id: Nux.AgeAssuranceDismissibleNotice + data: undefined + } + | { + id: Nux.AgeAssuranceDismissibleHeaderButton + data: undefined + } > export const NuxSchemas: Record<Nux, zod.ZodObject<any> | undefined> = { @@ -35,4 +45,6 @@ export const NuxSchemas: Record<Nux, zod.ZodObject<any> | undefined> = { [Nux.ExploreInterestsCard]: undefined, [Nux.InitialVerificationAnnouncement]: undefined, [Nux.ActivitySubscriptions]: undefined, + [Nux.AgeAssuranceDismissibleNotice]: undefined, + [Nux.AgeAssuranceDismissibleHeaderButton]: undefined, } diff --git a/src/state/queries/nuxs/index.ts b/src/state/queries/nuxs/index.ts index 6ad59c7a4..b9650d057 100644 --- a/src/state/queries/nuxs/index.ts +++ b/src/state/queries/nuxs/index.ts @@ -1,6 +1,6 @@ import {useMutation, useQueryClient} from '@tanstack/react-query' -import {AppNux, Nux} from '#/state/queries/nuxs/definitions' +import {type AppNux, type Nux} from '#/state/queries/nuxs/definitions' import {parseAppNux, serializeAppNux} from '#/state/queries/nuxs/util' import { preferencesQueryKey, @@ -40,6 +40,20 @@ export function useNuxs(): } } + // if (__DEV__) { + // const queryClient = useQueryClient() + // const agent = useAgent() + + // // @ts-ignore + // window.clearNux = async (ids: string[]) => { + // await agent.bskyAppRemoveNuxs(ids) + // // triggers a refetch + // await queryClient.invalidateQueries({ + // queryKey: preferencesQueryKey, + // }) + // } + // } + return { nuxs: undefined, status, diff --git a/src/state/queries/post-feed.ts b/src/state/queries/post-feed.ts index 361081e67..22e95fcd6 100644 --- a/src/state/queries/post-feed.ts +++ b/src/state/queries/post-feed.ts @@ -8,6 +8,7 @@ import { type BskyAgent, moderatePost, type ModerationDecision, + type ModerationPrefs, } from '@atproto/api' import { type InfiniteData, @@ -31,6 +32,7 @@ import {FeedTuner, type FeedTunerFn} from '#/lib/api/feed-manip' import {DISCOVER_FEED_URI} from '#/lib/constants' import {BSKY_FEED_OWNER_DIDS} from '#/lib/constants' import {logger} from '#/logger' +import {useAgeAssuranceContext} from '#/state/ageAssurance' import {STALE} from '#/state/queries' import {DEFAULT_LOGGED_OUT_PREFERENCES} from '#/state/queries/preferences/const' import {useAgent} from '#/state/session' @@ -134,8 +136,18 @@ export function usePostFeedQuery( const feedTuners = useFeedTuners(feedDesc) const moderationOpts = useModerationOpts() const {data: preferences} = usePreferencesQuery() + /** + * Load bearing: we need to await AA state or risk FOUC. This marginally + * delays feeds, but AA state is fetched immediately on load and is then + * available for the remainder of the session, so this delay only affects cold + * loads. -esb + */ + const {isReady: isAgeAssuranceReady} = useAgeAssuranceContext() const enabled = - opts?.enabled !== false && Boolean(moderationOpts) && Boolean(preferences) + opts?.enabled !== false && + Boolean(moderationOpts) && + Boolean(preferences) && + isAgeAssuranceReady const userInterests = aggregateUserInterests(preferences) const followingPinnedIndex = preferences?.savedFeeds?.findIndex( @@ -206,7 +218,11 @@ export function usePostFeedQuery( * some not. */ if (!agent.session) { - assertSomePostsPassModeration(res.feed) + assertSomePostsPassModeration( + res.feed, + preferences?.moderationPrefs || + DEFAULT_LOGGED_OUT_PREFERENCES.moderationPrefs, + ) } return { @@ -596,7 +612,10 @@ export function* findAllProfilesInQueryData( } } -function assertSomePostsPassModeration(feed: AppBskyFeedDefs.FeedViewPost[]) { +function assertSomePostsPassModeration( + feed: AppBskyFeedDefs.FeedViewPost[], + moderationPrefs: ModerationPrefs, +) { // no posts in this feed if (feed.length === 0) return true @@ -606,7 +625,7 @@ function assertSomePostsPassModeration(feed: AppBskyFeedDefs.FeedViewPost[]) { for (const item of feed) { const moderation = moderatePost(item.post, { userDid: undefined, - prefs: DEFAULT_LOGGED_OUT_PREFERENCES.moderationPrefs, + prefs: moderationPrefs, }) if (!moderation.ui('contentList').filter) { diff --git a/src/state/queries/preferences/index.ts b/src/state/queries/preferences/index.ts index e64f117e6..44d63b55c 100644 --- a/src/state/queries/preferences/index.ts +++ b/src/state/queries/preferences/index.ts @@ -1,3 +1,4 @@ +import {useCallback} from 'react' import { type AppBskyActorDefs, type BskyFeedViewPreference, @@ -9,6 +10,8 @@ import {PROD_DEFAULT_FEED} from '#/lib/constants' import {replaceEqualDeep} from '#/lib/functions' import {getAge} from '#/lib/strings/time' import {logger} from '#/logger' +import {useAgeAssuranceContext} from '#/state/ageAssurance' +import {AGE_RESTRICTED_MODERATION_PREFS} from '#/state/ageAssurance/const' import {STALE} from '#/state/queries' import { DEFAULT_HOME_FEED_PREFS, @@ -31,6 +34,8 @@ export const preferencesQueryKey = [preferencesQueryKeyRoot] export function usePreferencesQuery() { const agent = useAgent() + const {isAgeRestricted} = useAgeAssuranceContext() + return useQuery({ staleTime: STALE.SECONDS.FIFTEEN, structuralSharing: replaceEqualDeep, @@ -68,6 +73,16 @@ export function usePreferencesQuery() { return preferences } }, + select: useCallback( + (data: UsePreferencesQueryResponse) => { + const isUnderage = (data.userAge || 0) < 18 + if (isUnderage || isAgeRestricted) { + data.moderationPrefs = AGE_RESTRICTED_MODERATION_PREFS + } + return data + }, + [isAgeRestricted], + ), }) } |