diff options
Diffstat (limited to 'src/state/queries')
-rw-r--r-- | src/state/queries/feed.ts | 23 | ||||
-rw-r--r-- | src/state/queries/list.ts | 40 | ||||
-rw-r--r-- | src/state/queries/my-blocked-accounts.ts | 2 | ||||
-rw-r--r-- | src/state/queries/my-muted-accounts.ts | 2 | ||||
-rw-r--r-- | src/state/queries/notifications/feed.ts | 217 | ||||
-rw-r--r-- | src/state/queries/notifications/types.ts | 34 | ||||
-rw-r--r-- | src/state/queries/notifications/unread.tsx | 131 | ||||
-rw-r--r-- | src/state/queries/notifications/util.ts | 183 | ||||
-rw-r--r-- | src/state/queries/post-feed.ts | 261 | ||||
-rw-r--r-- | src/state/queries/post-liked-by.ts | 2 | ||||
-rw-r--r-- | src/state/queries/post-reposted-by.ts | 2 | ||||
-rw-r--r-- | src/state/queries/post-thread.ts | 19 | ||||
-rw-r--r-- | src/state/queries/post.ts | 3 | ||||
-rw-r--r-- | src/state/queries/profile-feedgens.ts | 2 | ||||
-rw-r--r-- | src/state/queries/profile-followers.ts | 2 | ||||
-rw-r--r-- | src/state/queries/profile-lists.ts | 3 | ||||
-rw-r--r-- | src/state/queries/profile.ts | 10 | ||||
-rw-r--r-- | src/state/queries/resolve-uri.ts | 2 | ||||
-rw-r--r-- | src/state/queries/service.ts | 3 | ||||
-rw-r--r-- | src/state/queries/suggested-follows.ts | 2 |
20 files changed, 547 insertions, 396 deletions
diff --git a/src/state/queries/feed.ts b/src/state/queries/feed.ts index 58c1261db..a73e64e8a 100644 --- a/src/state/queries/feed.ts +++ b/src/state/queries/feed.ts @@ -181,6 +181,9 @@ export function useIsFeedPublicQuery({uri}: {uri: string}) { if (msg.includes('missing jwt')) { return false + } else if (msg.includes('This feed requires being logged-in')) { + // e.g. https://github.com/bluesky-social/atproto/blob/99ab1ae55c463e8d5321a1eaad07a175bdd56fea/packages/bsky/src/feed-gen/best-of-follows.ts#L13 + return false } return true @@ -243,13 +246,19 @@ const FOLLOWING_FEED_STUB: FeedSourceInfo = { likeUri: '', } -export function usePinnedFeedsInfos(): FeedSourceInfo[] { +export function usePinnedFeedsInfos(): { + feeds: FeedSourceInfo[] + hasPinnedCustom: boolean +} { const queryClient = useQueryClient() const [tabs, setTabs] = React.useState<FeedSourceInfo[]>([ FOLLOWING_FEED_STUB, ]) const {data: preferences} = usePreferencesQuery() - const pinnedFeedsKey = JSON.stringify(preferences?.feeds?.pinned) + + const hasPinnedCustom = React.useMemo<boolean>(() => { + return tabs.some(tab => tab !== FOLLOWING_FEED_STUB) + }, [tabs]) React.useEffect(() => { if (!preferences?.feeds?.pinned) return @@ -296,13 +305,7 @@ export function usePinnedFeedsInfos(): FeedSourceInfo[] { } fetchFeedInfo() - }, [ - queryClient, - setTabs, - preferences?.feeds?.pinned, - // ensure we react to re-ordering - pinnedFeedsKey, - ]) + }, [queryClient, setTabs, preferences?.feeds?.pinned]) - return tabs + return {feeds: tabs, hasPinnedCustom} } diff --git a/src/state/queries/list.ts b/src/state/queries/list.ts index ef05009d1..550baecb3 100644 --- a/src/state/queries/list.ts +++ b/src/state/queries/list.ts @@ -3,7 +3,6 @@ import { AppBskyGraphGetList, AppBskyGraphList, AppBskyGraphDefs, - BskyAgent, } from '@atproto/api' import {Image as RNImage} from 'react-native-image-crop-picker' import {useQuery, useMutation, useQueryClient} from '@tanstack/react-query' @@ -75,13 +74,9 @@ export function useListCreateMutation() { ) // wait for the appview to update - await whenAppViewReady( - getAgent(), - res.uri, - (v: AppBskyGraphGetList.Response) => { - return typeof v?.data?.list.uri === 'string' - }, - ) + await whenAppViewReady(res.uri, (v: AppBskyGraphGetList.Response) => { + return typeof v?.data?.list.uri === 'string' + }) return res }, onSuccess() { @@ -142,16 +137,12 @@ export function useListMetadataMutation() { ).data // wait for the appview to update - await whenAppViewReady( - getAgent(), - res.uri, - (v: AppBskyGraphGetList.Response) => { - const list = v.data.list - return ( - list.name === record.name && list.description === record.description - ) - }, - ) + await whenAppViewReady(res.uri, (v: AppBskyGraphGetList.Response) => { + const list = v.data.list + return ( + list.name === record.name && list.description === record.description + ) + }) return res }, onSuccess(data, variables) { @@ -216,13 +207,9 @@ export function useListDeleteMutation() { } // wait for the appview to update - await whenAppViewReady( - getAgent(), - uri, - (v: AppBskyGraphGetList.Response) => { - return !v?.success - }, - ) + await whenAppViewReady(uri, (v: AppBskyGraphGetList.Response) => { + return !v?.success + }) }, onSuccess() { invalidateMyLists(queryClient) @@ -271,7 +258,6 @@ export function useListBlockMutation() { } async function whenAppViewReady( - agent: BskyAgent, uri: string, fn: (res: AppBskyGraphGetList.Response) => boolean, ) { @@ -280,7 +266,7 @@ async function whenAppViewReady( 1e3, // 1s delay between tries fn, () => - agent.app.bsky.graph.getList({ + getAgent().app.bsky.graph.getList({ list: uri, limit: 1, }), diff --git a/src/state/queries/my-blocked-accounts.ts b/src/state/queries/my-blocked-accounts.ts index 4d5bd7a0e..2c099c63d 100644 --- a/src/state/queries/my-blocked-accounts.ts +++ b/src/state/queries/my-blocked-accounts.ts @@ -2,7 +2,6 @@ import {AppBskyGraphGetBlocks} from '@atproto/api' import {useInfiniteQuery, InfiniteData, QueryKey} from '@tanstack/react-query' import {getAgent} from '#/state/session' -import {STALE} from '#/state/queries' export const RQKEY = () => ['my-blocked-accounts'] type RQPageParam = string | undefined @@ -15,7 +14,6 @@ export function useMyBlockedAccountsQuery() { QueryKey, RQPageParam >({ - staleTime: STALE.MINUTES.ONE, queryKey: RQKEY(), async queryFn({pageParam}: {pageParam: RQPageParam}) { const res = await getAgent().app.bsky.graph.getBlocks({ diff --git a/src/state/queries/my-muted-accounts.ts b/src/state/queries/my-muted-accounts.ts index 1d686637a..a175931b5 100644 --- a/src/state/queries/my-muted-accounts.ts +++ b/src/state/queries/my-muted-accounts.ts @@ -2,7 +2,6 @@ import {AppBskyGraphGetMutes} from '@atproto/api' import {useInfiniteQuery, InfiniteData, QueryKey} from '@tanstack/react-query' import {getAgent} from '#/state/session' -import {STALE} from '#/state/queries' export const RQKEY = () => ['my-muted-accounts'] type RQPageParam = string | undefined @@ -15,7 +14,6 @@ export function useMyMutedAccountsQuery() { QueryKey, RQPageParam >({ - staleTime: STALE.MINUTES.ONE, queryKey: RQKEY(), async queryFn({pageParam}: {pageParam: RQPageParam}) { const res = await getAgent().app.bsky.graph.getMutes({ diff --git a/src/state/queries/notifications/feed.ts b/src/state/queries/notifications/feed.ts index 54bd87540..5c519d045 100644 --- a/src/state/queries/notifications/feed.ts +++ b/src/state/queries/notifications/feed.ts @@ -1,12 +1,22 @@ -import { - AppBskyFeedDefs, - AppBskyFeedPost, - AppBskyFeedRepost, - AppBskyFeedLike, - AppBskyNotificationListNotifications, - BskyAgent, -} from '@atproto/api' -import chunk from 'lodash.chunk' +/** + * NOTE + * The ./unread.ts API: + * + * - Provides a `checkUnread()` function to sync with the server, + * - Periodically calls `checkUnread()`, and + * - Caches the first page of notifications. + * + * IMPORTANT: This query uses ./unread.ts's cache as its first page, + * IMPORTANT: which means the cache-freshness of this query is driven by the unread API. + * + * Follow these rules: + * + * 1. Call `checkUnread()` if you want to fetch latest in the background. + * 2. Call `checkUnread({invalidate: true})` if you want latest to sync into this query's results immediately. + * 3. Don't call this query's `refetch()` if you're trying to sync latest; call `checkUnread()` instead. + */ + +import {AppBskyFeedDefs} from '@atproto/api' import { useInfiniteQuery, InfiniteData, @@ -14,50 +24,27 @@ import { useQueryClient, QueryClient, } from '@tanstack/react-query' -import {getAgent} from '../../session' import {useModerationOpts} from '../preferences' -import {shouldFilterNotif} from './util' +import {useUnreadNotificationsApi} from './unread' +import {fetchPage} from './util' +import {FeedPage} from './types' import {useMutedThreads} from '#/state/muted-threads' -import {precacheProfile as precacheResolvedUri} from '../resolve-uri' -const GROUPABLE_REASONS = ['like', 'repost', 'follow'] +export type {NotificationType, FeedNotification, FeedPage} from './types' + const PAGE_SIZE = 30 -const MS_1HR = 1e3 * 60 * 60 -const MS_2DAY = MS_1HR * 48 type RQPageParam = string | undefined -type NotificationType = - | 'post-like' - | 'feedgen-like' - | 'repost' - | 'mention' - | 'reply' - | 'quote' - | 'follow' - | 'unknown' export function RQKEY() { return ['notification-feed'] } -export interface FeedNotification { - _reactKey: string - type: NotificationType - notification: AppBskyNotificationListNotifications.Notification - additional?: AppBskyNotificationListNotifications.Notification[] - subjectUri?: string - subject?: AppBskyFeedDefs.PostView -} - -export interface FeedPage { - cursor: string | undefined - items: FeedNotification[] -} - export function useNotificationFeedQuery(opts?: {enabled?: boolean}) { const queryClient = useQueryClient() const moderationOpts = useModerationOpts() const threadMutes = useMutedThreads() + const unreads = useUnreadNotificationsApi() const enabled = opts?.enabled !== false return useInfiniteQuery< @@ -69,40 +56,21 @@ export function useNotificationFeedQuery(opts?: {enabled?: boolean}) { >({ queryKey: RQKEY(), async queryFn({pageParam}: {pageParam: RQPageParam}) { - const res = await getAgent().listNotifications({ + // for the first page, we check the cached page held by the unread-checker first + if (!pageParam) { + const cachedPage = unreads.getCachedUnreadPage() + if (cachedPage) { + return cachedPage + } + } + // do a normal fetch + return fetchPage({ limit: PAGE_SIZE, cursor: pageParam, + queryClient, + moderationOpts, + threadMutes, }) - - // filter out notifs by mod rules - const notifs = res.data.notifications.filter( - notif => !shouldFilterNotif(notif, moderationOpts), - ) - - // group notifications which are essentially similar (follows, likes on a post) - let notifsGrouped = groupNotifications(notifs) - - // we fetch subjects of notifications (usually posts) now instead of lazily - // in the UI to avoid relayouts - const subjects = await fetchSubjects(getAgent(), notifsGrouped) - for (const notif of notifsGrouped) { - if (notif.subjectUri) { - notif.subject = subjects.get(notif.subjectUri) - if (notif.subject) { - precacheResolvedUri(queryClient, notif.subject.author) // precache the handle->did resolution - } - } - } - - // apply thread muting - notifsGrouped = notifsGrouped.filter( - notif => !isThreadMuted(notif, threadMutes), - ) - - return { - cursor: res.data.cursor, - items: notifsGrouped, - } }, initialPageParam: undefined, getNextPageParam: lastPage => lastPage.cursor, @@ -135,114 +103,3 @@ export function findPostInQueryData( } return undefined } - -function groupNotifications( - notifs: AppBskyNotificationListNotifications.Notification[], -): FeedNotification[] { - const groupedNotifs: FeedNotification[] = [] - for (const notif of notifs) { - const ts = +new Date(notif.indexedAt) - let grouped = false - if (GROUPABLE_REASONS.includes(notif.reason)) { - for (const groupedNotif of groupedNotifs) { - const ts2 = +new Date(groupedNotif.notification.indexedAt) - if ( - Math.abs(ts2 - ts) < MS_2DAY && - notif.reason === groupedNotif.notification.reason && - notif.reasonSubject === groupedNotif.notification.reasonSubject && - notif.author.did !== groupedNotif.notification.author.did - ) { - groupedNotif.additional = groupedNotif.additional || [] - groupedNotif.additional.push(notif) - grouped = true - break - } - } - } - if (!grouped) { - const type = toKnownType(notif) - groupedNotifs.push({ - _reactKey: `notif-${notif.uri}`, - type, - notification: notif, - subjectUri: getSubjectUri(type, notif), - }) - } - } - return groupedNotifs -} - -async function fetchSubjects( - agent: BskyAgent, - groupedNotifs: FeedNotification[], -): Promise<Map<string, AppBskyFeedDefs.PostView>> { - const uris = new Set<string>() - for (const notif of groupedNotifs) { - if (notif.subjectUri) { - uris.add(notif.subjectUri) - } - } - const uriChunks = chunk(Array.from(uris), 25) - const postsChunks = await Promise.all( - uriChunks.map(uris => - agent.app.bsky.feed.getPosts({uris}).then(res => res.data.posts), - ), - ) - const map = new Map<string, AppBskyFeedDefs.PostView>() - for (const post of postsChunks.flat()) { - if ( - AppBskyFeedPost.isRecord(post.record) && - AppBskyFeedPost.validateRecord(post.record).success - ) { - map.set(post.uri, post) - } - } - return map -} - -function toKnownType( - notif: AppBskyNotificationListNotifications.Notification, -): NotificationType { - if (notif.reason === 'like') { - if (notif.reasonSubject?.includes('feed.generator')) { - return 'feedgen-like' - } - return 'post-like' - } - if ( - notif.reason === 'repost' || - notif.reason === 'mention' || - notif.reason === 'reply' || - notif.reason === 'quote' || - notif.reason === 'follow' - ) { - return notif.reason as NotificationType - } - return 'unknown' -} - -function getSubjectUri( - type: NotificationType, - notif: AppBskyNotificationListNotifications.Notification, -): string | undefined { - if (type === 'reply' || type === 'quote' || type === 'mention') { - return notif.uri - } else if (type === 'post-like' || type === 'repost') { - if ( - AppBskyFeedRepost.isRecord(notif.record) || - AppBskyFeedLike.isRecord(notif.record) - ) { - return typeof notif.record.subject?.uri === 'string' - ? notif.record.subject?.uri - : undefined - } - } -} - -function isThreadMuted(notif: FeedNotification, mutes: string[]): boolean { - if (!notif.subject) { - return false - } - const record = notif.subject.record as AppBskyFeedPost.Record // assured in fetchSubjects() - return mutes.includes(record.reply?.root.uri || notif.subject.uri) -} diff --git a/src/state/queries/notifications/types.ts b/src/state/queries/notifications/types.ts new file mode 100644 index 000000000..0e88f1071 --- /dev/null +++ b/src/state/queries/notifications/types.ts @@ -0,0 +1,34 @@ +import { + AppBskyNotificationListNotifications, + AppBskyFeedDefs, +} from '@atproto/api' + +export type NotificationType = + | 'post-like' + | 'feedgen-like' + | 'repost' + | 'mention' + | 'reply' + | 'quote' + | 'follow' + | 'unknown' + +export interface FeedNotification { + _reactKey: string + type: NotificationType + notification: AppBskyNotificationListNotifications.Notification + additional?: AppBskyNotificationListNotifications.Notification[] + subjectUri?: string + subject?: AppBskyFeedDefs.PostView +} + +export interface FeedPage { + cursor: string | undefined + items: FeedNotification[] +} + +export interface CachedFeedPage { + sessDid: string // used to invalidate on session changes + syncedAt: Date + data: FeedPage | undefined +} diff --git a/src/state/queries/notifications/unread.tsx b/src/state/queries/notifications/unread.tsx index 36bc6528f..e0510e79e 100644 --- a/src/state/queries/notifications/unread.tsx +++ b/src/state/queries/notifications/unread.tsx @@ -1,10 +1,19 @@ +/** + * A kind of companion API to ./feed.ts. See that file for more info. + */ + import React from 'react' import * as Notifications from 'expo-notifications' +import {useQueryClient} from '@tanstack/react-query' import BroadcastChannel from '#/lib/broadcast' import {useSession, getAgent} from '#/state/session' import {useModerationOpts} from '../preferences' -import {shouldFilterNotif} from './util' +import {fetchPage} from './util' +import {CachedFeedPage, FeedPage} from './types' import {isNative} from '#/platform/detection' +import {useMutedThreads} from '#/state/muted-threads' +import {RQKEY as RQKEY_NOTIFS} from './feed' +import {logger} from '#/logger' const UPDATE_INTERVAL = 30 * 1e3 // 30sec @@ -14,7 +23,8 @@ type StateContext = string interface ApiContext { markAllRead: () => Promise<void> - checkUnread: () => Promise<void> + checkUnread: (opts?: {invalidate?: boolean}) => Promise<void> + getCachedUnreadPage: () => FeedPage | undefined } const stateContext = React.createContext<StateContext>('') @@ -22,16 +32,23 @@ const stateContext = React.createContext<StateContext>('') const apiContext = React.createContext<ApiContext>({ async markAllRead() {}, async checkUnread() {}, + getCachedUnreadPage: () => undefined, }) export function Provider({children}: React.PropsWithChildren<{}>) { - const {hasSession} = useSession() + const {hasSession, currentAccount} = useSession() + const queryClient = useQueryClient() const moderationOpts = useModerationOpts() + const threadMutes = useMutedThreads() const [numUnread, setNumUnread] = React.useState('') - const checkUnreadRef = React.useRef<(() => Promise<void>) | null>(null) - const lastSyncRef = React.useRef<Date>(new Date()) + const checkUnreadRef = React.useRef<ApiContext['checkUnread'] | null>(null) + const cacheRef = React.useRef<CachedFeedPage>({ + sessDid: currentAccount?.did || '', + syncedAt: new Date(), + data: undefined, + }) // periodic sync React.useEffect(() => { @@ -46,14 +63,18 @@ export function Provider({children}: React.PropsWithChildren<{}>) { // listen for broadcasts React.useEffect(() => { const listener = ({data}: MessageEvent) => { - lastSyncRef.current = new Date() + cacheRef.current = { + sessDid: currentAccount?.did || '', + syncedAt: new Date(), + data: undefined, + } setNumUnread(data.event) } broadcast.addEventListener('message', listener) return () => { broadcast.removeEventListener('message', listener) } - }, [setNumUnread]) + }, [setNumUnread, currentAccount]) // create API const api = React.useMemo<ApiContext>(() => { @@ -61,7 +82,7 @@ export function Provider({children}: React.PropsWithChildren<{}>) { async markAllRead() { // update server await getAgent().updateSeenNotifications( - lastSyncRef.current.toISOString(), + cacheRef.current.syncedAt.toISOString(), ) // update & broadcast @@ -69,38 +90,59 @@ export function Provider({children}: React.PropsWithChildren<{}>) { broadcast.postMessage({event: ''}) }, - async checkUnread() { - const agent = getAgent() - - if (!agent.session) return - - // count - const res = await agent.listNotifications({limit: 40}) - const filtered = res.data.notifications.filter( - notif => !notif.isRead && !shouldFilterNotif(notif, moderationOpts), - ) - const num = - filtered.length >= 30 - ? '30+' - : filtered.length === 0 - ? '' - : String(filtered.length) - if (isNative) { - Notifications.setBadgeCountAsync(Math.min(filtered.length, 30)) + async checkUnread({invalidate}: {invalidate?: boolean} = {}) { + try { + if (!getAgent().session) return + + // count + const page = await fetchPage({ + cursor: undefined, + limit: 40, + queryClient, + moderationOpts, + threadMutes, + }) + const unreadCount = countUnread(page) + const unreadCountStr = + unreadCount >= 30 + ? '30+' + : unreadCount === 0 + ? '' + : String(unreadCount) + if (isNative) { + Notifications.setBadgeCountAsync(Math.min(unreadCount, 30)) + } + + // track last sync + const now = new Date() + const lastIndexed = + page.items[0] && new Date(page.items[0].notification.indexedAt) + cacheRef.current = { + sessDid: currentAccount?.did || '', + data: page, + syncedAt: !lastIndexed || now > lastIndexed ? now : lastIndexed, + } + + // update & broadcast + setNumUnread(unreadCountStr) + if (invalidate) { + queryClient.resetQueries({queryKey: RQKEY_NOTIFS()}) + } + broadcast.postMessage({event: unreadCountStr}) + } catch (e) { + logger.error('Failed to check unread notifications', {error: e}) } + }, - // track last sync - const now = new Date() - const lastIndexed = filtered[0] && new Date(filtered[0].indexedAt) - lastSyncRef.current = - !lastIndexed || now > lastIndexed ? now : lastIndexed - - // update & broadcast - setNumUnread(num) - broadcast.postMessage({event: num}) + getCachedUnreadPage() { + // return cached page if was for the current user + // (protects against session changes serving data from the past session) + if (cacheRef.current.sessDid === currentAccount?.did) { + return cacheRef.current.data + } }, } - }, [setNumUnread, moderationOpts]) + }, [setNumUnread, queryClient, moderationOpts, threadMutes, currentAccount]) checkUnreadRef.current = api.checkUnread return ( @@ -117,3 +159,20 @@ export function useUnreadNotifications() { export function useUnreadNotificationsApi() { return React.useContext(apiContext) } + +function countUnread(page: FeedPage) { + let num = 0 + for (const item of page.items) { + if (!item.notification.isRead) { + num++ + } + if (item.additional) { + for (const item2 of item.additional) { + if (!item2.isRead) { + num++ + } + } + } + } + return num +} diff --git a/src/state/queries/notifications/util.ts b/src/state/queries/notifications/util.ts index c49d1851a..b8f320473 100644 --- a/src/state/queries/notifications/util.ts +++ b/src/state/queries/notifications/util.ts @@ -3,10 +3,78 @@ import { ModerationOpts, moderateProfile, moderatePost, + AppBskyFeedDefs, + AppBskyFeedPost, + AppBskyFeedRepost, + AppBskyFeedLike, } from '@atproto/api' +import chunk from 'lodash.chunk' +import {QueryClient} from '@tanstack/react-query' +import {getAgent} from '../../session' +import {precacheProfile as precacheResolvedUri} from '../resolve-uri' +import {NotificationType, FeedNotification, FeedPage} from './types' + +const GROUPABLE_REASONS = ['like', 'repost', 'follow'] +const MS_1HR = 1e3 * 60 * 60 +const MS_2DAY = MS_1HR * 48 + +// exported api +// = + +export async function fetchPage({ + cursor, + limit, + queryClient, + moderationOpts, + threadMutes, +}: { + cursor: string | undefined + limit: number + queryClient: QueryClient + moderationOpts: ModerationOpts | undefined + threadMutes: string[] +}): Promise<FeedPage> { + const res = await getAgent().listNotifications({ + limit, + cursor, + }) + + // filter out notifs by mod rules + const notifs = res.data.notifications.filter( + notif => !shouldFilterNotif(notif, moderationOpts), + ) + + // group notifications which are essentially similar (follows, likes on a post) + let notifsGrouped = groupNotifications(notifs) + + // we fetch subjects of notifications (usually posts) now instead of lazily + // in the UI to avoid relayouts + const subjects = await fetchSubjects(notifsGrouped) + for (const notif of notifsGrouped) { + if (notif.subjectUri) { + notif.subject = subjects.get(notif.subjectUri) + if (notif.subject) { + precacheResolvedUri(queryClient, notif.subject.author) // precache the handle->did resolution + } + } + } + + // apply thread muting + notifsGrouped = notifsGrouped.filter( + notif => !isThreadMuted(notif, threadMutes), + ) + + return { + cursor: res.data.cursor, + items: notifsGrouped, + } +} + +// internal methods +// = // TODO this should be in the sdk as moderateNotification -prf -export function shouldFilterNotif( +function shouldFilterNotif( notif: AppBskyNotificationListNotifications.Notification, moderationOpts: ModerationOpts | undefined, ): boolean { @@ -36,3 +104,116 @@ export function shouldFilterNotif( // (this requires fetching the post) return false } + +function groupNotifications( + notifs: AppBskyNotificationListNotifications.Notification[], +): FeedNotification[] { + const groupedNotifs: FeedNotification[] = [] + for (const notif of notifs) { + const ts = +new Date(notif.indexedAt) + let grouped = false + if (GROUPABLE_REASONS.includes(notif.reason)) { + for (const groupedNotif of groupedNotifs) { + const ts2 = +new Date(groupedNotif.notification.indexedAt) + if ( + Math.abs(ts2 - ts) < MS_2DAY && + notif.reason === groupedNotif.notification.reason && + notif.reasonSubject === groupedNotif.notification.reasonSubject && + notif.author.did !== groupedNotif.notification.author.did && + notif.isRead === groupedNotif.notification.isRead + ) { + groupedNotif.additional = groupedNotif.additional || [] + groupedNotif.additional.push(notif) + grouped = true + break + } + } + } + if (!grouped) { + const type = toKnownType(notif) + groupedNotifs.push({ + _reactKey: `notif-${notif.uri}`, + type, + notification: notif, + subjectUri: getSubjectUri(type, notif), + }) + } + } + return groupedNotifs +} + +async function fetchSubjects( + groupedNotifs: FeedNotification[], +): Promise<Map<string, AppBskyFeedDefs.PostView>> { + const uris = new Set<string>() + for (const notif of groupedNotifs) { + if (notif.subjectUri) { + uris.add(notif.subjectUri) + } + } + const uriChunks = chunk(Array.from(uris), 25) + const postsChunks = await Promise.all( + uriChunks.map(uris => + getAgent() + .app.bsky.feed.getPosts({uris}) + .then(res => res.data.posts), + ), + ) + const map = new Map<string, AppBskyFeedDefs.PostView>() + for (const post of postsChunks.flat()) { + if ( + AppBskyFeedPost.isRecord(post.record) && + AppBskyFeedPost.validateRecord(post.record).success + ) { + map.set(post.uri, post) + } + } + return map +} + +function toKnownType( + notif: AppBskyNotificationListNotifications.Notification, +): NotificationType { + if (notif.reason === 'like') { + if (notif.reasonSubject?.includes('feed.generator')) { + return 'feedgen-like' + } + return 'post-like' + } + if ( + notif.reason === 'repost' || + notif.reason === 'mention' || + notif.reason === 'reply' || + notif.reason === 'quote' || + notif.reason === 'follow' + ) { + return notif.reason as NotificationType + } + return 'unknown' +} + +function getSubjectUri( + type: NotificationType, + notif: AppBskyNotificationListNotifications.Notification, +): string | undefined { + if (type === 'reply' || type === 'quote' || type === 'mention') { + return notif.uri + } else if (type === 'post-like' || type === 'repost') { + if ( + AppBskyFeedRepost.isRecord(notif.record) || + AppBskyFeedLike.isRecord(notif.record) + ) { + return typeof notif.record.subject?.uri === 'string' + ? notif.record.subject?.uri + : undefined + } + } +} + +function isThreadMuted(notif: FeedNotification, mutes: string[]): boolean { + if (!notif.subject) { + return false + } + const record = notif.subject.record as AppBskyFeedPost.Record // assured in fetchSubjects() + return mutes.includes(record.reply?.root.uri || notif.subject.uri) +} diff --git a/src/state/queries/post-feed.ts b/src/state/queries/post-feed.ts index 113e6f2fb..7cf315ef6 100644 --- a/src/state/queries/post-feed.ts +++ b/src/state/queries/post-feed.ts @@ -1,4 +1,3 @@ -import {useCallback, useMemo} from 'react' import {AppBskyFeedDefs, AppBskyFeedPost, moderatePost} from '@atproto/api' import { useInfiniteQuery, @@ -7,9 +6,8 @@ import { QueryClient, useQueryClient, } from '@tanstack/react-query' -import {getAgent} from '../session' import {useFeedTuners} from '../preferences/feed-tuners' -import {FeedTuner, NoopFeedTuner} from 'lib/api/feed-manip' +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 {AuthorFeedAPI} from 'lib/api/feed/author' @@ -17,10 +15,13 @@ import {LikesFeedAPI} from 'lib/api/feed/likes' import {CustomFeedAPI} from 'lib/api/feed/custom' import {ListFeedAPI} from 'lib/api/feed/list' import {MergeFeedAPI} from 'lib/api/feed/merge' -import {useModerationOpts} from '#/state/queries/preferences' import {logger} from '#/logger' import {STALE} from '#/state/queries' import {precacheFeedPosts as precacheResolvedUris} from './resolve-uri' +import {getAgent} from '#/state/session' +import {DEFAULT_LOGGED_OUT_PREFERENCES} from '#/state/queries/preferences/const' +import {getModerationOpts} from '#/state/queries/preferences/moderation' +import {KnownError} from '#/view/com/posts/FeedErrorMessage' type ActorDid = string type AuthorFilter = @@ -42,7 +43,7 @@ export interface FeedParams { mergeFeedSources?: string[] } -type RQPageParam = string | undefined +type RQPageParam = {cursor: string | undefined; api: FeedAPI} | undefined export function RQKEY(feedDesc: FeedDescriptor, params?: FeedParams) { return ['post-feed', feedDesc, params || {}] @@ -63,7 +64,15 @@ export interface FeedPostSlice { items: FeedPostSliceItem[] } +export interface FeedPageUnselected { + api: FeedAPI + cursor: string | undefined + feed: AppBskyFeedDefs.FeedViewPost[] +} + export interface FeedPage { + api: FeedAPI + tuner: FeedTuner | NoopFeedTuner cursor: string | undefined slices: FeedPostSlice[] } @@ -76,117 +85,139 @@ export function usePostFeedQuery( const queryClient = useQueryClient() const feedTuners = useFeedTuners(feedDesc) const enabled = opts?.enabled !== false - const moderationOpts = useModerationOpts() - const agent = getAgent() - - const api: FeedAPI = useMemo(() => { - if (feedDesc === 'home') { - return new MergeFeedAPI(agent, params || {}, feedTuners) - } else if (feedDesc === 'following') { - return new FollowingFeedAPI(agent) - } else if (feedDesc.startsWith('author')) { - const [_, actor, filter] = feedDesc.split('|') - return new AuthorFeedAPI(agent, {actor, filter}) - } else if (feedDesc.startsWith('likes')) { - const [_, actor] = feedDesc.split('|') - return new LikesFeedAPI(agent, {actor}) - } else if (feedDesc.startsWith('feedgen')) { - const [_, feed] = feedDesc.split('|') - return new CustomFeedAPI(agent, {feed}) - } else if (feedDesc.startsWith('list')) { - const [_, list] = feedDesc.split('|') - return new ListFeedAPI(agent, {list}) - } else { - // shouldnt happen - return new FollowingFeedAPI(agent) - } - }, [feedDesc, params, feedTuners, agent]) - - const disableTuner = !!params?.disableTuner - const tuner = useMemo( - () => (disableTuner ? new NoopFeedTuner() : new FeedTuner()), - [disableTuner], - ) - - const pollLatest = useCallback(async () => { - if (!enabled) { - return false - } - - logger.debug('usePostFeedQuery: pollLatest') - - const post = await api.peekLatest() - - if (post && moderationOpts) { - const slices = tuner.tune([post], feedTuners, { - dryRun: true, - maintainOrder: true, - }) - if (slices[0]) { - if ( - !moderatePost(slices[0].items[0].post, moderationOpts).content.filter - ) { - return true - } - } - } - return false - }, [api, tuner, feedTuners, moderationOpts, enabled]) - - const out = useInfiniteQuery< - FeedPage, + return useInfiniteQuery< + FeedPageUnselected, Error, InfiniteData<FeedPage>, QueryKey, RQPageParam >({ + enabled, staleTime: STALE.INFINITY, queryKey: RQKEY(feedDesc, params), async queryFn({pageParam}: {pageParam: RQPageParam}) { logger.debug('usePostFeedQuery', {feedDesc, pageParam}) - if (!pageParam) { - tuner.reset() - } - const res = await api.fetch({cursor: pageParam, limit: 30}) + + const {api, cursor} = pageParam + ? pageParam + : { + api: createApi(feedDesc, params || {}, feedTuners), + cursor: undefined, + } + + const res = await api.fetch({cursor, limit: 30}) precacheResolvedUris(queryClient, res.feed) // precache the handle->did resolution - const slices = tuner.tune(res.feed, feedTuners) + + /* + * 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, - slices: slices.map(slice => ({ - _reactKey: slice._reactKey, - rootUri: slice.rootItem.post.uri, - isThread: - slice.items.length > 1 && - slice.items.every( - item => item.post.author.did === slice.items[0].post.author.did, - ), - items: slice.items - .map((item, i) => { - if ( - AppBskyFeedPost.isRecord(item.post.record) && - AppBskyFeedPost.validateRecord(item.post.record).success - ) { - return { - _reactKey: `${slice._reactKey}-${i}`, - uri: item.post.uri, - post: item.post, - record: item.post.record, - reason: i === 0 && slice.source ? slice.source : item.reason, + feed: res.feed, + } + }, + initialPageParam: undefined, + getNextPageParam: lastPage => ({ + api: lastPage.api, + cursor: lastPage.cursor, + }), + select(data) { + const tuner = params?.disableTuner + ? new NoopFeedTuner() + : new FeedTuner(feedTuners) + return { + pageParams: data.pageParams, + pages: data.pages.map(page => ({ + api: page.api, + tuner, + cursor: page.cursor, + slices: tuner.tune(page.feed).map(slice => ({ + _reactKey: slice._reactKey, + rootUri: slice.rootItem.post.uri, + isThread: + slice.items.length > 1 && + slice.items.every( + item => item.post.author.did === slice.items[0].post.author.did, + ), + items: slice.items + .map((item, i) => { + if ( + AppBskyFeedPost.isRecord(item.post.record) && + AppBskyFeedPost.validateRecord(item.post.record).success + ) { + return { + _reactKey: `${slice._reactKey}-${i}`, + uri: item.post.uri, + post: item.post, + record: item.post.record, + reason: + i === 0 && slice.source ? slice.source : item.reason, + } } - } - return undefined - }) - .filter(Boolean) as FeedPostSliceItem[], + return undefined + }) + .filter(Boolean) as FeedPostSliceItem[], + })), })), } }, - initialPageParam: undefined, - getNextPageParam: lastPage => lastPage.cursor, - enabled, }) +} + +export async function pollLatest(page: FeedPage | undefined) { + if (!page) { + return false + } - return {...out, pollLatest} + logger.debug('usePostFeedQuery: pollLatest') + const post = await page.api.peekLatest() + if (post) { + const slices = page.tuner.tune([post], { + dryRun: true, + maintainOrder: true, + }) + if (slices[0]) { + return true + } + } + + return false +} + +function createApi( + feedDesc: FeedDescriptor, + params: FeedParams, + feedTuners: FeedTunerFn[], +) { + if (feedDesc === 'home') { + return new MergeFeedAPI(params, feedTuners) + } else if (feedDesc === 'following') { + return new FollowingFeedAPI() + } else if (feedDesc.startsWith('author')) { + const [_, actor, filter] = feedDesc.split('|') + return new AuthorFeedAPI({actor, filter}) + } else if (feedDesc.startsWith('likes')) { + const [_, actor] = feedDesc.split('|') + return new LikesFeedAPI({actor}) + } else if (feedDesc.startsWith('feedgen')) { + const [_, feed] = feedDesc.split('|') + return new CustomFeedAPI({feed}) + } else if (feedDesc.startsWith('list')) { + const [_, list] = feedDesc.split('|') + return new ListFeedAPI({list}) + } else { + // shouldnt happen + return new FollowingFeedAPI() + } } /** @@ -196,8 +227,10 @@ export function usePostFeedQuery( export function findPostInQueryData( queryClient: QueryClient, uri: string, -): FeedPostSliceItem | undefined { - const queryDatas = queryClient.getQueriesData<InfiniteData<FeedPage>>({ +): AppBskyFeedDefs.FeedViewPost | undefined { + const queryDatas = queryClient.getQueriesData< + InfiniteData<FeedPageUnselected> + >({ queryKey: ['post-feed'], }) for (const [_queryKey, queryData] of queryDatas) { @@ -205,14 +238,34 @@ export function findPostInQueryData( continue } for (const page of queryData?.pages) { - for (const slice of page.slices) { - for (const item of slice.items) { - if (item.uri === uri) { - return item - } + for (const item of page.feed) { + if (item.post.uri === uri) { + return item } } } } return undefined } + +function assertSomePostsPassModeration(feed: AppBskyFeedDefs.FeedViewPost[]) { + // assume false + let somePostsPassModeration = false + + for (const item of feed) { + const moderationOpts = getModerationOpts({ + userDid: '', + preferences: DEFAULT_LOGGED_OUT_PREFERENCES, + }) + const moderation = moderatePost(item.post, moderationOpts) + + if (!moderation.content.filter) { + // we have a sfw post + somePostsPassModeration = true + } + } + + if (!somePostsPassModeration) { + throw new Error(KnownError.FeedNSFPublic) + } +} diff --git a/src/state/queries/post-liked-by.ts b/src/state/queries/post-liked-by.ts index 33b379a0c..528b3be70 100644 --- a/src/state/queries/post-liked-by.ts +++ b/src/state/queries/post-liked-by.ts @@ -2,7 +2,6 @@ import {AppBskyFeedGetLikes} from '@atproto/api' import {useInfiniteQuery, InfiniteData, QueryKey} from '@tanstack/react-query' import {getAgent} from '#/state/session' -import {STALE} from '#/state/queries' const PAGE_SIZE = 30 type RQPageParam = string | undefined @@ -18,7 +17,6 @@ export function usePostLikedByQuery(resolvedUri: string | undefined) { QueryKey, RQPageParam >({ - staleTime: STALE.MINUTES.ONE, queryKey: RQKEY(resolvedUri || ''), async queryFn({pageParam}: {pageParam: RQPageParam}) { const res = await getAgent().getLikes({ diff --git a/src/state/queries/post-reposted-by.ts b/src/state/queries/post-reposted-by.ts index 3a6fe1633..f9a80056f 100644 --- a/src/state/queries/post-reposted-by.ts +++ b/src/state/queries/post-reposted-by.ts @@ -2,7 +2,6 @@ import {AppBskyFeedGetRepostedBy} from '@atproto/api' import {useInfiniteQuery, InfiniteData, QueryKey} from '@tanstack/react-query' import {getAgent} from '#/state/session' -import {STALE} from '#/state/queries' const PAGE_SIZE = 30 type RQPageParam = string | undefined @@ -18,7 +17,6 @@ export function usePostRepostedByQuery(resolvedUri: string | undefined) { QueryKey, RQPageParam >({ - staleTime: STALE.MINUTES.ONE, queryKey: RQKEY(resolvedUri || ''), async queryFn({pageParam}: {pageParam: RQPageParam}) { const res = await getAgent().getRepostedBy({ diff --git a/src/state/queries/post-thread.ts b/src/state/queries/post-thread.ts index c616b05cc..d40af1fe2 100644 --- a/src/state/queries/post-thread.ts +++ b/src/state/queries/post-thread.ts @@ -7,11 +7,7 @@ import {useQuery, useQueryClient, QueryClient} from '@tanstack/react-query' import {getAgent} from '#/state/session' import {UsePreferencesQueryResponse} from '#/state/queries/preferences/types' -import {STALE} from '#/state/queries' -import { - findPostInQueryData as findPostInFeedQueryData, - FeedPostSliceItem, -} from './post-feed' +import {findPostInQueryData as findPostInFeedQueryData} from './post-feed' import {findPostInQueryData as findPostInNotifsQueryData} from './notifications/feed' import {precacheThreadPosts as precacheResolvedUris} from './resolve-uri' @@ -68,7 +64,6 @@ export type ThreadNode = export function usePostThreadQuery(uri: string | undefined) { const queryClient = useQueryClient() return useQuery<ThreadNode, Error>({ - staleTime: STALE.MINUTES.ONE, queryKey: RQKEY(uri || ''), async queryFn() { const res = await getAgent().getPostThread({uri: uri!}) @@ -93,7 +88,7 @@ export function usePostThreadQuery(uri: string | undefined) { { const item = findPostInFeedQueryData(queryClient, uri) if (item) { - return feedItemToPlaceholderThread(item) + return feedViewPostToPlaceholderThread(item) } } { @@ -275,13 +270,15 @@ function threadNodeToPlaceholderThread( } } -function feedItemToPlaceholderThread(item: FeedPostSliceItem): ThreadNode { +function feedViewPostToPlaceholderThread( + item: AppBskyFeedDefs.FeedViewPost, +): ThreadNode { return { type: 'post', _reactKey: item.post.uri, uri: item.post.uri, post: item.post, - record: item.record, + record: item.post.record as AppBskyFeedPost.Record, // validated in post-feed parent: undefined, replies: undefined, viewer: item.post.viewer, @@ -291,7 +288,7 @@ function feedItemToPlaceholderThread(item: FeedPostSliceItem): ThreadNode { hasMore: false, showChildReplyLine: false, showParentReplyLine: false, - isParentLoading: !!item.record.reply, + isParentLoading: !!(item.post.record as AppBskyFeedPost.Record).reply, isChildLoading: !!item.post.replyCount, }, } @@ -305,7 +302,7 @@ function postViewToPlaceholderThread( _reactKey: post.uri, uri: post.uri, post: post, - record: post.record as AppBskyFeedPost.Record, // validate in notifs + record: post.record as AppBskyFeedPost.Record, // validated in notifs parent: undefined, replies: undefined, viewer: post.viewer, diff --git a/src/state/queries/post.ts b/src/state/queries/post.ts index d4193b8ce..b31696446 100644 --- a/src/state/queries/post.ts +++ b/src/state/queries/post.ts @@ -4,13 +4,11 @@ import {useQuery, useMutation, useQueryClient} from '@tanstack/react-query' import {getAgent} from '#/state/session' import {updatePostShadow} from '#/state/cache/post-shadow' -import {STALE} from '#/state/queries' export const RQKEY = (postUri: string) => ['post', postUri] export function usePostQuery(uri: string | undefined) { return useQuery<AppBskyFeedDefs.PostView>({ - staleTime: STALE.MINUTES.ONE, queryKey: RQKEY(uri || ''), async queryFn() { const res = await getAgent().getPosts({uris: [uri!]}) @@ -29,7 +27,6 @@ export function useGetPost() { return React.useCallback( async ({uri}: {uri: string}) => { return queryClient.fetchQuery({ - staleTime: STALE.MINUTES.ONE, queryKey: RQKEY(uri || ''), async queryFn() { const urip = new AtUri(uri) diff --git a/src/state/queries/profile-feedgens.ts b/src/state/queries/profile-feedgens.ts index 04860430e..7d33eb9c8 100644 --- a/src/state/queries/profile-feedgens.ts +++ b/src/state/queries/profile-feedgens.ts @@ -2,7 +2,6 @@ import {AppBskyFeedGetActorFeeds} from '@atproto/api' import {useInfiniteQuery, InfiniteData, QueryKey} from '@tanstack/react-query' import {getAgent} from '#/state/session' -import {STALE} from '#/state/queries' const PAGE_SIZE = 30 type RQPageParam = string | undefined @@ -22,7 +21,6 @@ export function useProfileFeedgensQuery( QueryKey, RQPageParam >({ - staleTime: STALE.MINUTES.ONE, queryKey: RQKEY(did), async queryFn({pageParam}: {pageParam: RQPageParam}) { const res = await getAgent().app.bsky.feed.getActorFeeds({ diff --git a/src/state/queries/profile-followers.ts b/src/state/queries/profile-followers.ts index 774bd23f1..b2008851d 100644 --- a/src/state/queries/profile-followers.ts +++ b/src/state/queries/profile-followers.ts @@ -2,7 +2,6 @@ import {AppBskyGraphGetFollowers} from '@atproto/api' import {useInfiniteQuery, InfiniteData, QueryKey} from '@tanstack/react-query' import {getAgent} from '#/state/session' -import {STALE} from '#/state/queries' const PAGE_SIZE = 30 type RQPageParam = string | undefined @@ -17,7 +16,6 @@ export function useProfileFollowersQuery(did: string | undefined) { QueryKey, RQPageParam >({ - staleTime: STALE.MINUTES.FIVE, queryKey: RQKEY(did || ''), async queryFn({pageParam}: {pageParam: RQPageParam}) { const res = await getAgent().app.bsky.graph.getFollowers({ diff --git a/src/state/queries/profile-lists.ts b/src/state/queries/profile-lists.ts index 997c85910..505d33b9f 100644 --- a/src/state/queries/profile-lists.ts +++ b/src/state/queries/profile-lists.ts @@ -1,8 +1,6 @@ import {AppBskyGraphGetLists} from '@atproto/api' import {useInfiniteQuery, InfiniteData, QueryKey} from '@tanstack/react-query' - import {getAgent} from '#/state/session' -import {STALE} from '#/state/queries' const PAGE_SIZE = 30 type RQPageParam = string | undefined @@ -18,7 +16,6 @@ export function useProfileListsQuery(did: string, opts?: {enabled?: boolean}) { QueryKey, RQPageParam >({ - staleTime: STALE.MINUTES.ONE, queryKey: RQKEY(did), async queryFn({pageParam}: {pageParam: RQPageParam}) { const res = await getAgent().app.bsky.graph.getLists({ diff --git a/src/state/queries/profile.ts b/src/state/queries/profile.ts index e27bac9a6..62e8f39c0 100644 --- a/src/state/queries/profile.ts +++ b/src/state/queries/profile.ts @@ -4,7 +4,6 @@ import { AppBskyActorDefs, AppBskyActorProfile, AppBskyActorGetProfile, - BskyAgent, } from '@atproto/api' import {useQuery, useQueryClient, useMutation} from '@tanstack/react-query' import {Image as RNImage} from 'react-native-image-crop-picker' @@ -22,6 +21,10 @@ export const RQKEY = (did: string) => ['profile', did] export function useProfileQuery({did}: {did: string | undefined}) { return useQuery({ + // WARNING + // this staleTime is load-bearing + // if you remove it, the UI infinite-loops + // -prf staleTime: STALE.MINUTES.FIVE, queryKey: RQKEY(did || ''), queryFn: async () => { @@ -68,7 +71,7 @@ export function useProfileUpdateMutation() { } return existing }) - await whenAppViewReady(getAgent(), profile.did, res => { + await whenAppViewReady(profile.did, res => { if (typeof newUserAvatar !== 'undefined') { if (newUserAvatar === null && res.data.avatar) { // url hasnt cleared yet @@ -464,7 +467,6 @@ function useProfileUnblockMutation() { } async function whenAppViewReady( - agent: BskyAgent, actor: string, fn: (res: AppBskyActorGetProfile.Response) => boolean, ) { @@ -472,6 +474,6 @@ async function whenAppViewReady( 5, // 5 tries 1e3, // 1s delay between tries fn, - () => agent.app.bsky.actor.getProfile({actor}), + () => getAgent().app.bsky.actor.getProfile({actor}), ) } diff --git a/src/state/queries/resolve-uri.ts b/src/state/queries/resolve-uri.ts index 05a9f4b1c..a75998466 100644 --- a/src/state/queries/resolve-uri.ts +++ b/src/state/queries/resolve-uri.ts @@ -23,7 +23,7 @@ export function useResolveUriQuery(uri: string | undefined): UriUseQueryResult { export function useResolveDidQuery(didOrHandle: string | undefined) { return useQuery<string, Error>({ - staleTime: STALE.INFINITY, + staleTime: STALE.HOURS.ONE, queryKey: RQKEY(didOrHandle || ''), async queryFn() { if (!didOrHandle) { diff --git a/src/state/queries/service.ts b/src/state/queries/service.ts index c7df89960..5f7e10778 100644 --- a/src/state/queries/service.ts +++ b/src/state/queries/service.ts @@ -1,13 +1,10 @@ import {BskyAgent} from '@atproto/api' import {useQuery} from '@tanstack/react-query' -import {STALE} from '#/state/queries' - export const RQKEY = (serviceUrl: string) => ['service', serviceUrl] export function useServiceQuery(serviceUrl: string) { return useQuery({ - staleTime: STALE.HOURS.ONE, queryKey: RQKEY(serviceUrl), queryFn: async () => { const agent = new BskyAgent({service: serviceUrl}) diff --git a/src/state/queries/suggested-follows.ts b/src/state/queries/suggested-follows.ts index 176bbe15b..eadcb590a 100644 --- a/src/state/queries/suggested-follows.ts +++ b/src/state/queries/suggested-follows.ts @@ -90,7 +90,7 @@ export function useGetSuggestedFollowersByActor() { return React.useCallback( async (actor: string) => { const res = await queryClient.fetchQuery({ - staleTime: 60 * 1000, + staleTime: STALE.MINUTES.ONE, queryKey: suggestedFollowsByActorQueryKey(actor), queryFn: async () => { const res = |