From 1e6725c0a1dcf65c933497233dd8b547749d1b34 Mon Sep 17 00:00:00 2001 From: DS Boyce <260543580+ds-boyce@users.noreply.github.com> Date: Thu, 6 Aug 2026 16:34:49 -0700 Subject: [PATCH] Fix post numbers being clipped in some cases (#11413) --- src/components/RichText.tsx | 25 ++++++++++++++++--- .../components/ThreadItemAnchor.tsx | 7 ++++-- .../PostThread/components/ThreadItemPost.tsx | 7 ++++-- .../components/ThreadItemPostNumber.tsx | 21 ++++++++++------ .../components/ThreadItemTreePost.tsx | 8 +++--- 5 files changed, 49 insertions(+), 19 deletions(-) diff --git a/src/components/RichText.tsx b/src/components/RichText.tsx index 4be7aebbc6..4110619b91 100644 --- a/src/components/RichText.tsx +++ b/src/components/RichText.tsx @@ -3,7 +3,7 @@ import {type StyleProp, type TextStyle} from 'react-native' import {AppBskyRichtextFacet, RichText as RichTextAPI} from '@atproto/api' 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 {InlineLinkText, type LinkProps} from '#/components/Link' import {ProfileHoverCard} from '#/components/ProfileHoverCard' @@ -28,6 +28,18 @@ export type RichTextProps = TextStyleProp & emojiMultiplier?: number shouldProxyLinks?: boolean 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 * @@ -56,6 +68,7 @@ export function RichText({ onTextLayout, shouldProxyLinks, suffix, + suffixOffset = 0, disableMentionFacetValidation, }: RichTextProps) { const richText = useMemo(() => { @@ -69,6 +82,10 @@ export function RichText({ }, [value]) const plainStyles = style + const suffixStyles = + suffix && suffixOffset + ? ios({paddingBottom: suffixOffset, marginBottom: -suffixOffset}) + : null const interactiveStyles = [plainStyles, interactiveStyle] const {text, facets} = richText @@ -83,7 +100,7 @@ export function RichText({ emoji selectable={selectable} testID={testID} - style={[plainStyles, {fontSize}]} + style={[plainStyles, {fontSize}, suffixStyles]} onLayout={onLayout} onTextLayout={onTextLayout} // @ts-ignore web only -prf @@ -99,7 +116,7 @@ export function RichText({ emoji selectable={selectable} testID={testID} - style={plainStyles} + style={[plainStyles, suffixStyles]} numberOfLines={numberOfLines} onLayout={onLayout} onTextLayout={onTextLayout} @@ -187,7 +204,7 @@ export function RichText({ emoji selectable={selectable} testID={testID} - style={plainStyles} + style={[plainStyles, suffixStyles]} numberOfLines={numberOfLines} onLayout={onLayout} onTextLayout={onTextLayout} diff --git a/src/screens/PostThread/components/ThreadItemAnchor.tsx b/src/screens/PostThread/components/ThreadItemAnchor.tsx index e6fdc01939..c4d664238b 100644 --- a/src/screens/PostThread/components/ThreadItemAnchor.tsx +++ b/src/screens/PostThread/components/ThreadItemAnchor.tsx @@ -31,8 +31,9 @@ import {PreviewableUserAvatar} from '#/view/com/util/UserAvatar' import {KnownLikers, LikesStat} from '#/screens/PostThread/components/LikesStat' import {ThreadItemAnchorFollowButton} from '#/screens/PostThread/components/ThreadItemAnchorFollowButton' import { - hasThreadItemPostNumber, + POST_NUMBER_INLINE_OFFSET, ThreadItemPostNumber, + useHasThreadItemPostNumber, } from '#/screens/PostThread/components/ThreadItemPostNumber' import { LINEAR_AVI_WIDTH, @@ -190,6 +191,7 @@ const ThreadItemAnchorInner = memo(function ThreadItemAnchorInner({ const post = postShadow const record = item.value.post.record const postNumbering = item.value + const showPostNumber = useHasThreadItemPostNumber(postNumbering) const moderation = item.moderation const authorShadow = useProfileShadow(post.author) const {isActive: live} = useActorStatus(post.author) @@ -406,8 +408,9 @@ const ThreadItemAnchorInner = memo(function ThreadItemAnchorInner({ style={[a.flex_1, a.text_lg]} authorHandle={post.author.handle} shouldProxyLinks={true} + suffixOffset={POST_NUMBER_INLINE_OFFSET} suffix={ - hasThreadItemPostNumber(postNumbering) ? ( + showPostNumber ? ( ) : undefined } diff --git a/src/screens/PostThread/components/ThreadItemPost.tsx b/src/screens/PostThread/components/ThreadItemPost.tsx index 0e35f00c41..c7f5b3b5f7 100644 --- a/src/screens/PostThread/components/ThreadItemPost.tsx +++ b/src/screens/PostThread/components/ThreadItemPost.tsx @@ -24,8 +24,9 @@ import {useMergedThreadgateHiddenReplies} from '#/state/threadgate-hidden-replie import {PostMeta} from '#/view/com/util/PostMeta' import {PreviewableUserAvatar} from '#/view/com/util/UserAvatar' import { - hasThreadItemPostNumber, + POST_NUMBER_INLINE_OFFSET, ThreadItemPostNumber, + useHasThreadItemPostNumber, } from '#/screens/PostThread/components/ThreadItemPostNumber' import { LINEAR_AVI_WIDTH, @@ -204,6 +205,7 @@ const ThreadItemPostInner = memo(function ThreadItemPostInner({ const post = item.value.post const record = item.value.post.record const postNumbering = item.value + const showPostNumber = useHasThreadItemPostNumber(postNumbering) const moderation = item.moderation const richText = useMemo( () => @@ -332,8 +334,9 @@ const ThreadItemPostInner = memo(function ThreadItemPostInner({ numberOfLines={limitLines ? MAX_POST_LINES : undefined} authorHandle={post.author.handle} shouldProxyLinks={true} + suffixOffset={POST_NUMBER_INLINE_OFFSET} suffix={ - !limitLines && hasThreadItemPostNumber(postNumbering) ? ( + !limitLines && showPostNumber ? ( ) : undefined } diff --git a/src/screens/PostThread/components/ThreadItemPostNumber.tsx b/src/screens/PostThread/components/ThreadItemPostNumber.tsx index 55f071fd66..54e383a5cb 100644 --- a/src/screens/PostThread/components/ThreadItemPostNumber.tsx +++ b/src/screens/PostThread/components/ThreadItemPostNumber.tsx @@ -5,13 +5,22 @@ import {Trans, useLingui} from '@lingui/react/macro' import {atoms as a, native, platform, useTheme} from '#/alf' 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, ) { + const ax = useAnalytics() const index = value.opThreadPostIndex const count = value.opThreadPostCount return ( + ax.features.enabled(ax.features.CanonicalPostNumberingEnable) && index !== undefined && count !== undefined && index >= 1 && @@ -27,17 +36,13 @@ export function ThreadItemPostNumber({ value: AppBskyUnspeccedDefs.ThreadItemPost inline?: boolean }) { - const ax = useAnalytics() const t = useTheme() const {t: l} = useLingui() + const shouldRender = useHasThreadItemPostNumber(value) const index = value.opThreadPostIndex const count = value.opThreadPostCount - const isEnabled = ax.features.enabled( - ax.features.CanonicalPostNumberingEnable, - ) - - if (!isEnabled || !hasThreadItemPostNumber(value)) { + if (!shouldRender) { return null } @@ -54,7 +59,7 @@ export function ThreadItemPostNumber({ }, inline ? platform({ - native: {transform: [{translateY: 6}]}, + native: {transform: [{translateY: POST_NUMBER_INLINE_OFFSET}]}, web: {top: -2}, }) : {top: -2}, diff --git a/src/screens/PostThread/components/ThreadItemTreePost.tsx b/src/screens/PostThread/components/ThreadItemTreePost.tsx index 9ea74c04db..1560550329 100644 --- a/src/screens/PostThread/components/ThreadItemTreePost.tsx +++ b/src/screens/PostThread/components/ThreadItemTreePost.tsx @@ -23,8 +23,9 @@ import {type OnPostSuccessData} from '#/state/shell/composer' import {useMergedThreadgateHiddenReplies} from '#/state/threadgate-hidden-replies' import {PostMeta} from '#/view/com/util/PostMeta' import { - hasThreadItemPostNumber, + POST_NUMBER_INLINE_OFFSET, ThreadItemPostNumber, + useHasThreadItemPostNumber, } from '#/screens/PostThread/components/ThreadItemPostNumber' import { OUTER_SPACE, @@ -264,6 +265,7 @@ const ThreadItemTreePostInner = memo(function ThreadItemTreePostInner({ const post = item.value.post const record = item.value.post.record const postNumbering = item.value + const showPostNumber = useHasThreadItemPostNumber(postNumbering) const moderation = item.moderation const richText = useMemo( () => @@ -360,9 +362,9 @@ const ThreadItemTreePostInner = memo(function ThreadItemTreePostInner({ numberOfLines={limitLines ? MAX_POST_LINES : undefined} authorHandle={post.author.handle} shouldProxyLinks={true} + suffixOffset={POST_NUMBER_INLINE_OFFSET} suffix={ - !limitLines && - hasThreadItemPostNumber(postNumbering) ? ( + !limitLines && showPostNumber ? ( ) : undefined }