diff options
Diffstat (limited to 'src/state')
33 files changed, 828 insertions, 746 deletions
diff --git a/src/state/cache/post-shadow.ts b/src/state/cache/post-shadow.ts index 7cf72fae4..6225cbdba 100644 --- a/src/state/cache/post-shadow.ts +++ b/src/state/cache/post-shadow.ts @@ -1,13 +1,14 @@ -import {useEffect, useState, useMemo} from 'react' -import EventEmitter from 'eventemitter3' +import {useEffect, useMemo, useState} from 'react' import {AppBskyFeedDefs} from '@atproto/api' +import {QueryClient} from '@tanstack/react-query' +import EventEmitter from 'eventemitter3' + import {batchedUpdates} from '#/lib/batchedUpdates' -import {Shadow, castAsShadow} from './types' import {findAllPostsInQueryData as findAllPostsInNotifsQueryData} from '../queries/notifications/feed' import {findAllPostsInQueryData as findAllPostsInFeedQueryData} from '../queries/post-feed' import {findAllPostsInQueryData as findAllPostsInThreadQueryData} from '../queries/post-thread' import {findAllPostsInQueryData as findAllPostsInSearchQueryData} from '../queries/search-posts' -import {queryClient} from 'lib/react-query' +import {castAsShadow, Shadow} from './types' export type {Shadow} from './types' export interface PostShadow { @@ -93,8 +94,12 @@ function mergeShadow( }) } -export function updatePostShadow(uri: string, value: Partial<PostShadow>) { - const cachedPosts = findPostsInCache(uri) +export function updatePostShadow( + queryClient: QueryClient, + uri: string, + value: Partial<PostShadow>, +) { + const cachedPosts = findPostsInCache(queryClient, uri) for (let post of cachedPosts) { shadows.set(post, {...shadows.get(post), ...value}) } @@ -104,6 +109,7 @@ export function updatePostShadow(uri: string, value: Partial<PostShadow>) { } function* findPostsInCache( + queryClient: QueryClient, uri: string, ): Generator<AppBskyFeedDefs.PostView, void> { for (let post of findAllPostsInFeedQueryData(queryClient, uri)) { diff --git a/src/state/cache/profile-shadow.ts b/src/state/cache/profile-shadow.ts index 34fe5995d..ca791bc9e 100644 --- a/src/state/cache/profile-shadow.ts +++ b/src/state/cache/profile-shadow.ts @@ -1,7 +1,10 @@ -import {useEffect, useState, useMemo} from 'react' -import EventEmitter from 'eventemitter3' +import {useEffect, useMemo, useState} from 'react' import {AppBskyActorDefs} from '@atproto/api' +import {QueryClient} from '@tanstack/react-query' +import EventEmitter from 'eventemitter3' + import {batchedUpdates} from '#/lib/batchedUpdates' +import {findAllProfilesInQueryData as findAllProfilesInActorSearchQueryData} from '../queries/actor-search' import {findAllProfilesInQueryData as findAllProfilesInListMembersQueryData} from '../queries/list-members' import {findAllProfilesInQueryData as findAllProfilesInMyBlockedAccountsQueryData} from '../queries/my-blocked-accounts' import {findAllProfilesInQueryData as findAllProfilesInMyMutedAccountsQueryData} from '../queries/my-muted-accounts' @@ -11,9 +14,7 @@ import {findAllProfilesInQueryData as findAllProfilesInProfileQueryData} from '. import {findAllProfilesInQueryData as findAllProfilesInProfileFollowersQueryData} from '../queries/profile-followers' import {findAllProfilesInQueryData as findAllProfilesInProfileFollowsQueryData} from '../queries/profile-follows' import {findAllProfilesInQueryData as findAllProfilesInSuggestedFollowsQueryData} from '../queries/suggested-follows' -import {findAllProfilesInQueryData as findAllProfilesInActorSearchQueryData} from '../queries/actor-search' -import {Shadow, castAsShadow} from './types' -import {queryClient} from 'lib/react-query' +import {castAsShadow, Shadow} from './types' export type {Shadow} from './types' export interface ProfileShadow { @@ -58,10 +59,11 @@ export function useProfileShadow< } export function updateProfileShadow( + queryClient: QueryClient, did: string, value: Partial<ProfileShadow>, ) { - const cachedProfiles = findProfilesInCache(did) + const cachedProfiles = findProfilesInCache(queryClient, did) for (let post of cachedProfiles) { shadows.set(post, {...shadows.get(post), ...value}) } @@ -90,6 +92,7 @@ function mergeShadow<TProfileView extends AppBskyActorDefs.ProfileView>( } function* findProfilesInCache( + queryClient: QueryClient, did: string, ): Generator<AppBskyActorDefs.ProfileView, void> { yield* findAllProfilesInListMembersQueryData(queryClient, did) diff --git a/src/state/dialogs/index.tsx b/src/state/dialogs/index.tsx index 4cafaa086..26bb6792f 100644 --- a/src/state/dialogs/index.tsx +++ b/src/state/dialogs/index.tsx @@ -1,20 +1,39 @@ import React from 'react' -import {DialogControlProps} from '#/components/Dialog' +import {SharedValue, useSharedValue} from 'react-native-reanimated' -const DialogContext = React.createContext<{ +import {DialogControlRefProps} from '#/components/Dialog' +import {Provider as GlobalDialogsProvider} from '#/components/dialogs/Context' + +interface IDialogContext { + /** + * The currently active `useDialogControl` hooks. + */ activeDialogs: React.MutableRefObject< - Map<string, React.MutableRefObject<DialogControlProps>> + Map<string, React.MutableRefObject<DialogControlRefProps>> > -}>({ - activeDialogs: { - current: new Map(), - }, -}) + /** + * The currently open dialogs, referenced by their IDs, generated from + * `useId`. + */ + openDialogs: React.MutableRefObject<Set<string>> + /** + * The counterpart to `accessibilityViewIsModal` for Android. This property + * applies to the parent of all non-modal views, and prevents TalkBack from + * navigating within content beneath an open dialog. + * + * @see https://reactnative.dev/docs/accessibility#importantforaccessibility-android + */ + importantForAccessibility: SharedValue<'auto' | 'no-hide-descendants'> +} + +const DialogContext = React.createContext<IDialogContext>({} as IDialogContext) const DialogControlContext = React.createContext<{ - closeAllDialogs(): void + closeAllDialogs(): boolean + setDialogIsOpen(id: string, isOpen: boolean): void }>({ - closeAllDialogs: () => {}, + closeAllDialogs: () => false, + setDialogIsOpen: () => {}, }) export function useDialogStateContext() { @@ -27,17 +46,53 @@ export function useDialogStateControlContext() { export function Provider({children}: React.PropsWithChildren<{}>) { const activeDialogs = React.useRef< - Map<string, React.MutableRefObject<DialogControlProps>> + Map<string, React.MutableRefObject<DialogControlRefProps>> >(new Map()) + const openDialogs = React.useRef<Set<string>>(new Set()) + const importantForAccessibility = useSharedValue< + 'auto' | 'no-hide-descendants' + >('auto') + const closeAllDialogs = React.useCallback(() => { - activeDialogs.current.forEach(dialog => dialog.current.close()) + openDialogs.current.forEach(id => { + const dialog = activeDialogs.current.get(id) + if (dialog) dialog.current.close() + }) + return openDialogs.current.size > 0 }, []) - const context = React.useMemo(() => ({activeDialogs}), []) - const controls = React.useMemo(() => ({closeAllDialogs}), [closeAllDialogs]) + + const setDialogIsOpen = React.useCallback( + (id: string, isOpen: boolean) => { + if (isOpen) { + openDialogs.current.add(id) + importantForAccessibility.value = 'no-hide-descendants' + } else { + openDialogs.current.delete(id) + if (openDialogs.current.size < 1) { + importantForAccessibility.value = 'auto' + } + } + }, + [importantForAccessibility], + ) + + const context = React.useMemo<IDialogContext>( + () => ({ + activeDialogs, + openDialogs, + importantForAccessibility, + }), + [importantForAccessibility, activeDialogs, openDialogs], + ) + const controls = React.useMemo( + () => ({closeAllDialogs, setDialogIsOpen}), + [closeAllDialogs, setDialogIsOpen], + ) + return ( <DialogContext.Provider value={context}> <DialogControlContext.Provider value={controls}> - {children} + <GlobalDialogsProvider>{children}</GlobalDialogsProvider> </DialogControlContext.Provider> </DialogContext.Provider> ) diff --git a/src/state/modals/index.tsx b/src/state/modals/index.tsx index 691add005..524dcb1ba 100644 --- a/src/state/modals/index.tsx +++ b/src/state/modals/index.tsx @@ -1,6 +1,5 @@ import React from 'react' -import {AppBskyActorDefs, AppBskyGraphDefs, ModerationUI} from '@atproto/api' -import {StyleProp, ViewStyle} from 'react-native' +import {AppBskyActorDefs, AppBskyGraphDefs} from '@atproto/api' import {Image as RNImage} from 'react-native-image-crop-picker' import {ImageModel} from '#/state/models/media/image' @@ -9,49 +8,12 @@ import {useNonReactiveCallback} from '#/lib/hooks/useNonReactiveCallback' import {EmbedPlayerSource} from '#/lib/strings/embed-player' import {ThreadgateSetting} from '../queries/threadgate' -export interface ConfirmModal { - name: 'confirm' - title: string - message: string | (() => JSX.Element) - onPressConfirm: () => void | Promise<void> - onPressCancel?: () => void | Promise<void> - confirmBtnText?: string - confirmBtnStyle?: StyleProp<ViewStyle> - cancelBtnText?: string -} - export interface EditProfileModal { name: 'edit-profile' profile: AppBskyActorDefs.ProfileViewDetailed onUpdate?: () => void } -export interface ModerationDetailsModal { - name: 'moderation-details' - context: 'account' | 'content' - moderation: ModerationUI -} - -export type ReportModal = { - name: 'report' -} & ( - | { - uri: string - cid: string - } - | {did: string} -) - -export type AppealLabelModal = { - name: 'appeal-label' -} & ( - | { - uri: string - cid: string - } - | {did: string} -) - export interface CreateOrEditListModal { name: 'create-or-edit-list' purpose?: string @@ -135,10 +97,6 @@ export interface AddAppPasswordModal { name: 'add-app-password' } -export interface ContentFilteringSettingsModal { - name: 'content-filtering-settings' -} - export interface ContentLanguagesSettingsModal { name: 'content-languages-settings' } @@ -147,10 +105,6 @@ export interface PostLanguagesSettingsModal { name: 'post-languages-settings' } -export interface BirthDateSettingsModal { - name: 'birth-date-settings' -} - export interface VerifyEmailModal { name: 'verify-email' showReminder?: boolean @@ -191,22 +145,15 @@ export type Modal = | ChangeHandleModal | DeleteAccountModal | EditProfileModal - | BirthDateSettingsModal | VerifyEmailModal | ChangeEmailModal | ChangePasswordModal | SwitchAccountModal // Curation - | ContentFilteringSettingsModal | ContentLanguagesSettingsModal | PostLanguagesSettingsModal - // Moderation - | ModerationDetailsModal - | ReportModal - | AppealLabelModal - // Lists | CreateOrEditListModal | UserAddRemoveListsModal @@ -225,7 +172,6 @@ export type Modal = | InviteCodesModal // Generic - | ConfirmModal | LinkWarningModal | EmbedConsentModal | InAppBrowserConsentModal diff --git a/src/state/models/media/gallery.ts b/src/state/models/media/gallery.ts index 04023bf82..9c8c13010 100644 --- a/src/state/models/media/gallery.ts +++ b/src/state/models/media/gallery.ts @@ -4,11 +4,21 @@ import {Image as RNImage} from 'react-native-image-crop-picker' import {openPicker} from 'lib/media/picker' import {getImageDim} from 'lib/media/manip' +interface InitialImageUri { + uri: string + width: number + height: number +} + export class GalleryModel { images: ImageModel[] = [] - constructor() { + constructor(uris?: {uri: string; width: number; height: number}[]) { makeAutoObservable(this) + + if (uris) { + this.addFromUris(uris) + } } get isEmpty() { @@ -23,7 +33,7 @@ export class GalleryModel { return this.images.some(image => image.altText.trim() === '') } - async add(image_: Omit<RNImage, 'size'>) { + *add(image_: Omit<RNImage, 'size'>) { if (this.size >= 4) { return } @@ -86,4 +96,15 @@ export class GalleryModel { }), ) } + + async addFromUris(uris: InitialImageUri[]) { + for (const uriObj of uris) { + this.add({ + mime: 'image/jpeg', + height: uriObj.height, + width: uriObj.width, + path: uriObj.uri, + }) + } + } } diff --git a/src/state/persisted/__tests__/migrate.test.ts b/src/state/persisted/__tests__/migrate.test.ts index e4b55d5da..97767e273 100644 --- a/src/state/persisted/__tests__/migrate.test.ts +++ b/src/state/persisted/__tests__/migrate.test.ts @@ -26,7 +26,7 @@ test('migrate: fresh install', async () => { expect(AsyncStorage.getItem).toHaveBeenCalledWith('root') expect(read).toHaveBeenCalledTimes(1) - expect(logger.info).toHaveBeenCalledWith( + expect(logger.debug).toHaveBeenCalledWith( 'persisted state: no migration needed', ) }) @@ -38,7 +38,7 @@ test('migrate: fresh install, existing new storage', async () => { expect(AsyncStorage.getItem).toHaveBeenCalledWith('root') expect(read).toHaveBeenCalledTimes(1) - expect(logger.info).toHaveBeenCalledWith( + expect(logger.debug).toHaveBeenCalledWith( 'persisted state: no migration needed', ) }) @@ -68,7 +68,7 @@ test('migrate: has legacy data', async () => { await migrate() expect(write).toHaveBeenCalledWith(transform(fixtures.LEGACY_DATA_DUMP)) - expect(logger.info).toHaveBeenCalledWith( + expect(logger.debug).toHaveBeenCalledWith( 'persisted state: migrated legacy storage', ) }) diff --git a/src/state/persisted/index.ts b/src/state/persisted/index.ts index 2f34c2dbf..f57172d2f 100644 --- a/src/state/persisted/index.ts +++ b/src/state/persisted/index.ts @@ -19,7 +19,7 @@ const _emitter = new EventEmitter() * the Provider. */ export async function init() { - logger.info('persisted state: initializing') + logger.debug('persisted state: initializing') broadcast.onmessage = onBroadcastMessage @@ -27,11 +27,11 @@ export async function init() { await migrate() // migrate old store const stored = await store.read() // check for new store if (!stored) { - logger.info('persisted state: initializing default storage') + logger.debug('persisted state: initializing default storage') await store.write(defaults) // opt: init new store } _state = stored || defaults // return new store - logger.log('persisted state: initialized') + logger.debug('persisted state: initialized') } catch (e) { logger.error('persisted state: failed to load root state from storage', { message: e, diff --git a/src/state/persisted/legacy.ts b/src/state/persisted/legacy.ts index cce080c84..fd94a96a2 100644 --- a/src/state/persisted/legacy.ts +++ b/src/state/persisted/legacy.ts @@ -121,7 +121,7 @@ export function transform(legacy: Partial<LegacySchema>): Schema { * local storage AND old storage exists. */ export async function migrate() { - logger.info('persisted state: check need to migrate') + logger.debug('persisted state: check need to migrate') try { const rawLegacyData = await AsyncStorage.getItem( @@ -131,7 +131,7 @@ export async function migrate() { const alreadyMigrated = Boolean(newData) if (!alreadyMigrated && rawLegacyData) { - logger.info('persisted state: migrating legacy storage') + logger.debug('persisted state: migrating legacy storage') const legacyData = JSON.parse(rawLegacyData) const newData = transform(legacyData) @@ -139,14 +139,14 @@ export async function migrate() { if (validate.success) { await write(newData) - logger.info('persisted state: migrated legacy storage') + logger.debug('persisted state: migrated legacy storage') } else { logger.error('persisted state: legacy data failed validation', { message: validate.error, }) } } else { - logger.info('persisted state: no migration needed') + logger.debug('persisted state: no migration needed') } } catch (e: any) { logger.error(e, { diff --git a/src/state/preferences/in-app-browser.tsx b/src/state/preferences/in-app-browser.tsx index 4f033db65..2398f1f81 100644 --- a/src/state/preferences/in-app-browser.tsx +++ b/src/state/preferences/in-app-browser.tsx @@ -5,6 +5,11 @@ import * as WebBrowser from 'expo-web-browser' import {isNative} from '#/platform/detection' import {useModalControls} from '../modals' import {usePalette} from 'lib/hooks/usePalette' +import { + isBskyRSSUrl, + isRelativeUrl, + createBskyAppAbsoluteUrl, +} from 'lib/strings/url-helpers' type StateContext = persisted.Schema['useInAppBrowser'] type SetContext = (v: persisted.Schema['useInAppBrowser']) => void @@ -57,6 +62,10 @@ export function useOpenLink() { const openLink = React.useCallback( (url: string, override?: boolean) => { + if (isBskyRSSUrl(url) && isRelativeUrl(url)) { + url = createBskyAppAbsoluteUrl(url) + } + if (isNative && !url.startsWith('mailto:')) { if (override === undefined && enabled === undefined) { openModal({ diff --git a/src/state/preferences/index.tsx b/src/state/preferences/index.tsx index a442b763a..cf1d90151 100644 --- a/src/state/preferences/index.tsx +++ b/src/state/preferences/index.tsx @@ -15,6 +15,7 @@ export { useSetExternalEmbedPref, } from './external-embeds-prefs' export * from './hidden-posts' +export {useLabelDefinitions} from './label-defs' export function Provider({children}: React.PropsWithChildren<{}>) { return ( diff --git a/src/state/preferences/label-defs.tsx b/src/state/preferences/label-defs.tsx new file mode 100644 index 000000000..d60f8ccb8 --- /dev/null +++ b/src/state/preferences/label-defs.tsx @@ -0,0 +1,25 @@ +import React from 'react' +import {InterpretedLabelValueDefinition, AppBskyLabelerDefs} from '@atproto/api' +import {useLabelDefinitionsQuery} from '../queries/preferences' + +interface StateContext { + labelDefs: Record<string, InterpretedLabelValueDefinition[]> + labelers: AppBskyLabelerDefs.LabelerViewDetailed[] +} + +const stateContext = React.createContext<StateContext>({ + labelDefs: {}, + labelers: [], +}) + +export function Provider({children}: React.PropsWithChildren<{}>) { + const {labelDefs, labelers} = useLabelDefinitionsQuery() + + const state = {labelDefs, labelers} + + return <stateContext.Provider value={state}>{children}</stateContext.Provider> +} + +export function useLabelDefinitions() { + return React.useContext(stateContext) +} diff --git a/src/state/queries/actor-autocomplete.ts b/src/state/queries/actor-autocomplete.ts index 3159ad7aa..e6bf04ba3 100644 --- a/src/state/queries/actor-autocomplete.ts +++ b/src/state/queries/actor-autocomplete.ts @@ -6,17 +6,14 @@ import {logger} from '#/logger' import {getAgent} from '#/state/session' import {useMyFollowsQuery} from '#/state/queries/my-follows' import {STALE} from '#/state/queries' -import { - DEFAULT_LOGGED_OUT_PREFERENCES, - getModerationOpts, - useModerationOpts, -} from './preferences' +import {DEFAULT_LOGGED_OUT_PREFERENCES, useModerationOpts} from './preferences' import {isInvalidHandle} from '#/lib/strings/handles' +import {isJustAMute} from '#/lib/moderation' -const DEFAULT_MOD_OPTS = getModerationOpts({ - userDid: '', - preferences: DEFAULT_LOGGED_OUT_PREFERENCES, -}) +const DEFAULT_MOD_OPTS = { + userDid: undefined, + prefs: DEFAULT_LOGGED_OUT_PREFERENCES.moderationPrefs, +} export const RQKEY = (prefix: string) => ['actor-autocomplete', prefix] @@ -104,18 +101,12 @@ function computeSuggestions( } for (const item of searched) { if (!items.find(item2 => item2.handle === item.handle)) { - items.push({ - did: item.did, - handle: item.handle, - displayName: item.displayName, - avatar: item.avatar, - labels: item.labels, - }) + items.push(item) } } return items.filter(profile => { - const mod = moderateProfile(profile, moderationOpts) - return !mod.account.filter && mod.account.cause?.type !== 'muted' + const modui = moderateProfile(profile, moderationOpts).ui('profileList') + return !modui.filter || isJustAMute(modui) }) } diff --git a/src/state/queries/feed.ts b/src/state/queries/feed.ts index 67294ece2..1fa92c291 100644 --- a/src/state/queries/feed.ts +++ b/src/state/queries/feed.ts @@ -1,11 +1,9 @@ -import React from 'react' import { useQuery, useInfiniteQuery, InfiniteData, QueryKey, useMutation, - useQueryClient, } from '@tanstack/react-query' import { AtUri, @@ -15,7 +13,6 @@ import { AppBskyUnspeccedGetPopularFeedGenerators, } from '@atproto/api' -import {logger} from '#/logger' import {router} from '#/routes' import {sanitizeDisplayName} from '#/lib/strings/display-names' import {sanitizeHandle} from '#/lib/strings/handles' @@ -219,83 +216,59 @@ const FOLLOWING_FEED_STUB: FeedSourceInfo = { likeUri: '', } -export function usePinnedFeedsInfos(): { - feeds: FeedSourceInfo[] - hasPinnedCustom: boolean - isLoading: boolean -} { - const queryClient = useQueryClient() - const [tabs, setTabs] = React.useState<FeedSourceInfo[]>([ - FOLLOWING_FEED_STUB, - ]) - const [isLoading, setLoading] = React.useState(true) - const {data: preferences} = usePreferencesQuery() +export function usePinnedFeedsInfos() { + const {data: preferences, isLoading: isLoadingPrefs} = usePreferencesQuery() + const pinnedUris = preferences?.feeds?.pinned ?? [] - const hasPinnedCustom = React.useMemo<boolean>(() => { - return tabs.some(tab => tab !== FOLLOWING_FEED_STUB) - }, [tabs]) - - React.useEffect(() => { - if (!preferences?.feeds?.pinned) return - const uris = preferences.feeds.pinned - - async function fetchFeedInfo() { - const reqs = [] - - for (const uri of uris) { - const cached = queryClient.getQueryData<FeedSourceInfo>( - feedSourceInfoQueryKey({uri}), - ) - - if (cached) { - reqs.push(cached) - } else { - reqs.push( - (async () => { - // these requests can fail, need to filter those out - try { - return await queryClient.fetchQuery({ - staleTime: STALE.SECONDS.FIFTEEN, - queryKey: feedSourceInfoQueryKey({uri}), - queryFn: async () => { - const type = getFeedTypeFromUri(uri) + return useQuery({ + staleTime: STALE.INFINITY, + enabled: !isLoadingPrefs, + queryKey: ['pinnedFeedsInfos', pinnedUris.join(',')], + queryFn: async () => { + let resolved = new Map() + + // Get all feeds. We can do this in a batch. + const feedUris = pinnedUris.filter( + uri => getFeedTypeFromUri(uri) === 'feed', + ) + let feedsPromise = Promise.resolve() + if (feedUris.length > 0) { + feedsPromise = getAgent() + .app.bsky.feed.getFeedGenerators({ + feeds: feedUris, + }) + .then(res => { + for (let feedView of res.data.feeds) { + resolved.set(feedView.uri, hydrateFeedGenerator(feedView)) + } + }) + } - if (type === 'feed') { - const res = - await getAgent().app.bsky.feed.getFeedGenerator({ - feed: uri, - }) - return hydrateFeedGenerator(res.data.view) - } else { - const res = await getAgent().app.bsky.graph.getList({ - list: uri, - limit: 1, - }) - return hydrateList(res.data.list) - } - }, - }) - } catch (e) { - // expected failure - logger.info(`usePinnedFeedsInfos: failed to fetch ${uri}`, { - error: e, - }) - } - })(), - ) + // Get all lists. This currently has to be done individually. + const listUris = pinnedUris.filter( + uri => getFeedTypeFromUri(uri) === 'list', + ) + const listsPromises = listUris.map(listUri => + getAgent() + .app.bsky.graph.getList({ + list: listUri, + limit: 1, + }) + .then(res => { + const listView = res.data.list + resolved.set(listView.uri, hydrateList(listView)) + }), + ) + + // The returned result will have the original order. + const result = [FOLLOWING_FEED_STUB] + await Promise.allSettled([feedsPromise, ...listsPromises]) + for (let pinnedUri of pinnedUris) { + if (resolved.has(pinnedUri)) { + result.push(resolved.get(pinnedUri)) } } - - const views = (await Promise.all(reqs)).filter( - Boolean, - ) as FeedSourceInfo[] - - setTabs([FOLLOWING_FEED_STUB].concat(views)) - setLoading(false) - } - - fetchFeedInfo() - }, [queryClient, setTabs, preferences?.feeds?.pinned]) - - return {feeds: tabs, hasPinnedCustom, isLoading} + return result + }, + }) } diff --git a/src/state/queries/labeler.ts b/src/state/queries/labeler.ts new file mode 100644 index 000000000..b2f93c4a4 --- /dev/null +++ b/src/state/queries/labeler.ts @@ -0,0 +1,89 @@ +import {z} from 'zod' +import {useQuery, useMutation, useQueryClient} from '@tanstack/react-query' +import {AppBskyLabelerDefs} from '@atproto/api' + +import {getAgent} from '#/state/session' +import {preferencesQueryKey} from '#/state/queries/preferences' +import {STALE} from '#/state/queries' + +export const labelerInfoQueryKey = (did: string) => ['labeler-info', did] +export const labelersInfoQueryKey = (dids: string[]) => [ + 'labelers-info', + dids.sort(), +] +export const labelersDetailedInfoQueryKey = (dids: string[]) => [ + 'labelers-detailed-info', + dids, +] + +export function useLabelerInfoQuery({ + did, + enabled, +}: { + did?: string + enabled?: boolean +}) { + return useQuery({ + enabled: !!did && enabled !== false, + queryKey: labelerInfoQueryKey(did as string), + queryFn: async () => { + const res = await getAgent().app.bsky.labeler.getServices({ + dids: [did as string], + detailed: true, + }) + return res.data.views[0] as AppBskyLabelerDefs.LabelerViewDetailed + }, + }) +} + +export function useLabelersInfoQuery({dids}: {dids: string[]}) { + return useQuery({ + enabled: !!dids.length, + queryKey: labelersInfoQueryKey(dids), + queryFn: async () => { + const res = await getAgent().app.bsky.labeler.getServices({dids}) + return res.data.views as AppBskyLabelerDefs.LabelerView[] + }, + }) +} + +export function useLabelersDetailedInfoQuery({dids}: {dids: string[]}) { + return useQuery({ + enabled: !!dids.length, + queryKey: labelersDetailedInfoQueryKey(dids), + gcTime: 1000 * 60 * 60 * 6, // 6 hours + staleTime: STALE.MINUTES.ONE, + queryFn: async () => { + const res = await getAgent().app.bsky.labeler.getServices({ + dids, + detailed: true, + }) + return res.data.views as AppBskyLabelerDefs.LabelerViewDetailed[] + }, + }) +} + +export function useLabelerSubscriptionMutation() { + const queryClient = useQueryClient() + + return useMutation({ + async mutationFn({did, subscribe}: {did: string; subscribe: boolean}) { + // TODO + z.object({ + did: z.string(), + subscribe: z.boolean(), + }).parse({did, subscribe}) + + if (subscribe) { + await getAgent().addLabeler(did) + } else { + await getAgent().removeLabeler(did) + } + }, + onSuccess() { + queryClient.invalidateQueries({ + queryKey: preferencesQueryKey, + }) + }, + }) +} diff --git a/src/state/queries/notifications/feed.ts b/src/state/queries/notifications/feed.ts index b91db9237..405d054d4 100644 --- a/src/state/queries/notifications/feed.ts +++ b/src/state/queries/notifications/feed.ts @@ -133,23 +133,6 @@ export function useNotificationFeedQuery(opts?: {enabled?: boolean}) { return query } -/** - * This helper is used by the post-thread placeholder function to - * find a post in the query-data cache - */ -export function findPostInQueryData( - queryClient: QueryClient, - uri: string, -): AppBskyFeedDefs.PostView | undefined { - const generator = findAllPostsInQueryData(queryClient, uri) - const result = generator.next() - if (result.done) { - return undefined - } else { - return result.value - } -} - export function* findAllPostsInQueryData( queryClient: QueryClient, uri: string, diff --git a/src/state/queries/notifications/util.ts b/src/state/queries/notifications/util.ts index 626d3e911..97fc57dc1 100644 --- a/src/state/queries/notifications/util.ts +++ b/src/state/queries/notifications/util.ts @@ -1,14 +1,13 @@ import { AppBskyNotificationListNotifications, ModerationOpts, - moderateProfile, + moderateNotification, AppBskyFeedDefs, AppBskyFeedPost, AppBskyFeedRepost, AppBskyFeedLike, AppBskyEmbedRecord, } from '@atproto/api' -import {moderatePost_wrapped as moderatePost} from '#/lib/moderatePost_wrapped' import chunk from 'lodash.chunk' import {QueryClient} from '@tanstack/react-query' import {getAgent} from '../../session' @@ -88,37 +87,20 @@ export async function fetchPage({ // internal methods // = -// TODO this should be in the sdk as moderateNotification -prf -function shouldFilterNotif( +export function shouldFilterNotif( notif: AppBskyNotificationListNotifications.Notification, moderationOpts: ModerationOpts | undefined, ): boolean { if (!moderationOpts) { return false } - const profile = moderateProfile(notif.author, moderationOpts) - if ( - profile.account.filter || - profile.profile.filter || - notif.author.viewer?.muted - ) { - return true - } - if ( - notif.type === 'reply' || - notif.type === 'quote' || - notif.type === 'mention' - ) { - // NOTE: the notification overlaps the post enough for this to work - const post = moderatePost(notif, moderationOpts) - if (post.content.filter) { - return true - } + if (notif.author.viewer?.following) { + return false } - return false + return moderateNotification(notif, moderationOpts).ui('contentList').filter } -function groupNotifications( +export function groupNotifications( notifs: AppBskyNotificationListNotifications.Notification[], ): FeedNotification[] { const groupedNotifs: FeedNotification[] = [] diff --git a/src/state/queries/post-feed.ts b/src/state/queries/post-feed.ts index 320009089..b89888197 100644 --- a/src/state/queries/post-feed.ts +++ b/src/state/queries/post-feed.ts @@ -1,34 +1,39 @@ import React, {useCallback, useEffect, useRef} from 'react' import {AppState} from 'react-native' -import {AppBskyFeedDefs, AppBskyFeedPost, PostModeration} from '@atproto/api' import { - useInfiniteQuery, + AppBskyFeedDefs, + AppBskyFeedPost, + AtUri, + ModerationDecision, +} from '@atproto/api' +import { InfiniteData, - QueryKey, QueryClient, + QueryKey, + useInfiniteQuery, useQueryClient, } from '@tanstack/react-query' + +import {HomeFeedAPI} from '#/lib/api/feed/home' import {moderatePost_wrapped as moderatePost} from '#/lib/moderatePost_wrapped' -import {useFeedTuners} from '../preferences/feed-tuners' -import {FeedTuner, FeedTunerFn, NoopFeedTuner} from 'lib/api/feed-manip' -import {FeedAPI, ReasonFeedSource} from 'lib/api/feed/types' -import {FollowingFeedAPI} from 'lib/api/feed/following' +import {logger} from '#/logger' +import {STALE} from '#/state/queries' +import {DEFAULT_LOGGED_OUT_PREFERENCES} from '#/state/queries/preferences/const' +import {getAgent} from '#/state/session' import {AuthorFeedAPI} from 'lib/api/feed/author' -import {LikesFeedAPI} from 'lib/api/feed/likes' import {CustomFeedAPI} from 'lib/api/feed/custom' +import {FollowingFeedAPI} from 'lib/api/feed/following' +import {LikesFeedAPI} from 'lib/api/feed/likes' import {ListFeedAPI} from 'lib/api/feed/list' import {MergeFeedAPI} from 'lib/api/feed/merge' -import {HomeFeedAPI} from '#/lib/api/feed/home' -import {logger} from '#/logger' -import {STALE} from '#/state/queries' -import {precacheFeedPostProfiles} from './profile' -import {getAgent} from '#/state/session' -import {DEFAULT_LOGGED_OUT_PREFERENCES} from '#/state/queries/preferences/const' -import {getModerationOpts} from '#/state/queries/preferences/moderation' +import {FeedAPI, ReasonFeedSource} from 'lib/api/feed/types' +import {FeedTuner, FeedTunerFn, NoopFeedTuner} from 'lib/api/feed-manip' +import {BSKY_FEED_OWNER_DIDS} from 'lib/constants' import {KnownError} from '#/view/com/posts/FeedErrorMessage' -import {embedViewRecordToPostView, getEmbeddedPost} from './util' +import {useFeedTuners} from '../preferences/feed-tuners' import {useModerationOpts} from './preferences' -import {queryClient} from 'lib/react-query' +import {precacheFeedPostProfiles} from './profile' +import {embedViewRecordToPostView, getEmbeddedPost} from './util' type ActorDid = string type AuthorFilter = @@ -63,7 +68,7 @@ export interface FeedPostSliceItem { post: AppBskyFeedDefs.PostView record: AppBskyFeedPost.Record reason?: AppBskyFeedDefs.ReasonRepost | ReasonFeedSource - moderation: PostModeration + moderation: ModerationDecision } export interface FeedPostSlice { @@ -137,24 +142,41 @@ export function usePostFeedQuery( cursor: undefined, } - const res = await api.fetch({cursor, limit: PAGE_SIZE}) - precacheFeedPostProfiles(queryClient, res.feed) - - /* - * If this is a public view, we need to check if posts fail moderation. - * If all fail, we throw an error. If only some fail, we continue and let - * moderations happen later, which results in some posts being shown and - * some not. - */ - if (!getAgent().session) { - assertSomePostsPassModeration(res.feed) - } + try { + const res = await api.fetch({cursor, limit: PAGE_SIZE}) + precacheFeedPostProfiles(queryClient, res.feed) + + /* + * If this is a public view, we need to check if posts fail moderation. + * If all fail, we throw an error. If only some fail, we continue and let + * moderations happen later, which results in some posts being shown and + * some not. + */ + if (!getAgent().session) { + assertSomePostsPassModeration(res.feed) + } + + return { + api, + cursor: res.cursor, + feed: res.feed, + fetchedAt: Date.now(), + } + } catch (e) { + const feedDescParts = feedDesc.split('|') + const feedOwnerDid = new AtUri(feedDescParts[1]).hostname - return { - api, - cursor: res.cursor, - feed: res.feed, - fetchedAt: Date.now(), + if ( + feedDescParts[0] === 'feedgen' && + BSKY_FEED_OWNER_DIDS.includes(feedOwnerDid) + ) { + logger.error(`Bluesky feed may be offline: ${feedOwnerDid}`, { + feedDesc, + jsError: e, + }) + } + + throw e } }, initialPageParam: undefined, @@ -227,9 +249,17 @@ export function usePostFeedQuery( // apply moderation filter for (let i = 0; i < slice.items.length; i++) { + const ignoreFilter = + slice.items[i].post.author.did === ignoreFilterFor + if (ignoreFilter) { + // remove mutes to avoid confused UIs + moderations[i].causes = moderations[i].causes.filter( + cause => cause.type !== 'muted', + ) + } if ( - moderations[i]?.content.filter && - slice.items[i].post.author.did !== ignoreFilterFor + !ignoreFilter && + moderations[i]?.ui('contentList').filter ) { return undefined } @@ -253,7 +283,7 @@ export function usePostFeedQuery( .success ) { return { - _reactKey: `${slice._reactKey}-${i}`, + _reactKey: `${slice._reactKey}-${i}-${item.post.uri}`, uri: item.post.uri, post: item.post, record: item.post.record, @@ -365,23 +395,6 @@ function createApi( } } -/** - * This helper is used by the post-thread placeholder function to - * find a post in the query-data cache - */ -export function findPostInQueryData( - queryClient: QueryClient, - uri: string, -): AppBskyFeedDefs.PostView | undefined { - const generator = findAllPostsInQueryData(queryClient, uri) - const result = generator.next() - if (result.done) { - return undefined - } else { - return result.value - } -} - export function* findAllPostsInQueryData( queryClient: QueryClient, uri: string, @@ -429,13 +442,12 @@ function assertSomePostsPassModeration(feed: AppBskyFeedDefs.FeedViewPost[]) { let somePostsPassModeration = false for (const item of feed) { - const moderationOpts = getModerationOpts({ - userDid: '', - preferences: DEFAULT_LOGGED_OUT_PREFERENCES, + const moderation = moderatePost(item.post, { + userDid: undefined, + prefs: DEFAULT_LOGGED_OUT_PREFERENCES.moderationPrefs, }) - const moderation = moderatePost(item.post, moderationOpts) - if (!moderation.content.filter) { + if (!moderation.ui('contentList').filter) { // we have a sfw post somePostsPassModeration = true } @@ -446,7 +458,11 @@ function assertSomePostsPassModeration(feed: AppBskyFeedDefs.FeedViewPost[]) { } } -export function resetProfilePostsQueries(did: string, timeout = 0) { +export function resetProfilePostsQueries( + queryClient: QueryClient, + did: string, + timeout = 0, +) { setTimeout(() => { queryClient.resetQueries({ predicate: query => diff --git a/src/state/queries/post-liked-by.ts b/src/state/queries/post-liked-by.ts index 2cde07f28..a0498ada4 100644 --- a/src/state/queries/post-liked-by.ts +++ b/src/state/queries/post-liked-by.ts @@ -12,9 +12,9 @@ const PAGE_SIZE = 30 type RQPageParam = string | undefined // TODO refactor invalidate on mutate? -export const RQKEY = (resolvedUri: string) => ['post-liked-by', resolvedUri] +export const RQKEY = (resolvedUri: string) => ['liked-by', resolvedUri] -export function usePostLikedByQuery(resolvedUri: string | undefined) { +export function useLikedByQuery(resolvedUri: string | undefined) { return useInfiniteQuery< AppBskyFeedGetLikes.OutputSchema, Error, diff --git a/src/state/queries/post-thread.ts b/src/state/queries/post-thread.ts index ba4243163..26d40599c 100644 --- a/src/state/queries/post-thread.ts +++ b/src/state/queries/post-thread.ts @@ -8,8 +8,8 @@ import {useQuery, useQueryClient, QueryClient} from '@tanstack/react-query' import {getAgent} from '#/state/session' import {UsePreferencesQueryResponse} from '#/state/queries/preferences/types' -import {findPostInQueryData as findPostInFeedQueryData} from './post-feed' -import {findPostInQueryData as findPostInNotifsQueryData} from './notifications/feed' +import {findAllPostsInQueryData as findAllPostsInFeedQueryData} from './post-feed' +import {findAllPostsInQueryData as findAllPostsInNotifsQueryData} from './notifications/feed' import {precacheThreadPostProfiles} from './profile' import {getEmbeddedPost} from './util' @@ -82,21 +82,9 @@ export function usePostThreadQuery(uri: string | undefined) { return undefined } { - const item = findPostInQueryData(queryClient, uri) - if (item) { - return threadNodeToPlaceholderThread(item) - } - } - { - const item = findPostInFeedQueryData(queryClient, uri) - if (item) { - return postViewToPlaceholderThread(item) - } - } - { - const item = findPostInNotifsQueryData(queryClient, uri) - if (item) { - return postViewToPlaceholderThread(item) + const post = findPostInQueryData(queryClient, uri) + if (post) { + return post } } return undefined @@ -171,11 +159,18 @@ function responseToThreadNodes( AppBskyFeedPost.isRecord(node.post.record) && AppBskyFeedPost.validateRecord(node.post.record).success ) { + const post = node.post + // These should normally be present. They're missing only for + // posts that were *just* created. Ideally, the backend would + // know to return zeros. Fill them in manually to compensate. + post.replyCount ??= 0 + post.likeCount ??= 0 + post.repostCount ??= 0 return { type: 'post', _reactKey: node.post.uri, uri: node.post.uri, - post: node.post, + post: post, record: node.post.record, parent: node.parent && direction !== 'down' @@ -213,14 +208,24 @@ function responseToThreadNodes( function findPostInQueryData( queryClient: QueryClient, uri: string, -): ThreadNode | undefined { - const generator = findAllPostsInQueryData(queryClient, uri) - const result = generator.next() - if (result.done) { - return undefined - } else { - return result.value +): ThreadNode | void { + let partial + for (let item of findAllPostsInQueryData(queryClient, uri)) { + if (item.type === 'post') { + // Currently, the backend doesn't send full post info in some cases + // (for example, for quoted posts). We use missing `likeCount` + // as a way to detect that. In the future, we should fix this on + // the backend, which will let us always stop on the first result. + const hasAllInfo = item.post.likeCount != null + if (hasAllInfo) { + return item + } else { + partial = item + // Keep searching, we might still find a full post in the cache. + } + } } + return partial } export function* findAllPostsInQueryData( @@ -236,7 +241,10 @@ export function* findAllPostsInQueryData( } for (const item of traverseThread(queryData)) { if (item.uri === uri) { - yield item + const placeholder = threadNodeToPlaceholderThread(item) + if (placeholder) { + yield placeholder + } } const quotedPost = item.type === 'post' ? getEmbeddedPost(item.post.embed) : undefined @@ -245,6 +253,12 @@ export function* findAllPostsInQueryData( } } } + for (let post of findAllPostsInFeedQueryData(queryClient, uri)) { + yield postViewToPlaceholderThread(post) + } + for (let post of findAllPostsInNotifsQueryData(queryClient, uri)) { + yield postViewToPlaceholderThread(post) + } } function* traverseThread(node: ThreadNode): Generator<ThreadNode, void> { diff --git a/src/state/queries/post.ts b/src/state/queries/post.ts index eb59f7da4..b868a1dac 100644 --- a/src/state/queries/post.ts +++ b/src/state/queries/post.ts @@ -1,11 +1,13 @@ import {useCallback} from 'react' import {AppBskyFeedDefs, AtUri} from '@atproto/api' -import {useQuery, useMutation, useQueryClient} from '@tanstack/react-query' -import {Shadow} from '#/state/cache/types' -import {getAgent} from '#/state/session' -import {updatePostShadow} from '#/state/cache/post-shadow' +import {useMutation, useQuery, useQueryClient} from '@tanstack/react-query' + import {track} from '#/lib/analytics/analytics' import {useToggleMutationQueue} from '#/lib/hooks/useToggleMutationQueue' +import {logEvent, LogEvents} from '#/lib/statsig/statsig' +import {updatePostShadow} from '#/state/cache/post-shadow' +import {Shadow} from '#/state/cache/types' +import {getAgent} from '#/state/session' export const RQKEY = (postUri: string) => ['post', postUri] @@ -58,12 +60,15 @@ export function useGetPost() { export function usePostLikeMutationQueue( post: Shadow<AppBskyFeedDefs.PostView>, + logContext: LogEvents['post:like']['logContext'] & + LogEvents['post:unlike']['logContext'], ) { + const queryClient = useQueryClient() const postUri = post.uri const postCid = post.cid const initialLikeUri = post.viewer?.like - const likeMutation = usePostLikeMutation() - const unlikeMutation = usePostUnlikeMutation() + const likeMutation = usePostLikeMutation(logContext) + const unlikeMutation = usePostUnlikeMutation(logContext) const queueToggle = useToggleMutationQueue({ initialState: initialLikeUri, @@ -86,7 +91,7 @@ export function usePostLikeMutationQueue( }, onSuccess(finalLikeUri) { // finalize - updatePostShadow(postUri, { + updatePostShadow(queryClient, postUri, { likeUri: finalLikeUri, }) }, @@ -94,39 +99,47 @@ export function usePostLikeMutationQueue( const queueLike = useCallback(() => { // optimistically update - updatePostShadow(postUri, { + updatePostShadow(queryClient, postUri, { likeUri: 'pending', }) return queueToggle(true) - }, [postUri, queueToggle]) + }, [queryClient, postUri, queueToggle]) const queueUnlike = useCallback(() => { // optimistically update - updatePostShadow(postUri, { + updatePostShadow(queryClient, postUri, { likeUri: undefined, }) return queueToggle(false) - }, [postUri, queueToggle]) + }, [queryClient, postUri, queueToggle]) return [queueLike, queueUnlike] } -function usePostLikeMutation() { +function usePostLikeMutation(logContext: LogEvents['post:like']['logContext']) { return useMutation< {uri: string}, // responds with the uri of the like Error, {uri: string; cid: string} // the post's uri and cid >({ - mutationFn: post => getAgent().like(post.uri, post.cid), + mutationFn: post => { + logEvent('post:like', {logContext}) + return getAgent().like(post.uri, post.cid) + }, onSuccess() { track('Post:Like') }, }) } -function usePostUnlikeMutation() { +function usePostUnlikeMutation( + logContext: LogEvents['post:unlike']['logContext'], +) { return useMutation<void, Error, {postUri: string; likeUri: string}>({ - mutationFn: ({likeUri}) => getAgent().deleteLike(likeUri), + mutationFn: ({likeUri}) => { + logEvent('post:unlike', {logContext}) + return getAgent().deleteLike(likeUri) + }, onSuccess() { track('Post:Unlike') }, @@ -135,12 +148,15 @@ function usePostUnlikeMutation() { export function usePostRepostMutationQueue( post: Shadow<AppBskyFeedDefs.PostView>, + logContext: LogEvents['post:repost']['logContext'] & + LogEvents['post:unrepost']['logContext'], ) { + const queryClient = useQueryClient() const postUri = post.uri const postCid = post.cid const initialRepostUri = post.viewer?.repost - const repostMutation = usePostRepostMutation() - const unrepostMutation = usePostUnrepostMutation() + const repostMutation = usePostRepostMutation(logContext) + const unrepostMutation = usePostUnrepostMutation(logContext) const queueToggle = useToggleMutationQueue({ initialState: initialRepostUri, @@ -163,7 +179,7 @@ export function usePostRepostMutationQueue( }, onSuccess(finalRepostUri) { // finalize - updatePostShadow(postUri, { + updatePostShadow(queryClient, postUri, { repostUri: finalRepostUri, }) }, @@ -171,39 +187,49 @@ export function usePostRepostMutationQueue( const queueRepost = useCallback(() => { // optimistically update - updatePostShadow(postUri, { + updatePostShadow(queryClient, postUri, { repostUri: 'pending', }) return queueToggle(true) - }, [postUri, queueToggle]) + }, [queryClient, postUri, queueToggle]) const queueUnrepost = useCallback(() => { // optimistically update - updatePostShadow(postUri, { + updatePostShadow(queryClient, postUri, { repostUri: undefined, }) return queueToggle(false) - }, [postUri, queueToggle]) + }, [queryClient, postUri, queueToggle]) return [queueRepost, queueUnrepost] } -function usePostRepostMutation() { +function usePostRepostMutation( + logContext: LogEvents['post:repost']['logContext'], +) { return useMutation< {uri: string}, // responds with the uri of the repost Error, {uri: string; cid: string} // the post's uri and cid >({ - mutationFn: post => getAgent().repost(post.uri, post.cid), + mutationFn: post => { + logEvent('post:repost', {logContext}) + return getAgent().repost(post.uri, post.cid) + }, onSuccess() { track('Post:Repost') }, }) } -function usePostUnrepostMutation() { +function usePostUnrepostMutation( + logContext: LogEvents['post:unrepost']['logContext'], +) { return useMutation<void, Error, {postUri: string; repostUri: string}>({ - mutationFn: ({repostUri}) => getAgent().deleteRepost(repostUri), + mutationFn: ({repostUri}) => { + logEvent('post:unrepost', {logContext}) + return getAgent().deleteRepost(repostUri) + }, onSuccess() { track('Post:Unrepost') }, @@ -211,12 +237,13 @@ function usePostUnrepostMutation() { } export function usePostDeleteMutation() { + const queryClient = useQueryClient() return useMutation<void, Error, {uri: string}>({ mutationFn: async ({uri}) => { await getAgent().deletePost(uri) }, onSuccess(data, variables) { - updatePostShadow(variables.uri, {isDeleted: true}) + updatePostShadow(queryClient, variables.uri, {isDeleted: true}) track('Post:Delete') }, }) diff --git a/src/state/queries/preferences/const.ts b/src/state/queries/preferences/const.ts index 2d9d02994..4cb4d1e96 100644 --- a/src/state/queries/preferences/const.ts +++ b/src/state/queries/preferences/const.ts @@ -7,7 +7,7 @@ import {DEFAULT_LOGGED_OUT_LABEL_PREFERENCES} from '#/state/queries/preferences/ export const DEFAULT_HOME_FEED_PREFS: UsePreferencesQueryResponse['feedViewPrefs'] = { hideReplies: false, - hideRepliesByUnfollowed: false, + hideRepliesByUnfollowed: true, hideRepliesByLikeCount: 0, hideReposts: false, hideQuotePosts: false, @@ -29,21 +29,17 @@ export const DEFAULT_PROD_FEEDS = { export const DEFAULT_LOGGED_OUT_PREFERENCES: UsePreferencesQueryResponse = { birthDate: new Date('2022-11-17'), // TODO(pwi) - adultContentEnabled: false, feeds: { saved: [], pinned: [], unpinned: [], }, - // labels are undefined until set by user - contentLabels: { - nsfw: DEFAULT_LOGGED_OUT_LABEL_PREFERENCES.nsfw, - nudity: DEFAULT_LOGGED_OUT_LABEL_PREFERENCES.nudity, - suggestive: DEFAULT_LOGGED_OUT_LABEL_PREFERENCES.suggestive, - gore: DEFAULT_LOGGED_OUT_LABEL_PREFERENCES.gore, - hate: DEFAULT_LOGGED_OUT_LABEL_PREFERENCES.hate, - spam: DEFAULT_LOGGED_OUT_LABEL_PREFERENCES.spam, - impersonation: DEFAULT_LOGGED_OUT_LABEL_PREFERENCES.impersonation, + moderationPrefs: { + adultContentEnabled: false, + labels: DEFAULT_LOGGED_OUT_LABEL_PREFERENCES, + labelers: [], + mutedWords: [], + hiddenPosts: [], }, feedViewPrefs: DEFAULT_HOME_FEED_PREFS, threadViewPrefs: DEFAULT_THREAD_VIEW_PREFS, diff --git a/src/state/queries/preferences/index.ts b/src/state/queries/preferences/index.ts index 632d31a13..f9cd59cda 100644 --- a/src/state/queries/preferences/index.ts +++ b/src/state/queries/preferences/index.ts @@ -1,25 +1,29 @@ -import {useMemo} from 'react' +import {useMemo, createContext, useContext} from 'react' import {useQuery, useMutation, useQueryClient} from '@tanstack/react-query' -import {LabelPreference, BskyFeedViewPreference} from '@atproto/api' +import { + LabelPreference, + BskyFeedViewPreference, + ModerationOpts, + AppBskyActorDefs, + BSKY_LABELER_DID, +} from '@atproto/api' import {track} from '#/lib/analytics/analytics' import {getAge} from '#/lib/strings/time' -import {useSession, getAgent} from '#/state/session' -import {DEFAULT_LABEL_PREFERENCES} from '#/state/queries/preferences/moderation' +import {getAgent, useSession} from '#/state/session' import { - ConfigurableLabelGroup, UsePreferencesQueryResponse, ThreadViewPreferences, } from '#/state/queries/preferences/types' -import {temp__migrateLabelPref} from '#/state/queries/preferences/util' import { DEFAULT_HOME_FEED_PREFS, DEFAULT_THREAD_VIEW_PREFS, DEFAULT_LOGGED_OUT_PREFERENCES, } from '#/state/queries/preferences/const' -import {getModerationOpts} from '#/state/queries/preferences/moderation' +import {DEFAULT_LOGGED_OUT_LABEL_PREFERENCES} from '#/state/queries/preferences/moderation' import {STALE} from '#/state/queries' -import {useHiddenPosts} from '#/state/preferences/hidden-posts' +import {useHiddenPosts, useLabelDefinitions} from '#/state/preferences' +import {saveLabelers} from '#/state/session/agent-config' export * from '#/state/queries/preferences/types' export * from '#/state/queries/preferences/moderation' @@ -40,6 +44,13 @@ export function usePreferencesQuery() { return DEFAULT_LOGGED_OUT_PREFERENCES } else { const res = await agent.getPreferences() + + // save to local storage to ensure there are labels on initial requests + saveLabelers( + agent.session.did, + res.moderationPrefs.labelers.map(l => l.did), + ) + const preferences: UsePreferencesQueryResponse = { ...res, feeds: { @@ -50,32 +61,6 @@ export function usePreferencesQuery() { return !res.feeds.pinned?.includes(f) }) || [], }, - // labels are undefined until set by user - contentLabels: { - nsfw: temp__migrateLabelPref( - res.contentLabels?.nsfw || DEFAULT_LABEL_PREFERENCES.nsfw, - ), - nudity: temp__migrateLabelPref( - res.contentLabels?.nudity || DEFAULT_LABEL_PREFERENCES.nudity, - ), - suggestive: temp__migrateLabelPref( - res.contentLabels?.suggestive || - DEFAULT_LABEL_PREFERENCES.suggestive, - ), - gore: temp__migrateLabelPref( - res.contentLabels?.gore || DEFAULT_LABEL_PREFERENCES.gore, - ), - hate: temp__migrateLabelPref( - res.contentLabels?.hate || DEFAULT_LABEL_PREFERENCES.hate, - ), - spam: temp__migrateLabelPref( - res.contentLabels?.spam || DEFAULT_LABEL_PREFERENCES.spam, - ), - impersonation: temp__migrateLabelPref( - res.contentLabels?.impersonation || - DEFAULT_LABEL_PREFERENCES.impersonation, - ), - }, feedViewPrefs: { ...DEFAULT_HOME_FEED_PREFS, ...(res.feedViewPrefs.home || {}), @@ -92,24 +77,41 @@ export function usePreferencesQuery() { }) } +// used in the moderation state devtool +export const moderationOptsOverrideContext = createContext< + ModerationOpts | undefined +>(undefined) + export function useModerationOpts() { + const override = useContext(moderationOptsOverrideContext) const {currentAccount} = useSession() const prefs = usePreferencesQuery() - const hiddenPosts = useHiddenPosts() - const opts = useMemo(() => { + const {labelDefs} = useLabelDefinitions() + const hiddenPosts = useHiddenPosts() // TODO move this into pds-stored prefs + const opts = useMemo<ModerationOpts | undefined>(() => { + if (override) { + return override + } if (!prefs.data) { return } - const moderationOpts = getModerationOpts({ - userDid: currentAccount?.did || '', - preferences: prefs.data, - }) - return { - ...moderationOpts, - hiddenPosts, + userDid: currentAccount?.did, + prefs: { + ...prefs.data.moderationPrefs, + labelers: prefs.data.moderationPrefs.labelers.length + ? prefs.data.moderationPrefs.labelers + : [ + { + did: BSKY_LABELER_DID, + labels: DEFAULT_LOGGED_OUT_LABEL_PREFERENCES, + }, + ], + hiddenPosts: hiddenPosts || [], + }, + labelDefs, } - }, [currentAccount?.did, prefs.data, hiddenPosts]) + }, [override, currentAccount, labelDefs, prefs.data, hiddenPosts]) return opts } @@ -133,10 +135,32 @@ export function usePreferencesSetContentLabelMutation() { return useMutation< void, unknown, - {labelGroup: ConfigurableLabelGroup; visibility: LabelPreference} + {label: string; visibility: LabelPreference; labelerDid: string | undefined} >({ - mutationFn: async ({labelGroup, visibility}) => { - await getAgent().setContentLabelPref(labelGroup, visibility) + mutationFn: async ({label, visibility, labelerDid}) => { + await getAgent().setContentLabelPref(label, visibility, labelerDid) + // triggers a refetch + await queryClient.invalidateQueries({ + queryKey: preferencesQueryKey, + }) + }, + }) +} + +export function useSetContentLabelMutation() { + const queryClient = useQueryClient() + + return useMutation({ + mutationFn: async ({ + label, + visibility, + labelerDid, + }: { + label: string + visibility: LabelPreference + labelerDid?: string + }) => { + await getAgent().setContentLabelPref(label, visibility, labelerDid) // triggers a refetch await queryClient.invalidateQueries({ queryKey: preferencesQueryKey, @@ -164,7 +188,7 @@ export function usePreferencesSetBirthDateMutation() { return useMutation<void, unknown, {birthDate: Date}>({ mutationFn: async ({birthDate}: {birthDate: Date}) => { - await getAgent().setPersonalDetails({birthDate}) + await getAgent().setPersonalDetails({birthDate: birthDate.toISOString()}) // triggers a refetch await queryClient.invalidateQueries({ queryKey: preferencesQueryKey, @@ -278,3 +302,45 @@ export function useUnpinFeedMutation() { }, }) } + +export function useUpsertMutedWordsMutation() { + const queryClient = useQueryClient() + + return useMutation({ + mutationFn: async (mutedWords: AppBskyActorDefs.MutedWord[]) => { + await getAgent().upsertMutedWords(mutedWords) + // triggers a refetch + await queryClient.invalidateQueries({ + queryKey: preferencesQueryKey, + }) + }, + }) +} + +export function useUpdateMutedWordMutation() { + const queryClient = useQueryClient() + + return useMutation({ + mutationFn: async (mutedWord: AppBskyActorDefs.MutedWord) => { + await getAgent().updateMutedWord(mutedWord) + // triggers a refetch + await queryClient.invalidateQueries({ + queryKey: preferencesQueryKey, + }) + }, + }) +} + +export function useRemoveMutedWordMutation() { + const queryClient = useQueryClient() + + return useMutation({ + mutationFn: async (mutedWord: AppBskyActorDefs.MutedWord) => { + await getAgent().removeMutedWord(mutedWord) + // triggers a refetch + await queryClient.invalidateQueries({ + queryKey: preferencesQueryKey, + }) + }, + }) +} diff --git a/src/state/queries/preferences/moderation.ts b/src/state/queries/preferences/moderation.ts index cdae52937..9cd183e8b 100644 --- a/src/state/queries/preferences/moderation.ts +++ b/src/state/queries/preferences/moderation.ts @@ -1,181 +1,53 @@ +import React from 'react' import { - LabelPreference, - ComAtprotoLabelDefs, - ModerationOpts, + DEFAULT_LABEL_SETTINGS, + BskyAgent, + interpretLabelValueDefinitions, } from '@atproto/api' -import { - LabelGroup, - ConfigurableLabelGroup, - UsePreferencesQueryResponse, -} from '#/state/queries/preferences/types' - -export type Label = ComAtprotoLabelDefs.Label - -export type LabelGroupConfig = { - id: LabelGroup - title: string - isAdultImagery?: boolean - subtitle?: string - warning: string - values: string[] -} - -export const DEFAULT_LABEL_PREFERENCES: Record< - ConfigurableLabelGroup, - LabelPreference -> = { - nsfw: 'hide', - nudity: 'warn', - suggestive: 'warn', - gore: 'warn', - hate: 'hide', - spam: 'hide', - impersonation: 'hide', -} +import {usePreferencesQuery} from './index' +import {useLabelersDetailedInfoQuery} from '../labeler' /** * More strict than our default settings for logged in users. - * - * TODO(pwi) */ -export const DEFAULT_LOGGED_OUT_LABEL_PREFERENCES: Record< - ConfigurableLabelGroup, - LabelPreference -> = { - nsfw: 'hide', - nudity: 'hide', - suggestive: 'hide', - gore: 'hide', - hate: 'hide', - spam: 'hide', - impersonation: 'hide', -} - -export const ILLEGAL_LABEL_GROUP: LabelGroupConfig = { - id: 'illegal', - title: 'Illegal Content', - warning: 'Illegal Content', - values: ['csam', 'dmca-violation', 'nudity-nonconsensual'], -} - -export const ALWAYS_FILTER_LABEL_GROUP: LabelGroupConfig = { - id: 'always-filter', - title: 'Content Warning', - warning: 'Content Warning', - values: ['!filter'], -} - -export const ALWAYS_WARN_LABEL_GROUP: LabelGroupConfig = { - id: 'always-warn', - title: 'Content Warning', - warning: 'Content Warning', - values: ['!warn', 'account-security'], -} - -export const UNKNOWN_LABEL_GROUP: LabelGroupConfig = { - id: 'unknown', - title: 'Unknown Label', - warning: 'Content Warning', - values: [], -} - -export const CONFIGURABLE_LABEL_GROUPS: Record< - ConfigurableLabelGroup, - LabelGroupConfig -> = { - nsfw: { - id: 'nsfw', - title: 'Explicit Sexual Images', - subtitle: 'i.e. pornography', - warning: 'Sexually Explicit', - values: ['porn', 'nsfl'], - isAdultImagery: true, - }, - nudity: { - id: 'nudity', - title: 'Other Nudity', - subtitle: 'Including non-sexual and artistic', - warning: 'Nudity', - values: ['nudity'], - isAdultImagery: true, - }, - suggestive: { - id: 'suggestive', - title: 'Sexually Suggestive', - subtitle: 'Does not include nudity', - warning: 'Sexually Suggestive', - values: ['sexual'], - isAdultImagery: true, - }, - gore: { - id: 'gore', - title: 'Violent / Bloody', - subtitle: 'Gore, self-harm, torture', - warning: 'Violence', - values: ['gore', 'self-harm', 'torture', 'nsfl', 'corpse'], - isAdultImagery: true, - }, - hate: { - id: 'hate', - title: 'Hate Group Iconography', - subtitle: 'Images of terror groups, articles covering events, etc.', - warning: 'Hate Groups', - values: ['icon-kkk', 'icon-nazi', 'icon-intolerant', 'behavior-intolerant'], - }, - spam: { - id: 'spam', - title: 'Spam', - subtitle: 'Excessive unwanted interactions', - warning: 'Spam', - values: ['spam'], - }, - impersonation: { - id: 'impersonation', - title: 'Impersonation', - subtitle: 'Accounts falsely claiming to be people or orgs', - warning: 'Impersonation', - values: ['impersonation'], - }, -} - -export function getModerationOpts({ - userDid, - preferences, -}: { - userDid: string - preferences: UsePreferencesQueryResponse -}): ModerationOpts { - return { - userDid: userDid, - adultContentEnabled: preferences.adultContentEnabled, - labels: { - porn: preferences.contentLabels.nsfw, - sexual: preferences.contentLabels.suggestive, - nudity: preferences.contentLabels.nudity, - nsfl: preferences.contentLabels.gore, - corpse: preferences.contentLabels.gore, - gore: preferences.contentLabels.gore, - torture: preferences.contentLabels.gore, - 'self-harm': preferences.contentLabels.gore, - 'intolerant-race': preferences.contentLabels.hate, - 'intolerant-gender': preferences.contentLabels.hate, - 'intolerant-sexual-orientation': preferences.contentLabels.hate, - 'intolerant-religion': preferences.contentLabels.hate, - intolerant: preferences.contentLabels.hate, - 'icon-intolerant': preferences.contentLabels.hate, - spam: preferences.contentLabels.spam, - impersonation: preferences.contentLabels.impersonation, - scam: 'warn', - }, - labelers: [ - { - labeler: { - did: '', - displayName: 'Bluesky Social', - }, - labels: {}, - }, - ], - } +export const DEFAULT_LOGGED_OUT_LABEL_PREFERENCES: typeof DEFAULT_LABEL_SETTINGS = + Object.fromEntries( + Object.entries(DEFAULT_LABEL_SETTINGS).map(([key, _pref]) => [key, 'hide']), + ) + +export function useMyLabelersQuery() { + const prefs = usePreferencesQuery() + const dids = Array.from( + new Set( + BskyAgent.appLabelers.concat( + prefs.data?.moderationPrefs.labelers.map(l => l.did) || [], + ), + ), + ) + const labelers = useLabelersDetailedInfoQuery({dids}) + const isLoading = prefs.isLoading || labelers.isLoading + const error = prefs.error || labelers.error + return React.useMemo(() => { + return { + isLoading, + error, + data: labelers.data, + } + }, [labelers, isLoading, error]) +} + +export function useLabelDefinitionsQuery() { + const labelers = useMyLabelersQuery() + return React.useMemo(() => { + return { + labelDefs: Object.fromEntries( + (labelers.data || []).map(labeler => [ + labeler.creator.did, + interpretLabelValueDefinitions(labeler), + ]), + ), + labelers: labelers.data || [], + } + }, [labelers]) } diff --git a/src/state/queries/preferences/types.ts b/src/state/queries/preferences/types.ts index 45c9eed7d..96da16f1a 100644 --- a/src/state/queries/preferences/types.ts +++ b/src/state/queries/preferences/types.ts @@ -1,46 +1,13 @@ import { BskyPreferences, - LabelPreference, BskyThreadViewPreference, BskyFeedViewPreference, } from '@atproto/api' -export const configurableAdultLabelGroups = [ - 'nsfw', - 'nudity', - 'suggestive', - 'gore', -] as const - -export const configurableOtherLabelGroups = [ - 'hate', - 'spam', - 'impersonation', -] as const - -export const configurableLabelGroups = [ - ...configurableAdultLabelGroups, - ...configurableOtherLabelGroups, -] as const -export type ConfigurableLabelGroup = (typeof configurableLabelGroups)[number] - -export type LabelGroup = - | ConfigurableLabelGroup - | 'illegal' - | 'always-filter' - | 'always-warn' - | 'unknown' - export type UsePreferencesQueryResponse = Omit< BskyPreferences, 'contentLabels' | 'feedViewPrefs' | 'feeds' > & { - /* - * Content labels previously included 'show', which has been deprecated in - * favor of 'ignore'. The API can return legacy data from the database, and - * we clean up the data in `usePreferencesQuery`. - */ - contentLabels: Record<ConfigurableLabelGroup, LabelPreference> feedViewPrefs: BskyFeedViewPreference & { lab_mergeFeedEnabled?: boolean } diff --git a/src/state/queries/preferences/util.ts b/src/state/queries/preferences/util.ts deleted file mode 100644 index 7b8160c28..000000000 --- a/src/state/queries/preferences/util.ts +++ /dev/null @@ -1,16 +0,0 @@ -import {LabelPreference} from '@atproto/api' - -/** - * Content labels previously included 'show', which has been deprecated in - * favor of 'ignore'. The API can return legacy data from the database, and - * we clean up the data in `usePreferencesQuery`. - * - * @deprecated - */ -export function temp__migrateLabelPref( - pref: LabelPreference | 'show', -): LabelPreference { - // @ts-ignore - if (pref === 'show') return 'ignore' - return pref -} diff --git a/src/state/queries/profile-extra-info.ts b/src/state/queries/profile-extra-info.ts deleted file mode 100644 index 8fc32c33e..000000000 --- a/src/state/queries/profile-extra-info.ts +++ /dev/null @@ -1,34 +0,0 @@ -import {useQuery} from '@tanstack/react-query' - -import {getAgent} from '#/state/session' -import {STALE} from '#/state/queries' - -// TODO refactor invalidate on mutate? -export const RQKEY = (did: string) => ['profile-extra-info', did] - -/** - * Fetches some additional information for the profile screen which - * is not available in the API's ProfileView - */ -export function useProfileExtraInfoQuery(did: string) { - return useQuery({ - staleTime: STALE.MINUTES.ONE, - queryKey: RQKEY(did), - async queryFn() { - const [listsRes, feedsRes] = await Promise.all([ - getAgent().app.bsky.graph.getLists({ - actor: did, - limit: 1, - }), - getAgent().app.bsky.feed.getActorFeeds({ - actor: did, - limit: 1, - }), - ]) - return { - hasLists: listsRes.data.lists.length > 0, - hasFeedgens: feedsRes.data.feeds.length > 0, - } - }, - }) -} diff --git a/src/state/queries/profile.ts b/src/state/queries/profile.ts index e81ea0f3f..19492cf66 100644 --- a/src/state/queries/profile.ts +++ b/src/state/queries/profile.ts @@ -1,31 +1,33 @@ import {useCallback} from 'react' +import {Image as RNImage} from 'react-native-image-crop-picker' import { - AtUri, AppBskyActorDefs, - AppBskyActorProfile, AppBskyActorGetProfile, - AppBskyFeedDefs, + AppBskyActorProfile, AppBskyEmbedRecord, AppBskyEmbedRecordWithMedia, + AppBskyFeedDefs, + AtUri, } from '@atproto/api' import { + QueryClient, + useMutation, useQuery, useQueryClient, - useMutation, - QueryClient, } from '@tanstack/react-query' -import {Image as RNImage} from 'react-native-image-crop-picker' -import {useSession, getAgent} from '../session' -import {updateProfileShadow} from '../cache/profile-shadow' + +import {track} from '#/lib/analytics/analytics' import {uploadBlob} from '#/lib/api' import {until} from '#/lib/async/until' +import {useToggleMutationQueue} from '#/lib/hooks/useToggleMutationQueue' +import {logEvent, LogEvents} from '#/lib/statsig/statsig' import {Shadow} from '#/state/cache/types' +import {STALE} from '#/state/queries' import {resetProfilePostsQueries} from '#/state/queries/post-feed' -import {useToggleMutationQueue} from '#/lib/hooks/useToggleMutationQueue' -import {RQKEY as RQKEY_MY_MUTED} from './my-muted-accounts' +import {updateProfileShadow} from '../cache/profile-shadow' +import {getAgent, useSession} from '../session' import {RQKEY as RQKEY_MY_BLOCKED} from './my-blocked-accounts' -import {STALE} from '#/state/queries' -import {track} from '#/lib/analytics/analytics' +import {RQKEY as RQKEY_MY_MUTED} from './my-muted-accounts' import {ThreadNode} from './post-thread' export const RQKEY = (did: string) => ['profile', did] @@ -186,11 +188,14 @@ export function useProfileUpdateMutation() { export function useProfileFollowMutationQueue( profile: Shadow<AppBskyActorDefs.ProfileViewDetailed>, + logContext: LogEvents['profile:follow']['logContext'] & + LogEvents['profile:unfollow']['logContext'], ) { + const queryClient = useQueryClient() const did = profile.did const initialFollowingUri = profile.viewer?.following - const followMutation = useProfileFollowMutation() - const unfollowMutation = useProfileUnfollowMutation() + const followMutation = useProfileFollowMutation(logContext) + const unfollowMutation = useProfileUnfollowMutation(logContext) const queueToggle = useToggleMutationQueue({ initialState: initialFollowingUri, @@ -212,7 +217,7 @@ export function useProfileFollowMutationQueue( }, onSuccess(finalFollowingUri) { // finalize - updateProfileShadow(did, { + updateProfileShadow(queryClient, did, { followingUri: finalFollowingUri, }) }, @@ -220,26 +225,29 @@ export function useProfileFollowMutationQueue( const queueFollow = useCallback(() => { // optimistically update - updateProfileShadow(did, { + updateProfileShadow(queryClient, did, { followingUri: 'pending', }) return queueToggle(true) - }, [did, queueToggle]) + }, [queryClient, did, queueToggle]) const queueUnfollow = useCallback(() => { // optimistically update - updateProfileShadow(did, { + updateProfileShadow(queryClient, did, { followingUri: undefined, }) return queueToggle(false) - }, [did, queueToggle]) + }, [queryClient, did, queueToggle]) return [queueFollow, queueUnfollow] } -function useProfileFollowMutation() { +function useProfileFollowMutation( + logContext: LogEvents['profile:follow']['logContext'], +) { return useMutation<{uri: string; cid: string}, Error, {did: string}>({ mutationFn: async ({did}) => { + logEvent('profile:follow', {logContext}) return await getAgent().follow(did) }, onSuccess(data, variables) { @@ -248,9 +256,12 @@ function useProfileFollowMutation() { }) } -function useProfileUnfollowMutation() { +function useProfileUnfollowMutation( + logContext: LogEvents['profile:unfollow']['logContext'], +) { return useMutation<void, Error, {did: string; followUri: string}>({ mutationFn: async ({followUri}) => { + logEvent('profile:unfollow', {logContext}) track('Profile:Unfollow', {username: followUri}) return await getAgent().deleteFollow(followUri) }, @@ -260,6 +271,7 @@ function useProfileUnfollowMutation() { export function useProfileMuteMutationQueue( profile: Shadow<AppBskyActorDefs.ProfileViewDetailed>, ) { + const queryClient = useQueryClient() const did = profile.did const initialMuted = profile.viewer?.muted const muteMutation = useProfileMuteMutation() @@ -282,25 +294,25 @@ export function useProfileMuteMutationQueue( }, onSuccess(finalMuted) { // finalize - updateProfileShadow(did, {muted: finalMuted}) + updateProfileShadow(queryClient, did, {muted: finalMuted}) }, }) const queueMute = useCallback(() => { // optimistically update - updateProfileShadow(did, { + updateProfileShadow(queryClient, did, { muted: true, }) return queueToggle(true) - }, [did, queueToggle]) + }, [queryClient, did, queueToggle]) const queueUnmute = useCallback(() => { // optimistically update - updateProfileShadow(did, { + updateProfileShadow(queryClient, did, { muted: false, }) return queueToggle(false) - }, [did, queueToggle]) + }, [queryClient, did, queueToggle]) return [queueMute, queueUnmute] } @@ -332,6 +344,7 @@ function useProfileUnmuteMutation() { export function useProfileBlockMutationQueue( profile: Shadow<AppBskyActorDefs.ProfileViewDetailed>, ) { + const queryClient = useQueryClient() const did = profile.did const initialBlockingUri = profile.viewer?.blocking const blockMutation = useProfileBlockMutation() @@ -357,7 +370,7 @@ export function useProfileBlockMutationQueue( }, onSuccess(finalBlockingUri) { // finalize - updateProfileShadow(did, { + updateProfileShadow(queryClient, did, { blockingUri: finalBlockingUri, }) }, @@ -365,19 +378,19 @@ export function useProfileBlockMutationQueue( const queueBlock = useCallback(() => { // optimistically update - updateProfileShadow(did, { + updateProfileShadow(queryClient, did, { blockingUri: 'pending', }) return queueToggle(true) - }, [did, queueToggle]) + }, [queryClient, did, queueToggle]) const queueUnblock = useCallback(() => { // optimistically update - updateProfileShadow(did, { + updateProfileShadow(queryClient, did, { blockingUri: undefined, }) return queueToggle(false) - }, [did, queueToggle]) + }, [queryClient, did, queueToggle]) return [queueBlock, queueUnblock] } @@ -397,13 +410,14 @@ function useProfileBlockMutation() { }, onSuccess(_, {did}) { queryClient.invalidateQueries({queryKey: RQKEY_MY_BLOCKED()}) - resetProfilePostsQueries(did, 1000) + resetProfilePostsQueries(queryClient, did, 1000) }, }) } function useProfileUnblockMutation() { const {currentAccount} = useSession() + const queryClient = useQueryClient() return useMutation<void, Error, {did: string; blockUri: string}>({ mutationFn: async ({blockUri}) => { if (!currentAccount) { @@ -416,7 +430,7 @@ function useProfileUnblockMutation() { }) }, onSuccess(_, {did}) { - resetProfilePostsQueries(did, 1000) + resetProfilePostsQueries(queryClient, did, 1000) }, }) } diff --git a/src/state/queries/suggested-follows.ts b/src/state/queries/suggested-follows.ts index 932226b75..45b3ebb62 100644 --- a/src/state/queries/suggested-follows.ts +++ b/src/state/queries/suggested-follows.ts @@ -46,7 +46,8 @@ export function useSuggestedFollowsQuery() { res.data.actors = res.data.actors .filter( - actor => !moderateProfile(actor, moderationOpts!).account.filter, + actor => + !moderateProfile(actor, moderationOpts!).ui('profileList').filter, ) .filter(actor => { const viewer = actor.viewer diff --git a/src/state/queries/util.ts b/src/state/queries/util.ts index f3a87ae5d..54752b332 100644 --- a/src/state/queries/util.ts +++ b/src/state/queries/util.ts @@ -53,5 +53,6 @@ export function embedViewRecordToPostView( record: v.value, indexedAt: v.indexedAt, labels: v.labels, + embed: v.embeds?.[0], } } diff --git a/src/state/session/agent-config.ts b/src/state/session/agent-config.ts new file mode 100644 index 000000000..3ee2718a3 --- /dev/null +++ b/src/state/session/agent-config.ts @@ -0,0 +1,12 @@ +import AsyncStorage from '@react-native-async-storage/async-storage' + +const PREFIX = 'agent-labelers' + +export async function saveLabelers(did: string, value: string[]) { + await AsyncStorage.setItem(`${PREFIX}:${did}`, JSON.stringify(value)) +} + +export async function readLabelers(did: string): Promise<string[] | undefined> { + const rawData = await AsyncStorage.getItem(`${PREFIX}:${did}`) + return rawData ? JSON.parse(rawData) : undefined +} diff --git a/src/state/session/index.tsx b/src/state/session/index.tsx index b555a997b..c7dba3089 100644 --- a/src/state/session/index.tsx +++ b/src/state/session/index.tsx @@ -1,18 +1,26 @@ import React from 'react' -import {BskyAgent, AtpPersistSessionHandler} from '@atproto/api' +import { + AtpPersistSessionHandler, + BSKY_LABELER_DID, + BskyAgent, +} from '@atproto/api' import {useQueryClient} from '@tanstack/react-query' import {jwtDecode} from 'jwt-decode' +import {track} from '#/lib/analytics/analytics' import {networkRetry} from '#/lib/async/retry' +import {IS_TEST_USER} from '#/lib/constants' +import {logEvent, LogEvents} from '#/lib/statsig/statsig' +import {hasProp} from '#/lib/type-guards' import {logger} from '#/logger' +import {isWeb} from '#/platform/detection' import * as persisted from '#/state/persisted' import {PUBLIC_BSKY_AGENT} from '#/state/queries' -import {IS_PROD} from '#/lib/constants' -import {emitSessionDropped} from '../events' import {useLoggedOutViewControls} from '#/state/shell/logged-out' import {useCloseAllActiveElements} from '#/state/util' -import {track} from '#/lib/analytics/analytics' -import {hasProp} from '#/lib/type-guards' +import {IS_DEV} from '#/env' +import {emitSessionDropped} from '../events' +import {readLabelers} from './agent-config' let __globalAgent: BskyAgent = PUBLIC_BSKY_AGENT @@ -36,7 +44,6 @@ export type SessionState = { } export type StateContext = SessionState & { hasSession: boolean - isSandbox: boolean } export type ApiContext = { createAccount: (props: { @@ -48,17 +55,22 @@ export type ApiContext = { verificationPhone?: string verificationCode?: string }) => Promise<void> - login: (props: { - service: string - identifier: string - password: string - }) => Promise<void> + login: ( + props: { + service: string + identifier: string + password: string + }, + logContext: LogEvents['account:loggedIn']['logContext'], + ) => Promise<void> /** * A full logout. Clears the `currentAccount` from session, AND removes * access tokens from all accounts, so that returning as any user will * require a full login. */ - logout: () => Promise<void> + logout: ( + logContext: LogEvents['account:loggedOut']['logContext'], + ) => Promise<void> /** * A partial logout. Clears the `currentAccount` from session, but DOES NOT * clear access tokens from accounts, allowing the user to return to their @@ -70,7 +82,10 @@ export type ApiContext = { initSession: (account: SessionAccount) => Promise<void> resumeSession: (account?: SessionAccount) => Promise<void> removeAccount: (account: SessionAccount) => void - selectAccount: (account: SessionAccount) => Promise<void> + selectAccount: ( + account: SessionAccount, + logContext: LogEvents['account:loggedIn']['logContext'], + ) => Promise<void> updateCurrentAccount: ( account: Partial< Pick<SessionAccount, 'handle' | 'email' | 'emailConfirmed'> @@ -84,7 +99,6 @@ const StateContext = React.createContext<StateContext>({ accounts: [], currentAccount: undefined, hasSession: false, - isSandbox: false, }) const ApiContext = React.createContext<ApiContext>({ @@ -136,7 +150,7 @@ function createPersistSessionHandler( accessJwt: session?.accessJwt, } - logger.info(`session: persistSession`, { + logger.debug(`session: persistSession`, { event, deactivated: refreshedAccount.deactivated, }) @@ -216,6 +230,7 @@ export function Provider({children}: React.PropsWithChildren<{}>) { }: any) => { logger.info(`session: creating account`) track('Try Create Account') + logEvent('account:create:begin', {}) const agent = new BskyAgent({service}) @@ -258,6 +273,8 @@ export function Provider({children}: React.PropsWithChildren<{}>) { deactivated, } + await configureModeration(agent, account) + agent.setPersistSessionHandler( createPersistSessionHandler( account, @@ -274,12 +291,13 @@ export function Provider({children}: React.PropsWithChildren<{}>) { logger.debug(`session: created account`, {}, logger.DebugContext.session) track('Create Account') + logEvent('account:create:success', {}) }, [upsertAccount, queryClient, clearCurrentAccount], ) const login = React.useCallback<ApiContext['login']>( - async ({service, identifier, password}) => { + async ({service, identifier, password}, logContext) => { logger.debug(`session: login`, {}, logger.DebugContext.session) const agent = new BskyAgent({service}) @@ -301,6 +319,8 @@ export function Provider({children}: React.PropsWithChildren<{}>) { deactivated: isSessionDeactivated(agent.session.accessJwt), } + await configureModeration(agent, account) + agent.setPersistSessionHandler( createPersistSessionHandler( account, @@ -312,30 +332,37 @@ export function Provider({children}: React.PropsWithChildren<{}>) { ) __globalAgent = agent + // @ts-ignore + if (IS_DEV && isWeb) window.agent = agent queryClient.clear() upsertAccount(account) logger.debug(`session: logged in`, {}, logger.DebugContext.session) track('Sign In', {resumedSession: false}) + logEvent('account:loggedIn', {logContext, withPassword: true}) }, [upsertAccount, queryClient, clearCurrentAccount], ) - const logout = React.useCallback<ApiContext['logout']>(async () => { - logger.info(`session: logout`) - clearCurrentAccount() - setStateAndPersist(s => { - return { - ...s, - accounts: s.accounts.map(a => ({ - ...a, - refreshJwt: undefined, - accessJwt: undefined, - })), - } - }) - }, [clearCurrentAccount, setStateAndPersist]) + const logout = React.useCallback<ApiContext['logout']>( + async logContext => { + logger.debug(`session: logout`) + clearCurrentAccount() + setStateAndPersist(s => { + return { + ...s, + accounts: s.accounts.map(a => ({ + ...a, + refreshJwt: undefined, + accessJwt: undefined, + })), + } + }) + logEvent('account:loggedOut', {logContext}) + }, + [clearCurrentAccount, setStateAndPersist], + ) const initSession = React.useCallback<ApiContext['initSession']>( async account => { @@ -351,6 +378,9 @@ export function Provider({children}: React.PropsWithChildren<{}>) { {networkErrorCallback: clearCurrentAccount}, ), }) + // @ts-ignore + if (IS_DEV && isWeb) window.agent = agent + await configureModeration(agent, account) let canReusePrevSession = false try { @@ -377,7 +407,7 @@ export function Provider({children}: React.PropsWithChildren<{}>) { } if (canReusePrevSession) { - logger.info(`session: attempting to reuse previous session`) + logger.debug(`session: attempting to reuse previous session`) agent.session = prevSession __globalAgent = agent @@ -387,7 +417,7 @@ export function Provider({children}: React.PropsWithChildren<{}>) { if (prevSession.deactivated) { // don't attempt to resume // use will be taken to the deactivated screen - logger.info(`session: reusing session for deactivated account`) + logger.debug(`session: reusing session for deactivated account`) return } @@ -413,7 +443,7 @@ export function Provider({children}: React.PropsWithChildren<{}>) { __globalAgent = PUBLIC_BSKY_AGENT }) } else { - logger.info(`session: attempting to resume using previous session`) + logger.debug(`session: attempting to resume using previous session`) try { const freshAccount = await resumeSessionWithFreshAccount() @@ -434,7 +464,7 @@ export function Provider({children}: React.PropsWithChildren<{}>) { } async function resumeSessionWithFreshAccount(): Promise<SessionAccount> { - logger.info(`session: resumeSessionWithFreshAccount`) + logger.debug(`session: resumeSessionWithFreshAccount`) await networkRetry(1, () => agent.resumeSession(prevSession)) @@ -526,11 +556,12 @@ export function Provider({children}: React.PropsWithChildren<{}>) { ) const selectAccount = React.useCallback<ApiContext['selectAccount']>( - async account => { + async (account, logContext) => { setState(s => ({...s, isSwitchingAccounts: true})) try { await initSession(account) setState(s => ({...s, isSwitchingAccounts: false})) + logEvent('account:loggedIn', {logContext, withPassword: false}) } catch (e) { // reset this in case of error setState(s => ({...s, isSwitchingAccounts: false})) @@ -555,11 +586,11 @@ export function Provider({children}: React.PropsWithChildren<{}>) { return persisted.onUpdate(() => { const session = persisted.get('session') - logger.info(`session: persisted onUpdate`, {}) + logger.debug(`session: persisted onUpdate`, {}) if (session.currentAccount && session.currentAccount.refreshJwt) { if (session.currentAccount?.did !== state.currentAccount?.did) { - logger.info(`session: persisted onUpdate, switching accounts`, { + logger.debug(`session: persisted onUpdate, switching accounts`, { from: { did: state.currentAccount?.did, handle: state.currentAccount?.handle, @@ -572,7 +603,7 @@ export function Provider({children}: React.PropsWithChildren<{}>) { initSession(session.currentAccount) } else { - logger.info(`session: persisted onUpdate, updating session`, {}) + logger.debug(`session: persisted onUpdate, updating session`, {}) /* * Use updated session in this tab's agent. Do not call @@ -610,9 +641,6 @@ export function Provider({children}: React.PropsWithChildren<{}>) { () => ({ ...state, hasSession: !!state.currentAccount, - isSandbox: state.currentAccount - ? !IS_PROD(state.currentAccount?.service) - : false, }), [state], ) @@ -649,6 +677,28 @@ export function Provider({children}: React.PropsWithChildren<{}>) { ) } +async function configureModeration(agent: BskyAgent, account: SessionAccount) { + if (IS_TEST_USER(account.handle)) { + const did = ( + await agent + .resolveHandle({handle: 'mod-authority.test'}) + .catch(_ => undefined) + )?.data.did + if (did) { + console.warn('USING TEST ENV MODERATION') + BskyAgent.configure({appLabelers: [did]}) + } + } else { + BskyAgent.configure({appLabelers: [BSKY_LABELER_DID]}) + const labelerDids = await readLabelers(account.did).catch(_ => {}) + if (labelerDids) { + agent.configureLabelersHeader( + labelerDids.filter(did => did !== BSKY_LABELER_DID), + ) + } + } +} + export function useSession() { return React.useContext(StateContext) } diff --git a/src/state/shell/composer.tsx b/src/state/shell/composer.tsx index 696a3c5ba..5b4e50543 100644 --- a/src/state/shell/composer.tsx +++ b/src/state/shell/composer.tsx @@ -2,7 +2,8 @@ import React from 'react' import { AppBskyEmbedRecord, AppBskyRichtextFacet, - PostModeration, + ModerationDecision, + AppBskyActorDefs, } from '@atproto/api' import {useNonReactiveCallback} from '#/lib/hooks/useNonReactiveCallback' @@ -10,13 +11,9 @@ export interface ComposerOptsPostRef { uri: string cid: string text: string - author: { - handle: string - displayName?: string - avatar?: string - } + author: AppBskyActorDefs.ProfileViewBasic embed?: AppBskyEmbedRecord.ViewRecord['embed'] - moderation?: PostModeration + moderation?: ModerationDecision } export interface ComposerOptsQuote { uri: string @@ -38,6 +35,8 @@ export interface ComposerOpts { quote?: ComposerOptsQuote mention?: string // handle of user to mention openPicker?: (pos: DOMRect | undefined) => void + text?: string + imageUris?: {uri: string; width: number; height: number}[] } type StateContext = ComposerOpts | undefined diff --git a/src/state/util.ts b/src/state/util.ts index 57f4331b0..f65d14a84 100644 --- a/src/state/util.ts +++ b/src/state/util.ts @@ -3,6 +3,7 @@ import {useLightboxControls} from './lightbox' import {useModalControls} from './modals' import {useComposerControls} from './shell/composer' import {useSetDrawerOpen} from './shell/drawer-open' +import {useDialogStateControlContext} from '#/state/dialogs' /** * returns true if something was closed @@ -12,6 +13,7 @@ export function useCloseAnyActiveElement() { const {closeLightbox} = useLightboxControls() const {closeModal} = useModalControls() const {closeComposer} = useComposerControls() + const {closeAllDialogs} = useDialogStateControlContext() const setDrawerOpen = useSetDrawerOpen() return useCallback(() => { if (closeLightbox()) { @@ -23,9 +25,12 @@ export function useCloseAnyActiveElement() { if (closeComposer()) { return true } + if (closeAllDialogs()) { + return true + } setDrawerOpen(false) return false - }, [closeLightbox, closeModal, closeComposer, setDrawerOpen]) + }, [closeLightbox, closeModal, closeComposer, setDrawerOpen, closeAllDialogs]) } /** @@ -35,11 +40,19 @@ export function useCloseAllActiveElements() { const {closeLightbox} = useLightboxControls() const {closeAllModals} = useModalControls() const {closeComposer} = useComposerControls() + const {closeAllDialogs: closeAlfDialogs} = useDialogStateControlContext() const setDrawerOpen = useSetDrawerOpen() return useCallback(() => { closeLightbox() closeAllModals() closeComposer() + closeAlfDialogs() setDrawerOpen(false) - }, [closeLightbox, closeAllModals, closeComposer, setDrawerOpen]) + }, [ + closeLightbox, + closeAllModals, + closeComposer, + closeAlfDialogs, + setDrawerOpen, + ]) } |