From f968c9e721dcb09afe40ac8ba5cefa8b25755493 Mon Sep 17 00:00:00 2001 From: DS Boyce <260543580+ds-boyce@users.noreply.github.com> Date: Tue, 25 Aug 2026 11:48:27 -0700 Subject: [PATCH] Add OP thread numbering to feed posts (#11472) --- src/lib/api/feed-manip.ts | 72 ++++++++++++++++++- .../components/ThreadItemPostNumber.tsx | 20 ++++-- src/screens/Search/Explore.tsx | 1 + src/state/queries/explore-feed-previews.tsx | 1 + src/state/queries/post-feed.ts | 8 ++- src/view/com/posts/PostFeed.tsx | 1 + src/view/com/posts/PostFeedItem.tsx | 32 ++++++++- 7 files changed, 122 insertions(+), 13 deletions(-) diff --git a/src/lib/api/feed-manip.ts b/src/lib/api/feed-manip.ts index cd99fc9d57..b91613f4e7 100644 --- a/src/lib/api/feed-manip.ts +++ b/src/lib/api/feed-manip.ts @@ -4,7 +4,54 @@ import {isPostInLanguage} from '../../locale/helpers' import {FALLBACK_MARKER_POST} from './feed/home' import {type ReasonFeedSource} from './feed/types' -type FeedViewPost = app.bsky.feed.defs.FeedViewPost +export type FeedPostNumbering = Pick< + app.bsky.unspecced.defs.ThreadItemPost, + 'opThreadPostIndex' | 'opThreadPostCount' +> + +type ValidFeedPostNumbering = Required + +// AppView adds these fields to feed responses ahead of their feed lexicon. +type FeedViewPost = app.bsky.feed.defs.FeedViewPost & FeedPostNumbering + +function getPostNumbering( + value: FeedPostNumbering, +): ValidFeedPostNumbering | undefined { + const {opThreadPostIndex: index, opThreadPostCount: count} = value + + if ( + index === undefined || + count === undefined || + index < 1 || + count < 1 || + index > count + ) { + return undefined + } + + return { + opThreadPostIndex: index, + opThreadPostCount: count, + } +} + +function inferPostNumbering( + feedPost: FeedViewPost, + position: 'parent' | 'root', +): ValidFeedPostNumbering | undefined { + const postNumbering = getPostNumbering(feedPost) + if (!postNumbering) { + return undefined + } + + // Feed responses number only the selected post, so derive the hydrated + // context that the feed renders alongside it. + return getPostNumbering({ + opThreadPostIndex: + position === 'root' ? 1 : postNumbering.opThreadPostIndex - 1, + opThreadPostCount: postNumbering.opThreadPostCount, + }) +} export type FeedTunerFn = ( tuner: FeedTuner, @@ -15,6 +62,7 @@ export type FeedTunerFn = ( type FeedSliceItem = { post: app.bsky.feed.defs.PostView record: app.bsky.feed.post.Main + postNumbering: FeedPostNumbering | undefined parentAuthor: app.bsky.actor.defs.ProfileViewBasic | undefined isParentBlocked: boolean isParentNotFound: boolean @@ -38,7 +86,10 @@ export class FeedViewPostsSlice { rootUri: string feedPostUri: string - constructor(feedPost: FeedViewPost) { + constructor( + feedPost: FeedViewPost, + postNumberingByUri: Map, + ) { const {post, reply, reason} = feedPost this.items = [] this.isIncompleteThread = false @@ -80,6 +131,7 @@ export class FeedViewPostsSlice { this.items.push({ post, record: post.record, + postNumbering: postNumberingByUri.get(post.uri), parentAuthor, isParentBlocked, isParentNotFound, @@ -128,6 +180,9 @@ export class FeedViewPostsSlice { this.items.unshift({ post: parent, record: parent.record, + postNumbering: + postNumberingByUri.get(parent.uri) ?? + inferPostNumbering(feedPost, 'parent'), parentAuthor: grandparentAuthor, isParentBlocked: isGrandparentBlocked, isParentNotFound: isGrandparentNotFound, @@ -151,6 +206,9 @@ export class FeedViewPostsSlice { this.items.unshift({ post: root, record: root.record, + postNumbering: + postNumberingByUri.get(root.uri) ?? + inferPostNumbering(feedPost, 'root'), isParentBlocked: false, isParentNotFound: false, parentAuthor: undefined, @@ -241,8 +299,16 @@ 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)) + .map(item => new FeedViewPostsSlice(item, postNumberingByUri)) .filter(s => s.items.length > 0 || s.isFallbackMarker) // run the custom tuners diff --git a/src/screens/PostThread/components/ThreadItemPostNumber.tsx b/src/screens/PostThread/components/ThreadItemPostNumber.tsx index 04db513a2b..c07aa98e5d 100644 --- a/src/screens/PostThread/components/ThreadItemPostNumber.tsx +++ b/src/screens/PostThread/components/ThreadItemPostNumber.tsx @@ -11,12 +11,17 @@ import {type app} from '#/lexicons' */ export const POST_NUMBER_INLINE_OFFSET = 6 +export type ThreadItemPostNumbering = Pick< + app.bsky.unspecced.defs.ThreadItemPost, + 'opThreadPostIndex' | 'opThreadPostCount' +> + export function useHasThreadItemPostNumber( - value: app.bsky.unspecced.defs.ThreadItemPost, + value: ThreadItemPostNumbering | undefined, ) { const ax = useAnalytics() - const index = value.opThreadPostIndex - const count = value.opThreadPostCount + const index = value?.opThreadPostIndex + const count = value?.opThreadPostCount return ( ax.features.enabled(ax.features.CanonicalPostNumberingEnable) && @@ -32,14 +37,14 @@ export function ThreadItemPostNumber({ value, inline = true, }: { - value: app.bsky.unspecced.defs.ThreadItemPost + value: ThreadItemPostNumbering | undefined inline?: boolean }) { const t = useTheme() const {t: l} = useLingui() const shouldRender = useHasThreadItemPostNumber(value) - const index = value.opThreadPostIndex - const count = value.opThreadPostCount + const index = value?.opThreadPostIndex + const count = value?.opThreadPostCount if (!shouldRender) { return null @@ -59,7 +64,8 @@ export function ThreadItemPostNumber({ }, inline ? platform({ - native: {transform: [{translateY: POST_NUMBER_INLINE_OFFSET}]}, + android: {transform: [{translateY: POST_NUMBER_INLINE_OFFSET}]}, + ios: {transform: [{translateY: a.py_2xs.paddingBottom}]}, web: { top: -2, marginBottom: -2, diff --git a/src/screens/Search/Explore.tsx b/src/screens/Search/Explore.tsx index 87e2301e1d..210c19265a 100644 --- a/src/screens/Search/Explore.tsx +++ b/src/screens/Search/Explore.tsx @@ -984,6 +984,7 @@ export function Explore({ void post: app.bsky.feed.defs.PostView + postNumbering: FeedPostNumbering | undefined additionalPostAlerts?: AppModerationCause[] feedDescriptor?: string }): React.ReactNode => { const [limitLines, setLimitLines] = useState( () => countLines(richText.text) >= MAX_POST_LINES, ) + const showPostNumber = useHasThreadItemPostNumber(postNumbering) const record = useMemo( () => @@ -510,12 +524,26 @@ let PostContent = ({ style={[a.flex_1, a.text_md]} authorHandle={postAuthor.handle} shouldProxyLinks={true} + suffixOffset={POST_NUMBER_INLINE_OFFSET} + suffix={ + !limitLines && showPostNumber ? ( + + ) : undefined + } /> {limitLines && ( - + + + + )} - ) : undefined} + ) : ( + + )} {record && } {postEmbed ? (