diff --git a/src/lib/api/feed-manip.test.ts b/src/lib/api/feed-manip.test.ts new file mode 100644 index 0000000000..f5ae43c461 --- /dev/null +++ b/src/lib/api/feed-manip.test.ts @@ -0,0 +1,54 @@ +import {type app} from '#/lexicons' +import {createFeedViewPostsSlices} from './feed-manip' + +jest.mock('./feed/home', () => ({ + FALLBACK_MARKER_POST: {post: {uri: 'at://did:plc:test/app.bsky.feed.post/1'}}, +})) + +const author = { + $type: 'app.bsky.actor.defs#profileViewBasic', + did: 'did:plc:alice', + handle: 'alice.test', +} as app.bsky.actor.defs.ProfileViewBasic + +function post(id: string) { + return { + $type: 'app.bsky.feed.defs#postView', + uri: `at://did:plc:alice/app.bsky.feed.post/${id}`, + cid: id, + author, + record: { + $type: 'app.bsky.feed.post', + text: id, + createdAt: '2026-08-31T00:00:00.000Z', + }, + indexedAt: '2026-08-31T00:00:00.000Z', + } as app.bsky.feed.defs.PostView +} + +describe('createFeedViewPostsSlices', () => { + it('preserves selected numbering and infers hydrated parent and root numbering', () => { + const root = post('root') + const parent = post('parent') + const selected = post('selected') + const feedPost = { + post: selected, + reply: {root, parent}, + opThreadPostIndex: 3, + opThreadPostCount: 4, + } as app.bsky.feed.defs.FeedViewPost & { + opThreadPostIndex: number + opThreadPostCount: number + } + + const [slice] = createFeedViewPostsSlices([feedPost]) + + expect( + slice.items.map(item => [item.post.uri, item.postNumbering]), + ).toEqual([ + [root.uri, {opThreadPostIndex: 1, opThreadPostCount: 4}], + [parent.uri, {opThreadPostIndex: 2, opThreadPostCount: 4}], + [selected.uri, {opThreadPostIndex: 3, opThreadPostCount: 4}], + ]) + }) +}) diff --git a/src/lib/api/feed-manip.ts b/src/lib/api/feed-manip.ts index b91613f4e7..167873c6b5 100644 --- a/src/lib/api/feed-manip.ts +++ b/src/lib/api/feed-manip.ts @@ -9,7 +9,7 @@ export type FeedPostNumbering = Pick< 'opThreadPostIndex' | 'opThreadPostCount' > -type ValidFeedPostNumbering = Required +export type ValidFeedPostNumbering = Required // AppView adds these fields to feed responses ahead of their feed lexicon. type FeedViewPost = app.bsky.feed.defs.FeedViewPost & FeedPostNumbering @@ -62,7 +62,7 @@ export type FeedTunerFn = ( type FeedSliceItem = { post: app.bsky.feed.defs.PostView record: app.bsky.feed.post.Main - postNumbering: FeedPostNumbering | undefined + postNumbering: ValidFeedPostNumbering | undefined parentAuthor: app.bsky.actor.defs.ProfileViewBasic | undefined isParentBlocked: boolean isParentNotFound: boolean @@ -88,7 +88,7 @@ export class FeedViewPostsSlice { constructor( feedPost: FeedViewPost, - postNumberingByUri: Map, + postNumberingByUri: Map, ) { const {post, reply, reason} = feedPost this.items = [] @@ -286,6 +286,22 @@ export class FeedViewPostsSlice { } } +export function createFeedViewPostsSlices( + feed: FeedViewPost[], +): FeedViewPostsSlice[] { + const postNumberingByUri = new Map() + for (const item of feed) { + const postNumbering = getPostNumbering(item) + if (postNumbering) { + postNumberingByUri.set(item.post.uri, postNumbering) + } + } + + return feed + .map(item => new FeedViewPostsSlice(item, postNumberingByUri)) + .filter(slice => slice.items.length > 0 || slice.isFallbackMarker) +} + export class FeedTuner { seenKeys: Set = new Set() seenUris: Set = new Set() @@ -299,17 +315,7 @@ export class FeedTuner { dryRun: false, }, ): FeedViewPostsSlice[] { - const postNumberingByUri = new Map() - for (const item of feed) { - const postNumbering = getPostNumbering(item) - if (postNumbering) { - postNumberingByUri.set(item.post.uri, postNumbering) - } - } - - let slices: FeedViewPostsSlice[] = feed - .map(item => new FeedViewPostsSlice(item, postNumberingByUri)) - .filter(s => s.items.length > 0 || s.isFallbackMarker) + let slices = createFeedViewPostsSlices(feed) // run the custom tuners for (const tunerFn of this.tunerFns) { diff --git a/src/state/queries/explore-feed-previews.tsx b/src/state/queries/explore-feed-previews.tsx index db0e1fa374..f6d0fd0311 100644 --- a/src/state/queries/explore-feed-previews.tsx +++ b/src/state/queries/explore-feed-previews.tsx @@ -11,7 +11,11 @@ import { import {CustomFeedAPI} from '#/lib/api/feed/custom' import {aggregateUserInterests} from '#/lib/api/feed/utils' -import {FeedTuner} from '#/lib/api/feed-manip' +import { + createFeedViewPostsSlices, + FeedTuner, + type ValidFeedPostNumbering, +} from '#/lib/api/feed-manip' import {cleanError} from '#/lib/strings/errors' import {useModerationOpts} from '#/state/preferences/moderation-opts' import { @@ -403,6 +407,35 @@ export function* findAllPostsInQueryData( } } +export function findPostNumberingInQueryData( + queryClient: QueryClient, + uri: string, +): ValidFeedPostNumbering | undefined { + const atUri = new AtUri(uri) + const queryDatas = queryClient.getQueriesData< + InfiniteData<{ + feed: app.bsky.feed.defs.GeneratorView + posts: app.bsky.feed.defs.FeedViewPost[] + }> + >({ + queryKey: [RQKEY_ROOT], + }) + + for (const [_queryKey, queryData] of queryDatas) { + if (!queryData?.pages) continue + + for (const page of queryData.pages) { + for (const slice of createFeedViewPostsSlices(page.posts)) { + for (const item of slice.items) { + if (item.postNumbering && didOrHandleUriMatches(atUri, item.post)) { + return item.postNumbering + } + } + } + } + } +} + export function* findAllProfilesInQueryData( queryClient: QueryClient, did: string, diff --git a/src/state/queries/post-feed.ts b/src/state/queries/post-feed.ts index aff1db34e9..402d8bdb18 100644 --- a/src/state/queries/post-feed.ts +++ b/src/state/queries/post-feed.ts @@ -26,9 +26,11 @@ import {PostListFeedAPI} from '#/lib/api/feed/posts' import {type FeedAPI, type ReasonFeedSource} from '#/lib/api/feed/types' import {aggregateUserInterests} from '#/lib/api/feed/utils' import { + createFeedViewPostsSlices, type FeedPostNumbering, FeedTuner, type FeedTunerFn, + type ValidFeedPostNumbering, } from '#/lib/api/feed-manip' import {DISCOVER_FEED_URI} from '#/lib/constants' import {logger} from '#/logger' @@ -527,6 +529,32 @@ export function* findAllPostsInQueryData( } } +export function findPostNumberingInQueryData( + queryClient: QueryClient, + uri: string, +): ValidFeedPostNumbering | undefined { + const atUri = new AtUri(uri) + const queryDatas = queryClient.getQueriesData< + InfiniteData + >({ + queryKey: [RQKEY_ROOT], + }) + + for (const [_queryKey, queryData] of queryDatas) { + if (!queryData?.pages) continue + + for (const page of queryData.pages) { + for (const slice of createFeedViewPostsSlices(page.feed)) { + for (const item of slice.items) { + if (item.postNumbering && didOrHandleUriMatches(atUri, item.post)) { + return item.postNumbering + } + } + } + } + } +} + export function* findAllProfilesInQueryData( queryClient: QueryClient, did: string, diff --git a/src/state/queries/usePostThread/queryCache.test.ts b/src/state/queries/usePostThread/queryCache.test.ts new file mode 100644 index 0000000000..256abe6cca --- /dev/null +++ b/src/state/queries/usePostThread/queryCache.test.ts @@ -0,0 +1,117 @@ +import {QueryClient} from '@tanstack/react-query' + +import {findAllPostsInQueryData as findAllPostsInBookmarksQueryData} from '#/state/queries/bookmarks/useBookmarksQuery' +import { + findAllPostsInQueryData as findAllPostsInExploreFeedPreviewsQueryData, + findPostNumberingInQueryData as findPostNumberingInExploreFeedPreviewsQueryData, +} from '#/state/queries/explore-feed-previews' +import {findAllPostsInQueryData as findAllPostsInNotifsQueryData} from '#/state/queries/notifications/feed' +import { + findAllPostsInQueryData as findAllPostsInFeedQueryData, + findPostNumberingInQueryData as findPostNumberingInFeedQueryData, +} from '#/state/queries/post-feed' +import {findAllPostsInQueryData as findAllPostsInQuoteQueryData} from '#/state/queries/post-quotes' +import {findAllPostsInQueryData as findAllPostsInSearchQueryData} from '#/state/queries/search-posts-v2' +import {type app} from '#/lexicons' +import {getThreadPlaceholder} from './queryCache' + +jest.mock('#/state/cache/post-shadow', () => ({ + dangerousGetPostShadow: jest.fn(), + updatePostShadow: jest.fn(), +})) +jest.mock('#/state/queries/bookmarks/useBookmarksQuery', () => ({ + findAllPostsInQueryData: jest.fn(), +})) +jest.mock('#/state/queries/explore-feed-previews', () => ({ + findAllPostsInQueryData: jest.fn(), + findPostNumberingInQueryData: jest.fn(), +})) +jest.mock('#/state/queries/notifications/feed', () => ({ + findAllPostsInQueryData: jest.fn(), +})) +jest.mock('#/state/queries/post-feed', () => ({ + findAllPostsInQueryData: jest.fn(), + findPostNumberingInQueryData: jest.fn(), +})) +jest.mock('#/state/queries/post-quotes', () => ({ + findAllPostsInQueryData: jest.fn(), +})) +jest.mock('#/state/queries/search-posts-v2', () => ({ + findAllPostsInQueryData: jest.fn(), +})) +jest.mock('#/state/queries/usePostThread', () => ({ + usePostThreadContext: jest.fn(), +})) + +const finders = [ + findAllPostsInBookmarksQueryData, + findAllPostsInExploreFeedPreviewsQueryData, + findAllPostsInNotifsQueryData, + findAllPostsInFeedQueryData, + findAllPostsInQuoteQueryData, + findAllPostsInSearchQueryData, +] + +function post(uri: string, likeCount: number) { + return { + $type: 'app.bsky.feed.defs#postView', + uri, + likeCount, + } as app.bsky.feed.defs.PostView +} + +describe('getThreadPlaceholder', () => { + const queryClient = new QueryClient() + + beforeEach(() => { + jest.resetAllMocks() + for (const finder of finders) { + jest.mocked(finder).mockImplementation(function* () {}) + } + jest.mocked(findPostNumberingInFeedQueryData).mockReturnValue(undefined) + jest + .mocked(findPostNumberingInExploreFeedPreviewsQueryData) + .mockReturnValue(undefined) + }) + + it('combines feed numbering with the preferred cached post', () => { + const uri = 'at://did:plc:alice/app.bsky.feed.post/1' + const notificationPost = post(uri, 4) + const feedPost = post(uri, 1) + jest.mocked(findPostNumberingInFeedQueryData).mockReturnValue({ + opThreadPostIndex: 2, + opThreadPostCount: 4, + }) + jest.mocked(findAllPostsInNotifsQueryData).mockImplementation(function* () { + yield notificationPost + return undefined + }) + jest.mocked(findAllPostsInFeedQueryData).mockImplementation(function* () { + yield feedPost + return undefined + }) + + const placeholder = getThreadPlaceholder(queryClient, uri) + + expect(placeholder?.value).toMatchObject({ + post: notificationPost, + opThread: true, + opThreadPostIndex: 2, + opThreadPostCount: 4, + }) + }) + + it('keeps non-numbered placeholders out of the OP thread', () => { + const uri = 'at://did:plc:alice/app.bsky.feed.post/1' + jest.mocked(findAllPostsInFeedQueryData).mockImplementation(function* () { + yield post(uri, 1) + return undefined + }) + + const placeholder = getThreadPlaceholder(queryClient, uri) + + expect(placeholder?.value).toMatchObject({opThread: false}) + expect(placeholder?.value).not.toHaveProperty('opThreadPostIndex') + expect(placeholder?.value).not.toHaveProperty('opThreadPostCount') + }) +}) diff --git a/src/state/queries/usePostThread/queryCache.ts b/src/state/queries/usePostThread/queryCache.ts index 42c2a05fc9..4fc497597a 100644 --- a/src/state/queries/usePostThread/queryCache.ts +++ b/src/state/queries/usePostThread/queryCache.ts @@ -3,14 +3,21 @@ import {type $Typed} from '@atproto/lex' import {AtUri} from '@atproto/syntax' import {type QueryClient, useQueryClient} from '@tanstack/react-query' +import {type ValidFeedPostNumbering} from '#/lib/api/feed-manip' import { dangerousGetPostShadow, updatePostShadow, } from '#/state/cache/post-shadow' import {findAllPostsInQueryData as findAllPostsInBookmarksQueryData} from '#/state/queries/bookmarks/useBookmarksQuery' -import {findAllPostsInQueryData as findAllPostsInExploreFeedPreviewsQueryData} from '#/state/queries/explore-feed-previews' +import { + findAllPostsInQueryData as findAllPostsInExploreFeedPreviewsQueryData, + findPostNumberingInQueryData as findPostNumberingInExploreFeedPreviewsQueryData, +} from '#/state/queries/explore-feed-previews' import {findAllPostsInQueryData as findAllPostsInNotifsQueryData} from '#/state/queries/notifications/feed' -import {findAllPostsInQueryData as findAllPostsInFeedQueryData} from '#/state/queries/post-feed' +import { + findAllPostsInQueryData as findAllPostsInFeedQueryData, + findPostNumberingInQueryData as findPostNumberingInFeedQueryData, +} from '#/state/queries/post-feed' import {findAllPostsInQueryData as findAllPostsInQuoteQueryData} from '#/state/queries/post-quotes' import {findAllPostsInQueryData as findAllPostsInSearchQueryData} from '#/state/queries/search-posts-v2' import {usePostThreadContext} from '#/state/queries/usePostThread' @@ -207,8 +214,15 @@ export function getThreadPlaceholder( queryClient: QueryClient, uri: string, ): $Typed | void { + const postNumbering = + findPostNumberingInFeedQueryData(queryClient, uri) ?? + findPostNumberingInExploreFeedPreviewsQueryData(queryClient, uri) let partial - for (let item of getThreadPlaceholderCandidates(queryClient, uri)) { + for (let item of getThreadPlaceholderCandidates( + queryClient, + uri, + postNumbering, + )) { /* * 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 @@ -231,6 +245,7 @@ export function getThreadPlaceholder( export function* getThreadPlaceholderCandidates( queryClient: QueryClient, uri: string, + postNumbering?: ValidFeedPostNumbering, ): Generator< $Typed< Omit & { @@ -243,7 +258,7 @@ export function* getThreadPlaceholderCandidates( * Check post thread queries first */ for (const post of findAllPostsInQueryData(queryClient, uri)) { - yield postViewToThreadPlaceholder(post) + yield postViewToThreadPlaceholder(post, postNumbering) } /* @@ -253,25 +268,25 @@ export function* getThreadPlaceholderCandidates( * avoid a notification->post scroll jump. */ for (let post of findAllPostsInNotifsQueryData(queryClient, uri)) { - yield postViewToThreadPlaceholder(post) + yield postViewToThreadPlaceholder(post, postNumbering) } for (let post of findAllPostsInFeedQueryData(queryClient, uri)) { - yield postViewToThreadPlaceholder(post) + yield postViewToThreadPlaceholder(post, postNumbering) } for (let post of findAllPostsInQuoteQueryData(queryClient, uri)) { - yield postViewToThreadPlaceholder(post) + yield postViewToThreadPlaceholder(post, postNumbering) } for (let post of findAllPostsInSearchQueryData(queryClient, uri)) { - yield postViewToThreadPlaceholder(post) + yield postViewToThreadPlaceholder(post, postNumbering) } for (let post of findAllPostsInBookmarksQueryData(queryClient, uri)) { - yield postViewToThreadPlaceholder(post) + yield postViewToThreadPlaceholder(post, postNumbering) } for (let post of findAllPostsInExploreFeedPreviewsQueryData( queryClient, uri, )) { - yield postViewToThreadPlaceholder(post) + yield postViewToThreadPlaceholder(post, postNumbering) } } diff --git a/src/state/queries/usePostThread/views.ts b/src/state/queries/usePostThread/views.ts index a3843878df..270cb39d6c 100644 --- a/src/state/queries/usePostThread/views.ts +++ b/src/state/queries/usePostThread/views.ts @@ -2,6 +2,7 @@ import {type $Typed} from '@atproto/lex' import {AtUri} from '@atproto/syntax' import {moderatePost, type ModerationOpts} from '@bsky/sdk/moderation' +import {type ValidFeedPostNumbering} from '#/lib/api/feed-manip' import {makeProfileLink} from '#/lib/routes/links' import { type ApiThreadItem, @@ -156,6 +157,7 @@ export function skeleton({ export function postViewToThreadPlaceholder( post: app.bsky.feed.defs.PostView, + postNumbering?: ValidFeedPostNumbering, ): $Typed< Omit & { value: $Typed @@ -168,7 +170,8 @@ export function postViewToThreadPlaceholder( value: { $type: 'app.bsky.unspecced.defs#threadItemPost', post, - opThread: false, + opThread: !!postNumbering, + ...postNumbering, moreParents: false, moreReplies: 0, hiddenByThreadgate: false,