diff options
Diffstat (limited to 'src/state/queries')
-rw-r--r-- | src/state/queries/actor-autocomplete.ts | 27 | ||||
-rw-r--r-- | src/state/queries/feed.ts | 129 | ||||
-rw-r--r-- | src/state/queries/labeler.ts | 89 | ||||
-rw-r--r-- | src/state/queries/notifications/feed.ts | 17 | ||||
-rw-r--r-- | src/state/queries/notifications/util.ts | 30 | ||||
-rw-r--r-- | src/state/queries/post-feed.ts | 138 | ||||
-rw-r--r-- | src/state/queries/post-liked-by.ts | 4 | ||||
-rw-r--r-- | src/state/queries/post-thread.ts | 66 | ||||
-rw-r--r-- | src/state/queries/post.ts | 81 | ||||
-rw-r--r-- | src/state/queries/preferences/const.ts | 18 | ||||
-rw-r--r-- | src/state/queries/preferences/index.ts | 162 | ||||
-rw-r--r-- | src/state/queries/preferences/moderation.ts | 218 | ||||
-rw-r--r-- | src/state/queries/preferences/types.ts | 33 | ||||
-rw-r--r-- | src/state/queries/preferences/util.ts | 16 | ||||
-rw-r--r-- | src/state/queries/profile-extra-info.ts | 34 | ||||
-rw-r--r-- | src/state/queries/profile.ts | 80 | ||||
-rw-r--r-- | src/state/queries/suggested-follows.ts | 3 | ||||
-rw-r--r-- | src/state/queries/util.ts | 1 |
18 files changed, 544 insertions, 602 deletions
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], } } |