diff --git a/src/lib/api/feed-manip.ts b/src/lib/api/feed-manip.ts index 912302d0..1123c4e2 100644 --- a/src/lib/api/feed-manip.ts +++ b/src/lib/api/feed-manip.ts @@ -16,14 +16,7 @@ export type FeedTunerFn = ( export class FeedViewPostsSlice { isFlattenedReply = false - constructor(public items: FeedViewPost[] = []) {} - - get _reactKey() { - const rootItem = this.isFlattenedReply ? this.items[1] : this.items[0] - return `slice-${rootItem.post.uri}-${ - rootItem.reason?.indexedAt || rootItem.post.indexedAt - }` - } + constructor(public items: FeedViewPost[], public _reactKey: string) {} get uri() { if (this.isFlattenedReply) { @@ -118,28 +111,34 @@ export class FeedViewPostsSlice { } export class NoopFeedTuner { - reset() {} + private keyCounter = 0 + + reset() { + this.keyCounter = 0 + } tune( feed: FeedViewPost[], - _tunerFns: FeedTunerFn[] = [], _opts?: {dryRun: boolean; maintainOrder: boolean}, ): FeedViewPostsSlice[] { - return feed.map(item => new FeedViewPostsSlice([item])) + return feed.map( + item => new FeedViewPostsSlice([item], `slice-${this.keyCounter++}`), + ) } } export class FeedTuner { + private keyCounter = 0 seenUris: Set = new Set() - constructor() {} + constructor(public tunerFns: FeedTunerFn[]) {} reset() { + this.keyCounter = 0 this.seenUris.clear() } tune( feed: FeedViewPost[], - tunerFns: FeedTunerFn[] = [], {dryRun, maintainOrder}: {dryRun: boolean; maintainOrder: boolean} = { dryRun: false, maintainOrder: false, @@ -148,7 +147,9 @@ export class FeedTuner { let slices: FeedViewPostsSlice[] = [] if (maintainOrder) { - slices = feed.map(item => new FeedViewPostsSlice([item])) + slices = feed.map( + item => new FeedViewPostsSlice([item], `slice-${this.keyCounter++}`), + ) } else { // arrange the posts into thread slices for (let i = feed.length - 1; i >= 0; i--) { @@ -164,12 +165,14 @@ export class FeedTuner { continue } } - slices.unshift(new FeedViewPostsSlice([item])) + slices.unshift( + new FeedViewPostsSlice([item], `slice-${this.keyCounter++}`), + ) } } // run the custom tuners - for (const tunerFn of tunerFns) { + for (const tunerFn of this.tunerFns) { slices = tunerFn(this, slices.slice()) } diff --git a/src/lib/api/feed/merge.ts b/src/lib/api/feed/merge.ts index bc1b0883..11e963f0 100644 --- a/src/lib/api/feed/merge.ts +++ b/src/lib/api/feed/merge.ts @@ -180,7 +180,7 @@ class MergeFeedSource { } class MergeFeedSource_Following extends MergeFeedSource { - tuner = new FeedTuner() + tuner = new FeedTuner(this.feedTuners) reset() { super.reset() @@ -197,7 +197,7 @@ class MergeFeedSource_Following extends MergeFeedSource { ): Promise { const res = await getAgent().getTimeline({cursor, limit}) // run the tuner pre-emptively to ensure better mixing - const slices = this.tuner.tune(res.data.feed, this.feedTuners, { + const slices = this.tuner.tune(res.data.feed, { dryRun: false, maintainOrder: true, }) diff --git a/src/state/queries/post-feed.ts b/src/state/queries/post-feed.ts index c3f0c758..74c0c064 100644 --- a/src/state/queries/post-feed.ts +++ b/src/state/queries/post-feed.ts @@ -1,5 +1,4 @@ -import {useCallback, useMemo} from 'react' -import {AppBskyFeedDefs, AppBskyFeedPost, moderatePost} from '@atproto/api' +import {AppBskyFeedDefs, AppBskyFeedPost} from '@atproto/api' import { useInfiniteQuery, InfiniteData, @@ -8,7 +7,7 @@ import { useQueryClient, } from '@tanstack/react-query' 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' @@ -16,7 +15,6 @@ 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' @@ -41,7 +39,9 @@ export interface FeedParams { mergeFeedSources?: string[] } -type RQPageParam = string | undefined +type RQPageParam = + | {cursor: string | undefined; api: FeedAPI; tuner: FeedTuner | NoopFeedTuner} + | undefined export function RQKEY(feedDesc: FeedDescriptor, params?: FeedParams) { return ['post-feed', feedDesc, params || {}] @@ -63,6 +63,8 @@ export interface FeedPostSlice { } export interface FeedPage { + api: FeedAPI + tuner: FeedTuner | NoopFeedTuner cursor: string | undefined slices: FeedPostSlice[] } @@ -75,64 +77,8 @@ export function usePostFeedQuery( const queryClient = useQueryClient() const feedTuners = useFeedTuners(feedDesc) const enabled = opts?.enabled !== false - const moderationOpts = useModerationOpts() - const api: FeedAPI = useMemo(() => { - 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() - } - }, [feedDesc, params, feedTuners]) - - 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< + return useInfiniteQuery< FeedPage, Error, InfiniteData, @@ -143,13 +89,23 @@ export function usePostFeedQuery( 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, tuner, cursor} = pageParam + ? pageParam + : { + api: createApi(feedDesc, params || {}, feedTuners), + tuner: params?.disableTuner + ? new NoopFeedTuner() + : new FeedTuner(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) + const slices = tuner.tune(res.feed) return { + api, + tuner, cursor: res.cursor, slices: slices.map(slice => ({ _reactKey: slice._reactKey, @@ -180,11 +136,60 @@ export function usePostFeedQuery( } }, initialPageParam: undefined, - getNextPageParam: lastPage => lastPage.cursor, + getNextPageParam: lastPage => ({ + api: lastPage.api, + tuner: lastPage.tuner, + cursor: lastPage.cursor, + }), enabled, }) +} - return {...out, pollLatest} +export async function pollLatest(page: FeedPage | undefined) { + if (!page) { + return false + } + + 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() + } } /** diff --git a/src/view/com/posts/Feed.tsx b/src/view/com/posts/Feed.tsx index fc6d7769..393c1bc9 100644 --- a/src/view/com/posts/Feed.tsx +++ b/src/view/com/posts/Feed.tsx @@ -23,6 +23,7 @@ import { FeedDescriptor, FeedParams, usePostFeedQuery, + pollLatest, } from '#/state/queries/post-feed' import {useModerationOpts} from '#/state/queries/preferences' @@ -84,22 +85,21 @@ let Feed = ({ hasNextPage, isFetchingNextPage, fetchNextPage, - pollLatest, } = usePostFeedQuery(feed, feedParams, opts) const isEmpty = !isFetching && !data?.pages[0]?.slices.length const checkForNew = React.useCallback(async () => { - if (!isFetched || isFetching || !onHasNew) { + if (!data?.pages[0] || isFetching || !onHasNew) { return } try { - if (await pollLatest()) { + if (await pollLatest(data.pages[0])) { onHasNew(true) } } catch (e) { logger.error('Poll latest failed', {feed, error: String(e)}) } - }, [feed, isFetched, isFetching, pollLatest, onHasNew]) + }, [feed, data, isFetching, onHasNew]) React.useEffect(() => { // we store the interval handler in a ref to avoid needless