Preserve thread numbering in placeholders (#11616)

This commit is contained in:
DS Boyce
2026-09-02 08:53:49 -07:00
committed by GitHub
parent 83cd5033b2
commit ae971db765
7 changed files with 282 additions and 26 deletions
+54
View File
@@ -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}],
])
})
})
+20 -14
View File
@@ -9,7 +9,7 @@ export type FeedPostNumbering = Pick<
'opThreadPostIndex' | 'opThreadPostCount'
>
type ValidFeedPostNumbering = Required<FeedPostNumbering>
export type ValidFeedPostNumbering = Required<FeedPostNumbering>
// 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<string, FeedPostNumbering>,
postNumberingByUri: Map<string, ValidFeedPostNumbering>,
) {
const {post, reply, reason} = feedPost
this.items = []
@@ -286,6 +286,22 @@ export class FeedViewPostsSlice {
}
}
export function createFeedViewPostsSlices(
feed: FeedViewPost[],
): FeedViewPostsSlice[] {
const postNumberingByUri = new Map<string, ValidFeedPostNumbering>()
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<string> = new Set()
seenUris: Set<string> = new Set()
@@ -299,17 +315,7 @@ export class FeedTuner {
dryRun: false,
},
): FeedViewPostsSlice[] {
const postNumberingByUri = new Map<string, FeedPostNumbering>()
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) {
+34 -1
View File
@@ -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,
+28
View File
@@ -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<FeedPageUnselected>
>({
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,
@@ -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')
})
})
+25 -10
View File
@@ -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<app.bsky.unspecced.getPostThreadV2.ThreadItem> | 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<app.bsky.unspecced.getPostThreadV2.ThreadItem, 'value'> & {
@@ -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)
}
}
+4 -1
View File
@@ -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<app.bsky.unspecced.getPostThreadV2.ThreadItem, 'value'> & {
value: $Typed<app.bsky.unspecced.defs.ThreadItemPost>
@@ -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,