Fix post numbers being clipped in some cases (#11413)

This commit is contained in:
DS Boyce
2026-08-06 16:34:49 -07:00
committed by GitHub
parent 1c349ad7da
commit 1e6725c0a1
5 changed files with 49 additions and 19 deletions
+21 -4
View File
@@ -3,7 +3,7 @@ import {type StyleProp, type TextStyle} from 'react-native'
import {AppBskyRichtextFacet, RichText as RichTextAPI} from '@atproto/api' import {AppBskyRichtextFacet, RichText as RichTextAPI} from '@atproto/api'
import {toShortUrl} from '#/lib/strings/url-helpers' import {toShortUrl} from '#/lib/strings/url-helpers'
import {atoms as a, flatten, type TextStyleProp} from '#/alf' import {atoms as a, flatten, ios, type TextStyleProp} from '#/alf'
import {isOnlyEmoji} from '#/alf/typography' import {isOnlyEmoji} from '#/alf/typography'
import {InlineLinkText, type LinkProps} from '#/components/Link' import {InlineLinkText, type LinkProps} from '#/components/Link'
import {ProfileHoverCard} from '#/components/ProfileHoverCard' import {ProfileHoverCard} from '#/components/ProfileHoverCard'
@@ -28,6 +28,18 @@ export type RichTextProps = TextStyleProp &
emojiMultiplier?: number emojiMultiplier?: number
shouldProxyLinks?: boolean shouldProxyLinks?: boolean
suffix?: React.ReactNode suffix?: React.ReactNode
/**
* How far below the text baseline `suffix` extends, in px.
*
* Inline views inside `Text` sit with their bottom edge on the baseline, so
* a suffix nudged below it overflows the `Text`'s measured bounds and iOS
* clips it. We reserve this much room as bottom padding and cancel it with
* an equal negative margin, so the suffix can paint without moving anything
* after it. Pass the same offset the suffix nudges itself by.
*
* Overrides any `paddingBottom`/`marginBottom` set via `style`.
*/
suffixOffset?: number
/** /**
* DANGEROUS: Disable facet lexicon validation * DANGEROUS: Disable facet lexicon validation
* *
@@ -56,6 +68,7 @@ export function RichText({
onTextLayout, onTextLayout,
shouldProxyLinks, shouldProxyLinks,
suffix, suffix,
suffixOffset = 0,
disableMentionFacetValidation, disableMentionFacetValidation,
}: RichTextProps) { }: RichTextProps) {
const richText = useMemo(() => { const richText = useMemo(() => {
@@ -69,6 +82,10 @@ export function RichText({
}, [value]) }, [value])
const plainStyles = style const plainStyles = style
const suffixStyles =
suffix && suffixOffset
? ios({paddingBottom: suffixOffset, marginBottom: -suffixOffset})
: null
const interactiveStyles = [plainStyles, interactiveStyle] const interactiveStyles = [plainStyles, interactiveStyle]
const {text, facets} = richText const {text, facets} = richText
@@ -83,7 +100,7 @@ export function RichText({
emoji emoji
selectable={selectable} selectable={selectable}
testID={testID} testID={testID}
style={[plainStyles, {fontSize}]} style={[plainStyles, {fontSize}, suffixStyles]}
onLayout={onLayout} onLayout={onLayout}
onTextLayout={onTextLayout} onTextLayout={onTextLayout}
// @ts-ignore web only -prf // @ts-ignore web only -prf
@@ -99,7 +116,7 @@ export function RichText({
emoji emoji
selectable={selectable} selectable={selectable}
testID={testID} testID={testID}
style={plainStyles} style={[plainStyles, suffixStyles]}
numberOfLines={numberOfLines} numberOfLines={numberOfLines}
onLayout={onLayout} onLayout={onLayout}
onTextLayout={onTextLayout} onTextLayout={onTextLayout}
@@ -187,7 +204,7 @@ export function RichText({
emoji emoji
selectable={selectable} selectable={selectable}
testID={testID} testID={testID}
style={plainStyles} style={[plainStyles, suffixStyles]}
numberOfLines={numberOfLines} numberOfLines={numberOfLines}
onLayout={onLayout} onLayout={onLayout}
onTextLayout={onTextLayout} onTextLayout={onTextLayout}
@@ -31,8 +31,9 @@ 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 { import {
hasThreadItemPostNumber, POST_NUMBER_INLINE_OFFSET,
ThreadItemPostNumber, ThreadItemPostNumber,
useHasThreadItemPostNumber,
} from '#/screens/PostThread/components/ThreadItemPostNumber' } from '#/screens/PostThread/components/ThreadItemPostNumber'
import { import {
LINEAR_AVI_WIDTH, LINEAR_AVI_WIDTH,
@@ -190,6 +191,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 postNumbering = item.value
const showPostNumber = useHasThreadItemPostNumber(postNumbering)
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)
@@ -406,8 +408,9 @@ 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}
suffixOffset={POST_NUMBER_INLINE_OFFSET}
suffix={ suffix={
hasThreadItemPostNumber(postNumbering) ? ( showPostNumber ? (
<ThreadItemPostNumber value={postNumbering} /> <ThreadItemPostNumber value={postNumbering} />
) : undefined ) : undefined
} }
@@ -24,8 +24,9 @@ import {useMergedThreadgateHiddenReplies} from '#/state/threadgate-hidden-replie
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 { import {
hasThreadItemPostNumber, POST_NUMBER_INLINE_OFFSET,
ThreadItemPostNumber, ThreadItemPostNumber,
useHasThreadItemPostNumber,
} from '#/screens/PostThread/components/ThreadItemPostNumber' } from '#/screens/PostThread/components/ThreadItemPostNumber'
import { import {
LINEAR_AVI_WIDTH, LINEAR_AVI_WIDTH,
@@ -204,6 +205,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 postNumbering = item.value
const showPostNumber = useHasThreadItemPostNumber(postNumbering)
const moderation = item.moderation const moderation = item.moderation
const richText = useMemo( const richText = useMemo(
() => () =>
@@ -332,8 +334,9 @@ 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}
suffixOffset={POST_NUMBER_INLINE_OFFSET}
suffix={ suffix={
!limitLines && hasThreadItemPostNumber(postNumbering) ? ( !limitLines && showPostNumber ? (
<ThreadItemPostNumber value={postNumbering} /> <ThreadItemPostNumber value={postNumbering} />
) : undefined ) : undefined
} }
@@ -5,13 +5,22 @@ import {Trans, useLingui} from '@lingui/react/macro'
import {atoms as a, native, platform, useTheme} from '#/alf' import {atoms as a, native, platform, useTheme} from '#/alf'
import {useAnalytics} from '#/analytics' import {useAnalytics} from '#/analytics'
export function hasThreadItemPostNumber( /**
* How far the inline badge is nudged below the text baseline to optically
* center it. The containing `RichText` must reserve matching room via
* `suffixOffset`, or iOS clips the overflow.
*/
export const POST_NUMBER_INLINE_OFFSET = 6
export function useHasThreadItemPostNumber(
value: AppBskyUnspeccedDefs.ThreadItemPost, value: AppBskyUnspeccedDefs.ThreadItemPost,
) { ) {
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) &&
index !== undefined && index !== undefined &&
count !== undefined && count !== undefined &&
index >= 1 && index >= 1 &&
@@ -27,17 +36,13 @@ export function ThreadItemPostNumber({
value: AppBskyUnspeccedDefs.ThreadItemPost value: AppBskyUnspeccedDefs.ThreadItemPost
inline?: boolean inline?: boolean
}) { }) {
const ax = useAnalytics()
const t = useTheme() const t = useTheme()
const {t: l} = useLingui() const {t: l} = useLingui()
const shouldRender = useHasThreadItemPostNumber(value)
const index = value.opThreadPostIndex const index = value.opThreadPostIndex
const count = value.opThreadPostCount const count = value.opThreadPostCount
const isEnabled = ax.features.enabled( if (!shouldRender) {
ax.features.CanonicalPostNumberingEnable,
)
if (!isEnabled || !hasThreadItemPostNumber(value)) {
return null return null
} }
@@ -54,7 +59,7 @@ export function ThreadItemPostNumber({
}, },
inline inline
? platform({ ? platform({
native: {transform: [{translateY: 6}]}, native: {transform: [{translateY: POST_NUMBER_INLINE_OFFSET}]},
web: {top: -2}, web: {top: -2},
}) })
: {top: -2}, : {top: -2},
@@ -23,8 +23,9 @@ 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 { import {
hasThreadItemPostNumber, POST_NUMBER_INLINE_OFFSET,
ThreadItemPostNumber, ThreadItemPostNumber,
useHasThreadItemPostNumber,
} from '#/screens/PostThread/components/ThreadItemPostNumber' } from '#/screens/PostThread/components/ThreadItemPostNumber'
import { import {
OUTER_SPACE, OUTER_SPACE,
@@ -264,6 +265,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 postNumbering = item.value
const showPostNumber = useHasThreadItemPostNumber(postNumbering)
const moderation = item.moderation const moderation = item.moderation
const richText = useMemo( const richText = useMemo(
() => () =>
@@ -360,9 +362,9 @@ 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}
suffixOffset={POST_NUMBER_INLINE_OFFSET}
suffix={ suffix={
!limitLines && !limitLines && showPostNumber ? (
hasThreadItemPostNumber(postNumbering) ? (
<ThreadItemPostNumber value={postNumbering} /> <ThreadItemPostNumber value={postNumbering} />
) : undefined ) : undefined
} }