Add canonical OP thread numbering (#11184)

Co-authored-by: DS Boyce <260543580+ds-boyce@users.noreply.github.com>
This commit is contained in:
Eric Bailey
2026-08-05 12:20:43 -05:00
committed by GitHub
parent 8cb6c7babe
commit b79908ecea
8 changed files with 164 additions and 17 deletions
+1 -1
View File
@@ -108,7 +108,7 @@
"@bsky.app/expo-scroll-edge-effect": "^0.1.9", "@bsky.app/expo-scroll-edge-effect": "^0.1.9",
"@bsky.app/expo-translate-text": "^0.2.9", "@bsky.app/expo-translate-text": "^0.2.9",
"@bsky.app/peek-menu": "^0.3.2", "@bsky.app/peek-menu": "^0.3.2",
"@bsky.app/react-native-uitextview": "^2.5.0", "@bsky.app/react-native-uitextview": "^2.6.0",
"@bsky.app/sift": "^0.3.9", "@bsky.app/sift": "^0.3.9",
"@bsky.app/tapper": "^0.6.1", "@bsky.app/tapper": "^0.6.1",
"@bsky.app/video": "0.3.6", "@bsky.app/video": "0.3.6",
+5 -5
View File
@@ -278,8 +278,8 @@ importers:
specifier: ^0.3.2 specifier: ^0.3.2
version: 0.3.2(expo@54.0.35(@babel/core@7.29.0)(react-native-webview@13.15.0(react-native@0.81.5(patch_hash=91fd85363059530dea649a5ca6012b933f79c8491737fbfdc685abc727e48403)(@babel/core@7.29.0)(@react-native/metro-config@0.86.0(@babel/core@7.29.0))(@types/react@19.1.17)(react@19.1.0))(react@19.1.0))(react-native@0.81.5(patch_hash=91fd85363059530dea649a5ca6012b933f79c8491737fbfdc685abc727e48403)(@babel/core@7.29.0)(@react-native/metro-config@0.86.0(@babel/core@7.29.0))(@types/react@19.1.17)(react@19.1.0))(react@19.1.0))(react-native@0.81.5(patch_hash=91fd85363059530dea649a5ca6012b933f79c8491737fbfdc685abc727e48403)(@babel/core@7.29.0)(@react-native/metro-config@0.86.0(@babel/core@7.29.0))(@types/react@19.1.17)(react@19.1.0))(react@19.1.0) version: 0.3.2(expo@54.0.35(@babel/core@7.29.0)(react-native-webview@13.15.0(react-native@0.81.5(patch_hash=91fd85363059530dea649a5ca6012b933f79c8491737fbfdc685abc727e48403)(@babel/core@7.29.0)(@react-native/metro-config@0.86.0(@babel/core@7.29.0))(@types/react@19.1.17)(react@19.1.0))(react@19.1.0))(react-native@0.81.5(patch_hash=91fd85363059530dea649a5ca6012b933f79c8491737fbfdc685abc727e48403)(@babel/core@7.29.0)(@react-native/metro-config@0.86.0(@babel/core@7.29.0))(@types/react@19.1.17)(react@19.1.0))(react@19.1.0))(react-native@0.81.5(patch_hash=91fd85363059530dea649a5ca6012b933f79c8491737fbfdc685abc727e48403)(@babel/core@7.29.0)(@react-native/metro-config@0.86.0(@babel/core@7.29.0))(@types/react@19.1.17)(react@19.1.0))(react@19.1.0)
'@bsky.app/react-native-uitextview': '@bsky.app/react-native-uitextview':
specifier: ^2.5.0 specifier: ^2.6.0
version: 2.5.0(react-native@0.81.5(patch_hash=91fd85363059530dea649a5ca6012b933f79c8491737fbfdc685abc727e48403)(@babel/core@7.29.0)(@react-native/metro-config@0.86.0(@babel/core@7.29.0))(@types/react@19.1.17)(react@19.1.0))(react@19.1.0) version: 2.6.0(react-native@0.81.5(patch_hash=91fd85363059530dea649a5ca6012b933f79c8491737fbfdc685abc727e48403)(@babel/core@7.29.0)(@react-native/metro-config@0.86.0(@babel/core@7.29.0))(@types/react@19.1.17)(react@19.1.0))(react@19.1.0)
'@bsky.app/sift': '@bsky.app/sift':
specifier: ^0.3.9 specifier: ^0.3.9
version: 0.3.9(expo@54.0.35(@babel/core@7.29.0)(react-native-webview@13.15.0(react-native@0.81.5(patch_hash=91fd85363059530dea649a5ca6012b933f79c8491737fbfdc685abc727e48403)(@babel/core@7.29.0)(@react-native/metro-config@0.86.0(@babel/core@7.29.0))(@types/react@19.1.17)(react@19.1.0))(react@19.1.0))(react-native@0.81.5(patch_hash=91fd85363059530dea649a5ca6012b933f79c8491737fbfdc685abc727e48403)(@babel/core@7.29.0)(@react-native/metro-config@0.86.0(@babel/core@7.29.0))(@types/react@19.1.17)(react@19.1.0))(react@19.1.0))(react-native-safe-area-context@5.6.2(react-native@0.81.5(patch_hash=91fd85363059530dea649a5ca6012b933f79c8491737fbfdc685abc727e48403)(@babel/core@7.29.0)(@react-native/metro-config@0.86.0(@babel/core@7.29.0))(@types/react@19.1.17)(react@19.1.0))(react@19.1.0))(react-native@0.81.5(patch_hash=91fd85363059530dea649a5ca6012b933f79c8491737fbfdc685abc727e48403)(@babel/core@7.29.0)(@react-native/metro-config@0.86.0(@babel/core@7.29.0))(@types/react@19.1.17)(react@19.1.0))(react@19.1.0) version: 0.3.9(expo@54.0.35(@babel/core@7.29.0)(react-native-webview@13.15.0(react-native@0.81.5(patch_hash=91fd85363059530dea649a5ca6012b933f79c8491737fbfdc685abc727e48403)(@babel/core@7.29.0)(@react-native/metro-config@0.86.0(@babel/core@7.29.0))(@types/react@19.1.17)(react@19.1.0))(react@19.1.0))(react-native@0.81.5(patch_hash=91fd85363059530dea649a5ca6012b933f79c8491737fbfdc685abc727e48403)(@babel/core@7.29.0)(@react-native/metro-config@0.86.0(@babel/core@7.29.0))(@types/react@19.1.17)(react@19.1.0))(react@19.1.0))(react-native-safe-area-context@5.6.2(react-native@0.81.5(patch_hash=91fd85363059530dea649a5ca6012b933f79c8491737fbfdc685abc727e48403)(@babel/core@7.29.0)(@react-native/metro-config@0.86.0(@babel/core@7.29.0))(@types/react@19.1.17)(react@19.1.0))(react@19.1.0))(react-native@0.81.5(patch_hash=91fd85363059530dea649a5ca6012b933f79c8491737fbfdc685abc727e48403)(@babel/core@7.29.0)(@react-native/metro-config@0.86.0(@babel/core@7.29.0))(@types/react@19.1.17)(react@19.1.0))(react@19.1.0)
@@ -1676,8 +1676,8 @@ packages:
react: '*' react: '*'
react-native: '*' react-native: '*'
'@bsky.app/react-native-uitextview@2.5.0': '@bsky.app/react-native-uitextview@2.6.0':
resolution: {integrity: sha512-8DyQAJEkr0ZPEb9QMmT3088V70B6xRsY8e/jvi2oq13hCsLEXCw5M1S48xKRnr8263bJxSH0BjK0nfhPS8Myqw==} resolution: {integrity: sha512-d6efoPKixj7ycaQWYSDr4JDB1jzIewWie6UtP4Pylgh+K//aXCNaudbLjHN4WP6RVGBfFqNac13hSh3tRikH7A==}
peerDependencies: peerDependencies:
react: '*' react: '*'
react-native: '>=0.81.5' react-native: '>=0.81.5'
@@ -10420,7 +10420,7 @@ snapshots:
react: 19.1.0 react: 19.1.0
react-native: 0.81.5(patch_hash=91fd85363059530dea649a5ca6012b933f79c8491737fbfdc685abc727e48403)(@babel/core@7.29.0)(@react-native/metro-config@0.86.0(@babel/core@7.29.0))(@types/react@19.1.17)(react@19.1.0) react-native: 0.81.5(patch_hash=91fd85363059530dea649a5ca6012b933f79c8491737fbfdc685abc727e48403)(@babel/core@7.29.0)(@react-native/metro-config@0.86.0(@babel/core@7.29.0))(@types/react@19.1.17)(react@19.1.0)
'@bsky.app/react-native-uitextview@2.5.0(react-native@0.81.5(patch_hash=91fd85363059530dea649a5ca6012b933f79c8491737fbfdc685abc727e48403)(@babel/core@7.29.0)(@react-native/metro-config@0.86.0(@babel/core@7.29.0))(@types/react@19.1.17)(react@19.1.0))(react@19.1.0)': '@bsky.app/react-native-uitextview@2.6.0(react-native@0.81.5(patch_hash=91fd85363059530dea649a5ca6012b933f79c8491737fbfdc685abc727e48403)(@babel/core@7.29.0)(@react-native/metro-config@0.86.0(@babel/core@7.29.0))(@types/react@19.1.17)(react@19.1.0))(react@19.1.0)':
dependencies: dependencies:
react: 19.1.0 react: 19.1.0
react-native: 0.81.5(patch_hash=91fd85363059530dea649a5ca6012b933f79c8491737fbfdc685abc727e48403)(@babel/core@7.29.0)(@react-native/metro-config@0.86.0(@babel/core@7.29.0))(@types/react@19.1.17)(react@19.1.0) react-native: 0.81.5(patch_hash=91fd85363059530dea649a5ca6012b933f79c8491737fbfdc685abc727e48403)(@babel/core@7.29.0)(@react-native/metro-config@0.86.0(@babel/core@7.29.0))(@types/react@19.1.17)(react@19.1.0)
+1
View File
@@ -24,6 +24,7 @@ export enum Features {
SearchStarterPacksV2Enable = 'search_starter_packs_v2:enable', SearchStarterPacksV2Enable = 'search_starter_packs_v2:enable',
FollowSortEnable = 'follow_sort:enable', FollowSortEnable = 'follow_sort:enable',
OnboardingInterestsRequiredEnable = 'onboarding:interests:required:enable', OnboardingInterestsRequiredEnable = 'onboarding:interests:required:enable',
CanonicalPostNumberingEnable = 'canonical_post_numbering:enable',
// values // values
TrendingDiscoverValues = 'trending_discover:values', TrendingDiscoverValues = 'trending_discover:values',
+8
View File
@@ -27,6 +27,7 @@ export type RichTextProps = TextStyleProp &
interactiveStyle?: StyleProp<TextStyle> interactiveStyle?: StyleProp<TextStyle>
emojiMultiplier?: number emojiMultiplier?: number
shouldProxyLinks?: boolean shouldProxyLinks?: boolean
suffix?: React.ReactNode
/** /**
* DANGEROUS: Disable facet lexicon validation * DANGEROUS: Disable facet lexicon validation
* *
@@ -54,6 +55,7 @@ export function RichText({
onLayout, onLayout,
onTextLayout, onTextLayout,
shouldProxyLinks, shouldProxyLinks,
suffix,
disableMentionFacetValidation, disableMentionFacetValidation,
}: RichTextProps) { }: RichTextProps) {
const richText = useMemo(() => { const richText = useMemo(() => {
@@ -87,6 +89,8 @@ export function RichText({
// @ts-ignore web only -prf // @ts-ignore web only -prf
dataSet={WORD_WRAP}> dataSet={WORD_WRAP}>
{text} {text}
{suffix ? ' ' : null}
{suffix}
</Text> </Text>
) )
} }
@@ -102,6 +106,8 @@ export function RichText({
// @ts-ignore web only -prf // @ts-ignore web only -prf
dataSet={WORD_WRAP}> dataSet={WORD_WRAP}>
{text} {text}
{suffix ? ' ' : null}
{suffix}
</Text> </Text>
) )
} }
@@ -188,6 +194,8 @@ export function RichText({
// @ts-ignore web only -prf // @ts-ignore web only -prf
dataSet={WORD_WRAP}> dataSet={WORD_WRAP}>
{els} {els}
{suffix ? ' ' : null}
{suffix}
</Text> </Text>
) )
} }
@@ -30,6 +30,10 @@ import {type PostSource} from '#/state/unstable-post-source'
import {PreviewableUserAvatar} from '#/view/com/util/UserAvatar' import {PreviewableUserAvatar} from '#/view/com/util/UserAvatar'
import {KnownLikers, LikesStat} from '#/screens/PostThread/components/LikesStat' import {KnownLikers, LikesStat} from '#/screens/PostThread/components/LikesStat'
import {ThreadItemAnchorFollowButton} from '#/screens/PostThread/components/ThreadItemAnchorFollowButton' import {ThreadItemAnchorFollowButton} from '#/screens/PostThread/components/ThreadItemAnchorFollowButton'
import {
hasThreadItemPostNumber,
ThreadItemPostNumber,
} from '#/screens/PostThread/components/ThreadItemPostNumber'
import { import {
LINEAR_AVI_WIDTH, LINEAR_AVI_WIDTH,
OUTER_SPACE, OUTER_SPACE,
@@ -184,6 +188,7 @@ const ThreadItemAnchorInner = memo(function ThreadItemAnchorInner({
const post = postShadow const post = postShadow
const record = item.value.post.record const record = item.value.post.record
const postNumbering = item.value
const moderation = item.moderation const moderation = item.moderation
const authorShadow = useProfileShadow(post.author) const authorShadow = useProfileShadow(post.author)
const {isActive: live} = useActorStatus(post.author) const {isActive: live} = useActorStatus(post.author)
@@ -400,8 +405,15 @@ const ThreadItemAnchorInner = memo(function ThreadItemAnchorInner({
style={[a.flex_1, a.text_lg]} style={[a.flex_1, a.text_lg]}
authorHandle={post.author.handle} authorHandle={post.author.handle}
shouldProxyLinks={true} shouldProxyLinks={true}
suffix={
hasThreadItemPostNumber(postNumbering) ? (
<ThreadItemPostNumber value={postNumbering} />
) : undefined
}
/> />
) : undefined} ) : (
<ThreadItemPostNumber inline={false} value={postNumbering} />
)}
<TranslatedPost post={post} postTextStyle={[a.text_lg]} /> <TranslatedPost post={post} postTextStyle={[a.text_lg]} />
{post.embed && ( {post.embed && (
<View style={[richText?.text ? a.py_xs : []]}> <View style={[richText?.text ? a.py_xs : []]}>
@@ -23,6 +23,10 @@ import {type OnPostSuccessData} from '#/state/shell/composer'
import {useMergedThreadgateHiddenReplies} from '#/state/threadgate-hidden-replies' import {useMergedThreadgateHiddenReplies} from '#/state/threadgate-hidden-replies'
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 {
hasThreadItemPostNumber,
ThreadItemPostNumber,
} from '#/screens/PostThread/components/ThreadItemPostNumber'
import { import {
LINEAR_AVI_WIDTH, LINEAR_AVI_WIDTH,
OUTER_SPACE, OUTER_SPACE,
@@ -196,6 +200,7 @@ const ThreadItemPostInner = memo(function ThreadItemPostInner({
const post = item.value.post const post = item.value.post
const record = item.value.post.record const record = item.value.post.record
const postNumbering = item.value
const moderation = item.moderation const moderation = item.moderation
const richText = useMemo( const richText = useMemo(
() => () =>
@@ -324,15 +329,28 @@ const ThreadItemPostInner = memo(function ThreadItemPostInner({
numberOfLines={limitLines ? MAX_POST_LINES : undefined} numberOfLines={limitLines ? MAX_POST_LINES : undefined}
authorHandle={post.author.handle} authorHandle={post.author.handle}
shouldProxyLinks={true} shouldProxyLinks={true}
suffix={
!limitLines && hasThreadItemPostNumber(postNumbering) ? (
<ThreadItemPostNumber value={postNumbering} />
) : undefined
}
/> />
{limitLines && ( {limitLines && (
<ShowMoreTextButton <View style={[a.flex_row, a.align_center, a.gap_xs]}>
style={[a.text_md]} <ShowMoreTextButton
onPress={onPressShowMore} style={[a.text_md]}
/> onPress={onPressShowMore}
/>
<ThreadItemPostNumber
inline={false}
value={postNumbering}
/>
</View>
)} )}
</View> </View>
) : undefined} ) : (
<ThreadItemPostNumber inline={false} value={postNumbering} />
)}
<TranslatedPost hideTranslateLink post={post} /> <TranslatedPost hideTranslateLink post={post} />
{post.embed && ( {post.embed && (
<View <View
@@ -0,0 +1,86 @@
import {Text, View} from 'react-native'
import {type AppBskyUnspeccedDefs} from '@atproto/api'
import {Trans, useLingui} from '@lingui/react/macro'
import {atoms as a, native, platform, useTheme} from '#/alf'
import {useAnalytics} from '#/analytics'
export function hasThreadItemPostNumber(
value: AppBskyUnspeccedDefs.ThreadItemPost,
) {
const index = value.opThreadPostIndex
const count = value.opThreadPostCount
return (
index !== undefined &&
count !== undefined &&
index >= 1 &&
count >= 1 &&
index <= count
)
}
export function ThreadItemPostNumber({
value,
inline = true,
}: {
value: AppBskyUnspeccedDefs.ThreadItemPost
inline?: boolean
}) {
const ax = useAnalytics()
const t = useTheme()
const {t: l} = useLingui()
const index = value.opThreadPostIndex
const count = value.opThreadPostCount
const isEnabled = ax.features.enabled(
ax.features.CanonicalPostNumberingEnable,
)
if (!isEnabled || !hasThreadItemPostNumber(value)) {
return null
}
return (
<View
style={[
a.flex_shrink_0,
a.rounded_full,
t.atoms.bg_contrast_50,
native(a.py_2xs),
{
paddingLeft: 5,
paddingRight: 5,
},
inline
? platform({
native: {transform: [{translateY: 6}]},
web: {top: -2},
})
: {top: -2},
]}>
<Text
accessibilityLabel={l({
message: `Post ${index} of ${count}`,
context: 'post-number-in-thread',
comment:
"Screen reader label indicating post count in a thread, e.g., the 3rd post of 5 total is 'Post 3 of 5'",
})}
accessibilityHint=""
style={[
a.text_xs,
a.font_medium,
t.atoms.text_contrast_high,
{
fontVariant: ['tabular-nums'],
},
]}>
<Trans
context="post-number-in-thread"
comment="Badge indicating post count in a thread, e.g., the 3rd post of 5 total is '3/5'">
{index}/{count}
</Trans>
</Text>
</View>
)
}
@@ -22,6 +22,10 @@ import {useSession} from '#/state/session'
import {type OnPostSuccessData} from '#/state/shell/composer' import {type OnPostSuccessData} from '#/state/shell/composer'
import {useMergedThreadgateHiddenReplies} from '#/state/threadgate-hidden-replies' import {useMergedThreadgateHiddenReplies} from '#/state/threadgate-hidden-replies'
import {PostMeta} from '#/view/com/util/PostMeta' import {PostMeta} from '#/view/com/util/PostMeta'
import {
hasThreadItemPostNumber,
ThreadItemPostNumber,
} from '#/screens/PostThread/components/ThreadItemPostNumber'
import { import {
OUTER_SPACE, OUTER_SPACE,
REPLY_LINE_WIDTH, REPLY_LINE_WIDTH,
@@ -258,6 +262,7 @@ const ThreadItemTreePostInner = memo(function ThreadItemTreePostInner({
const post = item.value.post const post = item.value.post
const record = item.value.post.record const record = item.value.post.record
const postNumbering = item.value
const moderation = item.moderation const moderation = item.moderation
const richText = useMemo( const richText = useMemo(
() => () =>
@@ -354,15 +359,32 @@ const ThreadItemTreePostInner = memo(function ThreadItemTreePostInner({
numberOfLines={limitLines ? MAX_POST_LINES : undefined} numberOfLines={limitLines ? MAX_POST_LINES : undefined}
authorHandle={post.author.handle} authorHandle={post.author.handle}
shouldProxyLinks={true} shouldProxyLinks={true}
suffix={
!limitLines &&
hasThreadItemPostNumber(postNumbering) ? (
<ThreadItemPostNumber value={postNumbering} />
) : undefined
}
/> />
{limitLines && ( {limitLines && (
<ShowMoreTextButton <View style={[a.flex_row, a.align_center, a.gap_xs]}>
style={[a.text_md]} <ShowMoreTextButton
onPress={onPressShowMore} style={[a.text_md]}
/> onPress={onPressShowMore}
/>
<ThreadItemPostNumber
inline={false}
value={postNumbering}
/>
</View>
)} )}
</View> </View>
) : null} ) : (
<ThreadItemPostNumber
inline={false}
value={postNumbering}
/>
)}
<TranslatedPost hideTranslateLink post={post} /> <TranslatedPost hideTranslateLink post={post} />
{post.embed && ( {post.embed && (
<View style={[a.pb_xs]}> <View style={[a.pb_xs]}>