Add OP thread numbering to feed posts (#11472)

This commit is contained in:
DS Boyce
2026-08-25 11:48:27 -07:00
committed by GitHub
parent 7750882fd0
commit f968c9e721
7 changed files with 122 additions and 13 deletions
+69 -3
View File
@@ -4,7 +4,54 @@ import {isPostInLanguage} from '../../locale/helpers'
import {FALLBACK_MARKER_POST} from './feed/home' import {FALLBACK_MARKER_POST} from './feed/home'
import {type ReasonFeedSource} from './feed/types' 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<FeedPostNumbering>
// 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 = ( export type FeedTunerFn = (
tuner: FeedTuner, tuner: FeedTuner,
@@ -15,6 +62,7 @@ export type FeedTunerFn = (
type FeedSliceItem = { type FeedSliceItem = {
post: app.bsky.feed.defs.PostView post: app.bsky.feed.defs.PostView
record: app.bsky.feed.post.Main record: app.bsky.feed.post.Main
postNumbering: FeedPostNumbering | undefined
parentAuthor: app.bsky.actor.defs.ProfileViewBasic | undefined parentAuthor: app.bsky.actor.defs.ProfileViewBasic | undefined
isParentBlocked: boolean isParentBlocked: boolean
isParentNotFound: boolean isParentNotFound: boolean
@@ -38,7 +86,10 @@ export class FeedViewPostsSlice {
rootUri: string rootUri: string
feedPostUri: string feedPostUri: string
constructor(feedPost: FeedViewPost) { constructor(
feedPost: FeedViewPost,
postNumberingByUri: Map<string, FeedPostNumbering>,
) {
const {post, reply, reason} = feedPost const {post, reply, reason} = feedPost
this.items = [] this.items = []
this.isIncompleteThread = false this.isIncompleteThread = false
@@ -80,6 +131,7 @@ export class FeedViewPostsSlice {
this.items.push({ this.items.push({
post, post,
record: post.record, record: post.record,
postNumbering: postNumberingByUri.get(post.uri),
parentAuthor, parentAuthor,
isParentBlocked, isParentBlocked,
isParentNotFound, isParentNotFound,
@@ -128,6 +180,9 @@ export class FeedViewPostsSlice {
this.items.unshift({ this.items.unshift({
post: parent, post: parent,
record: parent.record, record: parent.record,
postNumbering:
postNumberingByUri.get(parent.uri) ??
inferPostNumbering(feedPost, 'parent'),
parentAuthor: grandparentAuthor, parentAuthor: grandparentAuthor,
isParentBlocked: isGrandparentBlocked, isParentBlocked: isGrandparentBlocked,
isParentNotFound: isGrandparentNotFound, isParentNotFound: isGrandparentNotFound,
@@ -151,6 +206,9 @@ export class FeedViewPostsSlice {
this.items.unshift({ this.items.unshift({
post: root, post: root,
record: root.record, record: root.record,
postNumbering:
postNumberingByUri.get(root.uri) ??
inferPostNumbering(feedPost, 'root'),
isParentBlocked: false, isParentBlocked: false,
isParentNotFound: false, isParentNotFound: false,
parentAuthor: undefined, parentAuthor: undefined,
@@ -241,8 +299,16 @@ export class FeedTuner {
dryRun: false, dryRun: false,
}, },
): FeedViewPostsSlice[] { ): 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 let slices: FeedViewPostsSlice[] = feed
.map(item => new FeedViewPostsSlice(item)) .map(item => new FeedViewPostsSlice(item, postNumberingByUri))
.filter(s => s.items.length > 0 || s.isFallbackMarker) .filter(s => s.items.length > 0 || s.isFallbackMarker)
// run the custom tuners // run the custom tuners
@@ -11,12 +11,17 @@ import {type app} from '#/lexicons'
*/ */
export const POST_NUMBER_INLINE_OFFSET = 6 export const POST_NUMBER_INLINE_OFFSET = 6
export type ThreadItemPostNumbering = Pick<
app.bsky.unspecced.defs.ThreadItemPost,
'opThreadPostIndex' | 'opThreadPostCount'
>
export function useHasThreadItemPostNumber( export function useHasThreadItemPostNumber(
value: app.bsky.unspecced.defs.ThreadItemPost, value: ThreadItemPostNumbering | undefined,
) { ) {
const ax = useAnalytics() const ax = useAnalytics()
const index = value.opThreadPostIndex const index = value?.opThreadPostIndex
const count = value.opThreadPostCount const count = value?.opThreadPostCount
return ( return (
ax.features.enabled(ax.features.CanonicalPostNumberingEnable) && ax.features.enabled(ax.features.CanonicalPostNumberingEnable) &&
@@ -32,14 +37,14 @@ export function ThreadItemPostNumber({
value, value,
inline = true, inline = true,
}: { }: {
value: app.bsky.unspecced.defs.ThreadItemPost value: ThreadItemPostNumbering | undefined
inline?: boolean inline?: boolean
}) { }) {
const t = useTheme() const t = useTheme()
const {t: l} = useLingui() const {t: l} = useLingui()
const shouldRender = useHasThreadItemPostNumber(value) const shouldRender = useHasThreadItemPostNumber(value)
const index = value.opThreadPostIndex const index = value?.opThreadPostIndex
const count = value.opThreadPostCount const count = value?.opThreadPostCount
if (!shouldRender) { if (!shouldRender) {
return null return null
@@ -59,7 +64,8 @@ export function ThreadItemPostNumber({
}, },
inline inline
? platform({ ? platform({
native: {transform: [{translateY: POST_NUMBER_INLINE_OFFSET}]}, android: {transform: [{translateY: POST_NUMBER_INLINE_OFFSET}]},
ios: {transform: [{translateY: a.py_2xs.paddingBottom}]},
web: { web: {
top: -2, top: -2,
marginBottom: -2, marginBottom: -2,
+1
View File
@@ -984,6 +984,7 @@ export function Explore({
<PostFeedItem <PostFeedItem
post={subItem.post} post={subItem.post}
record={subItem.record} record={subItem.record}
postNumbering={subItem.postNumbering}
reason={indexInSlice === 0 ? slice.reason : undefined} reason={indexInSlice === 0 ? slice.reason : undefined}
feedContext={slice.feedContext} feedContext={slice.feedContext}
reqId={slice.reqId} reqId={slice.reqId}
@@ -233,6 +233,7 @@ export function useFeedPreviews(
uri: subItem.post.uri, uri: subItem.post.uri,
post: subItem.post, post: subItem.post,
record: subItem.record, record: subItem.record,
postNumbering: subItem.postNumbering,
moderation: moderations[i], moderation: moderations[i],
parentAuthor: subItem.parentAuthor, parentAuthor: subItem.parentAuthor,
isParentBlocked: subItem.isParentBlocked, isParentBlocked: subItem.isParentBlocked,
+7 -1
View File
@@ -25,7 +25,11 @@ import {MergeFeedAPI} from '#/lib/api/feed/merge'
import {PostListFeedAPI} from '#/lib/api/feed/posts' import {PostListFeedAPI} from '#/lib/api/feed/posts'
import {type FeedAPI, type ReasonFeedSource} from '#/lib/api/feed/types' import {type FeedAPI, type ReasonFeedSource} from '#/lib/api/feed/types'
import {aggregateUserInterests} from '#/lib/api/feed/utils' import {aggregateUserInterests} from '#/lib/api/feed/utils'
import {FeedTuner, type FeedTunerFn} from '#/lib/api/feed-manip' import {
type FeedPostNumbering,
FeedTuner,
type FeedTunerFn,
} from '#/lib/api/feed-manip'
import {DISCOVER_FEED_URI} from '#/lib/constants' import {DISCOVER_FEED_URI} from '#/lib/constants'
import {logger} from '#/logger' import {logger} from '#/logger'
import {STALE} from '#/state/queries' import {STALE} from '#/state/queries'
@@ -81,6 +85,7 @@ export interface FeedPostSliceItem {
uri: AtUriString uri: AtUriString
post: app.bsky.feed.defs.PostView post: app.bsky.feed.defs.PostView
record: app.bsky.feed.post.Main record: app.bsky.feed.post.Main
postNumbering?: FeedPostNumbering
moderation: ModerationDecision moderation: ModerationDecision
parentAuthor?: app.bsky.actor.defs.ProfileViewBasic parentAuthor?: app.bsky.actor.defs.ProfileViewBasic
isParentBlocked?: boolean isParentBlocked?: boolean
@@ -340,6 +345,7 @@ export function usePostFeedQuery(
uri: item.post.uri, uri: item.post.uri,
post: item.post, post: item.post,
record: item.record, record: item.record,
postNumbering: item.postNumbering,
moderation: moderations[i], moderation: moderations[i],
parentAuthor: item.parentAuthor, parentAuthor: item.parentAuthor,
isParentBlocked: item.isParentBlocked, isParentBlocked: item.isParentBlocked,
+1
View File
@@ -872,6 +872,7 @@ let PostFeed = ({
<PostFeedItem <PostFeedItem
post={item.post} post={item.post}
record={item.record} record={item.record}
postNumbering={item.postNumbering}
reason={indexInSlice === 0 ? slice.reason : undefined} reason={indexInSlice === 0 ? slice.reason : undefined}
feedContext={slice.feedContext} feedContext={slice.feedContext}
reqId={slice.reqId} reqId={slice.reqId}
+30 -2
View File
@@ -6,6 +6,7 @@ import {RichText as RichTextAPI} from '@bsky/sdk/richtext'
import {useQueryClient} from '@tanstack/react-query' import {useQueryClient} from '@tanstack/react-query'
import {type ReasonFeedSource} from '#/lib/api/feed/types' import {type ReasonFeedSource} from '#/lib/api/feed/types'
import {type FeedPostNumbering} from '#/lib/api/feed-manip'
import {MAX_POST_LINES} from '#/lib/constants' import {MAX_POST_LINES} from '#/lib/constants'
import {useOpenComposer} from '#/lib/hooks/useOpenComposer' import {useOpenComposer} from '#/lib/hooks/useOpenComposer'
import {usePalette} from '#/lib/hooks/usePalette' import {usePalette} from '#/lib/hooks/usePalette'
@@ -27,6 +28,11 @@ import {
import {Link} from '#/view/com/util/Link' import {Link} from '#/view/com/util/Link'
import {PostMeta} from '#/view/com/util/PostMeta' import {PostMeta} from '#/view/com/util/PostMeta'
import {PreviewableUserAvatar} from '#/view/com/util/UserAvatar' import {PreviewableUserAvatar} from '#/view/com/util/UserAvatar'
import {
POST_NUMBER_INLINE_OFFSET,
ThreadItemPostNumber,
useHasThreadItemPostNumber,
} from '#/screens/PostThread/components/ThreadItemPostNumber'
import {atoms as a, select, useTheme} from '#/alf' import {atoms as a, select, useTheme} from '#/alf'
import { import {
GalleryBleed, GalleryBleed,
@@ -53,6 +59,7 @@ import {PostFeedReason} from './PostFeedReason'
interface FeedItemProps { interface FeedItemProps {
record: app.bsky.feed.post.Main record: app.bsky.feed.post.Main
postNumbering?: FeedPostNumbering
reason: reason:
| app.bsky.feed.defs.ReasonRepost | app.bsky.feed.defs.ReasonRepost
| app.bsky.feed.defs.ReasonPin | app.bsky.feed.defs.ReasonPin
@@ -75,6 +82,7 @@ interface FeedItemProps {
export function PostFeedItem({ export function PostFeedItem({
post, post,
record, record,
postNumbering,
reason, reason,
feedContext, feedContext,
reqId, reqId,
@@ -112,6 +120,7 @@ export function PostFeedItem({
<FeedItemInner <FeedItemInner
post={postShadowed} post={postShadowed}
record={record} record={record}
postNumbering={postNumbering}
reason={reason} reason={reason}
feedContext={feedContext} feedContext={feedContext}
reqId={reqId} reqId={reqId}
@@ -137,6 +146,7 @@ export function PostFeedItem({
let FeedItemInner = ({ let FeedItemInner = ({
post, post,
record, record,
postNumbering,
reason, reason,
feedContext, feedContext,
reqId, reqId,
@@ -426,6 +436,7 @@ let FeedItemInner = ({
<PostContent <PostContent
moderation={moderation} moderation={moderation}
richText={richText} richText={richText}
postNumbering={postNumbering}
postEmbed={post.embed} postEmbed={post.embed}
postAuthor={post.author} postAuthor={post.author}
onOpenEmbed={onOpenEmbed} onOpenEmbed={onOpenEmbed}
@@ -457,6 +468,7 @@ FeedItemInner = memo(FeedItemInner)
let PostContent = ({ let PostContent = ({
post, post,
postNumbering,
moderation, moderation,
richText, richText,
postEmbed, postEmbed,
@@ -471,12 +483,14 @@ let PostContent = ({
postAuthor: app.bsky.feed.defs.PostView['author'] postAuthor: app.bsky.feed.defs.PostView['author']
onOpenEmbed: () => void onOpenEmbed: () => void
post: app.bsky.feed.defs.PostView post: app.bsky.feed.defs.PostView
postNumbering: FeedPostNumbering | undefined
additionalPostAlerts?: AppModerationCause[] additionalPostAlerts?: AppModerationCause[]
feedDescriptor?: string feedDescriptor?: string
}): React.ReactNode => { }): React.ReactNode => {
const [limitLines, setLimitLines] = useState( const [limitLines, setLimitLines] = useState(
() => countLines(richText.text) >= MAX_POST_LINES, () => countLines(richText.text) >= MAX_POST_LINES,
) )
const showPostNumber = useHasThreadItemPostNumber(postNumbering)
const record = useMemo<app.bsky.feed.post.Main | undefined>( const record = useMemo<app.bsky.feed.post.Main | undefined>(
() => () =>
@@ -510,12 +524,26 @@ let PostContent = ({
style={[a.flex_1, a.text_md]} style={[a.flex_1, a.text_md]}
authorHandle={postAuthor.handle} authorHandle={postAuthor.handle}
shouldProxyLinks={true} shouldProxyLinks={true}
suffixOffset={POST_NUMBER_INLINE_OFFSET}
suffix={
!limitLines && showPostNumber ? (
<ThreadItemPostNumber value={postNumbering} />
) : undefined
}
/> />
{limitLines && ( {limitLines && (
<ShowMoreTextButton style={[a.text_md]} onPress={onPressShowMore} /> <View style={[a.flex_row, a.align_center, a.gap_xs]}>
<ShowMoreTextButton
style={[a.text_md]}
onPress={onPressShowMore}
/>
<ThreadItemPostNumber inline={false} value={postNumbering} />
</View>
)} )}
</View> </View>
) : undefined} ) : (
<ThreadItemPostNumber inline={false} value={postNumbering} />
)}
{record && <TranslatedPost hideTranslateLink post={post} />} {record && <TranslatedPost hideTranslateLink post={post} />}
{postEmbed ? ( {postEmbed ? (
<View <View