Preserve thread numbering in placeholders (#11616)
This commit is contained in:
@@ -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
@@ -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) {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user