diff --git a/src/screens/PostThread/__tests__/reader.test.ts b/src/screens/PostThread/__tests__/reader.test.ts index 46f4cf5be..97ad755c5 100644 --- a/src/screens/PostThread/__tests__/reader.test.ts +++ b/src/screens/PostThread/__tests__/reader.test.ts @@ -93,16 +93,19 @@ describe('buildReaderThread', () => { expect(segments[0].seam.expanded).toBe(false) expect(segments[0].seam.hiddenReplyCount).toBe(3) expect(segments[0].seam.continuationUri).toBe(two.uri) + expect(segments[0].seam.isThreadEnd).toBe(false) // the last seam counts all replies, since none render below it expect(segments[1].item).toBe(two) expect(segments[1].seam.hiddenReplyCount).toBe(2) expect(segments[1].seam.continuationUri).toBe('') + expect(segments[1].seam.isThreadEnd).toBe(true) - // the anchor seam counts all of its replies and continues into the chain + // the anchor seam excludes the continuation already shown as a segment expect(anchorSeam).toBeDefined() expect(anchorSeam?.expanded).toBe(false) - expect(anchorSeam?.hiddenReplyCount).toBe(4) + expect(anchorSeam?.hiddenReplyCount).toBe(3) expect(anchorSeam?.continuationUri).toBe(one.uri) + expect(anchorSeam?.isThreadEnd).toBe(false) expect(expandedSeam).toBeUndefined() }) @@ -176,7 +179,7 @@ describe('buildReaderThread', () => { expect(expandedSeam).toBe(segments[1].seam) }) - it('keeps a trailing read more with the chain', () => { + it('attaches a trailing read more to the last segment', () => { const anchor = post({rkey: 'a', depth: 0}) const one = post({rkey: 'b', depth: 1, opThread: true}) const readMore: ThreadItem = { @@ -193,11 +196,30 @@ describe('buildReaderThread', () => { NO_SEAMS, ) - expect(items.map(i => i.type)).toEqual([ - 'threadPost', - 'readerSegment', - 'readMore', - ]) + expect(items.map(i => i.type)).toEqual(['threadPost', 'readerSegment']) + const segments = segmentsOf(items) + expect(segments[0].seam.isThreadEnd).toBe(false) + expect(segments[0].trailingReadMore).toBe(readMore) + }) + + it('hides trailing read more while the last seam is expanded', () => { + const anchor = post({rkey: 'a', depth: 0}) + const one = post({rkey: 'b', depth: 1, opThread: true}) + const readMore: ThreadItem = { + type: 'readMore', + key: `readMore:${one.uri}`, + depth: 2, + href: '/x', + moreReplies: 5, + skippedIndentIndices: new Set(), + } + const {items} = buildReaderThread([anchor, one, readMore], { + expandedSeamUri: one.uri, + }) + + const segments = segmentsOf(items) + expect(segments[0].seam.expanded).toBe(true) + expect(segments[0].trailingReadMore).toBeUndefined() }) it('drops sibling replies even when the chain is not the first sibling', () => { diff --git a/src/screens/PostThread/components/ReaderSeam.tsx b/src/screens/PostThread/components/ReaderSeam.tsx index c841efbc4..994c38fd7 100644 --- a/src/screens/PostThread/components/ReaderSeam.tsx +++ b/src/screens/PostThread/components/ReaderSeam.tsx @@ -53,6 +53,7 @@ export function ReaderSeam({ continuationUri, href, sort, + isThreadEnd = false, onToggle, onPostSuccess, threadgateRecord, @@ -63,6 +64,11 @@ export function ReaderSeam({ continuationUri: string href: string sort: string + /** + * Last post in the OP chain with no "Read more replies" below. Collapsed + * seams get a modest trailing spacer (smaller than the expanded-reply gap). + */ + isThreadEnd?: boolean onToggle: () => void onPostSuccess?: (data: OnPostSuccessData) => void threadgateRecord?: AppBskyFeedThreadgate.Record @@ -82,6 +88,7 @@ export function ReaderSeam({ continuationUri={continuationUri} href={href} sort={sort} + isThreadEnd={isThreadEnd} onToggle={onToggle} onPostSuccess={onPostSuccess} threadgateRecord={threadgateRecord} @@ -97,6 +104,7 @@ function ReaderSeamInner({ continuationUri, href, sort, + isThreadEnd, onToggle, onPostSuccess, threadgateRecord, @@ -108,6 +116,7 @@ function ReaderSeamInner({ continuationUri: string href: string sort: string + isThreadEnd: boolean onToggle: () => void onPostSuccess?: (data: OnPostSuccessData) => void threadgateRecord?: AppBskyFeedThreadgate.Record @@ -323,7 +332,15 @@ function ReaderSeamInner({ /> )} - {expanded && } + {/* + * Expanded: same gap as between consecutive reader segments. + * Collapsed thread end: smaller spacer before Also liked / page end. + */} + {expanded ? ( + + ) : ( + isThreadEnd && + )} ) } diff --git a/src/screens/PostThread/components/ReaderSeamControls.tsx b/src/screens/PostThread/components/ReaderSeamControls.tsx index 342ef6f1d..3f18a2f13 100644 --- a/src/screens/PostThread/components/ReaderSeamControls.tsx +++ b/src/screens/PostThread/components/ReaderSeamControls.tsx @@ -56,7 +56,8 @@ import * as bsky from '#/types/bsky' * plus any expanded details and replies. `left` positions it relative to the * consumer's container, since the anchor renders inside padded content while * segments render full bleed. `bottom` lets the consumer raise the bottom cap - * to line up with the seam's interaction row. + * to line up with the seam's interaction row when collapsed, or sit above the + * trailing spacer / on the replies' end rule when expanded. */ export function ReaderBracket({ left, diff --git a/src/screens/PostThread/components/ThreadItemAnchor.tsx b/src/screens/PostThread/components/ThreadItemAnchor.tsx index cd4c24f6f..00011bc0b 100644 --- a/src/screens/PostThread/components/ThreadItemAnchor.tsx +++ b/src/screens/PostThread/components/ThreadItemAnchor.tsx @@ -60,7 +60,7 @@ import { type ReaderSeam as ReaderSeamData, type ThreadPostPosition, } from '#/screens/PostThread/reader' -import {atoms as a, useTheme} from '#/alf' +import {atoms as a, tokens, useTheme} from '#/alf' import {Button} from '#/components/Button' import {DebugFieldDisplay} from '#/components/DebugFieldDisplay' import {CalendarClock_Stroke2_Corner0_Rounded as CalendarClockIcon} from '#/components/icons/CalendarClock' @@ -484,7 +484,11 @@ const ThreadItemAnchorInner = memo(function ThreadItemAnchorInner({ )} @@ -531,7 +535,13 @@ const ThreadItemAnchorInner = memo(function ThreadItemAnchorInner({ {post.embed && ( - view: PostThreadParams['view'] + view: PostThreadParams['view'] | 'reader' }) { const t = useTheme() const {_} = useLingui() const isTreeView = view === 'tree' + const isReader = view === 'reader' const indent = Math.max(0, item.depth - 1) const spacers = isTreeView @@ -50,6 +57,66 @@ export const ThreadItemReadMore = memo(function ThreadItemReadMore({ }) : null + if (isReader) { + /* + * Fixed height matching the seam row so the bracket bottom cap can sit at + * the arm's vertical center (same math as the interaction hairline) and + * not run past the read-more control. + */ + return ( + + + + {({hovered, pressed}) => { + const interacted = hovered || pressed + return ( + <> + + + + Read{' '} + + + + + ) + }} + + + ) + } + return ( {spacers} diff --git a/src/screens/PostThread/components/ThreadItemReaderSegment.tsx b/src/screens/PostThread/components/ThreadItemReaderSegment.tsx index 52db47a32..e2962e493 100644 --- a/src/screens/PostThread/components/ThreadItemReaderSegment.tsx +++ b/src/screens/PostThread/components/ThreadItemReaderSegment.tsx @@ -15,13 +15,14 @@ import { import {type OnPostSuccessData} from '#/state/shell/composer' import {ReaderSeam} from '#/screens/PostThread/components/ReaderSeam' import {ReaderBracket} from '#/screens/PostThread/components/ReaderSeamControls' +import {ThreadItemReadMore} from '#/screens/PostThread/components/ThreadItemReadMore' import { OUTER_SPACE, READER_LINE_INDENT, READER_SEAM_HEIGHT, } from '#/screens/PostThread/const' import {type ReaderSegmentItem} from '#/screens/PostThread/reader' -import {atoms as a, useTheme} from '#/alf' +import {atoms as a, tokens, useTheme} from '#/alf' import {Trash_Stroke2_Corner0_Rounded as TrashIcon} from '#/components/icons/Trash' import {GalleryBleed} from '#/components/images/Gallery' import {LabelsOnMyPost} from '#/components/moderation/LabelsOnMe' @@ -108,6 +109,7 @@ const ThreadItemReaderSegmentInner = memo( const seam = item.seam const record = item.item.value.post.record const moderation = item.item.moderation + const trailingReadMore = item.trailingReadMore const richText = useMemo( () => new RichTextAPI({ @@ -150,7 +152,7 @@ const ThreadItemReaderSegmentInner = memo( postTextStyle={[a.text_lg]} /> {post.embed && ( - + onToggleSeam(item.uri)} onPostSuccess={onPostSuccess} threadgateRecord={threadgateRecord} /> - {/* Bracket rendered last so it paints over the outline border. When - collapsed, raise the bottom cap to the seam row's center so it meets - the hairline; when expanded it spans down past the replies. */} + {trailingReadMore && ( + <> + + {/* End spacer; bracket closes flush at its bottom */} + + + )} + {/* Bracket rendered last so it paints over the outline border. + - Collapsed mid-chain: bottom cap at hairline + - Collapsed thread end: hairline, accounting for the modest spacer + - Expanded: close above the trailing spacer + - Trailing read-more: close flush at the spacer bottom */} ) diff --git a/src/screens/PostThread/components/ThreadPositionChip.tsx b/src/screens/PostThread/components/ThreadPositionChip.tsx index 33bd9a500..1bbf9e53a 100644 --- a/src/screens/PostThread/components/ThreadPositionChip.tsx +++ b/src/screens/PostThread/components/ThreadPositionChip.tsx @@ -1,12 +1,16 @@ import {type ThreadPostPosition} from '#/screens/PostThread/reader' import {atoms as a, useTheme} from '#/alf' -import {Text} from '#/components/Typography' +import {Span} from '#/components/Typography' /** * Position indicator shown at the end of a post's text when it is part of a * self-thread in linear view, e.g. "(2/12)". Rendered inline via RichText's * `trailing` prop so it flows with the last line of text. Not translated: it * is purely numeric. + * + * Uses Span (raw RN Text) rather than our Text component so we do not apply + * an absolute lineHeight from leading_snug + text_sm, which on native collapses + * spacing for the whole parent paragraph. */ export function ThreadPositionChip({ threadPosition, @@ -16,8 +20,8 @@ export function ThreadPositionChip({ const t = useTheme() return ( - + {` (${threadPosition.position}/${threadPosition.postCount})`} - + ) } diff --git a/src/screens/PostThread/index.tsx b/src/screens/PostThread/index.tsx index 632f4605d..a6622b279 100644 --- a/src/screens/PostThread/index.tsx +++ b/src/screens/PostThread/index.tsx @@ -20,6 +20,7 @@ import {usePostViewAuthorShadowFilter} from '#/state/cache/profile-shadow' import {useFeedFeedback} from '#/state/feed-feedback' import {useAlsoLikedCollapseByDefault} from '#/state/preferences/also-liked-collapse-by-default' import {useAlsoLikedFeedEnabled} from '#/state/preferences/also-liked-feed-enabled' +import {useEnableSquareButtons} from '#/state/preferences/enable-square-buttons' import {useModerationOpts} from '#/state/preferences/moderation-opts' import { ALSO_LIKED_PAGE_SIZE, @@ -1041,6 +1042,7 @@ function ReaderHideRepliesButton({onPress}: {onPress: () => void}) { const t = useTheme() const {t: l} = useLingui() const {footerHeight} = useShellLayout() + const enableSquareButtons = useEnableSquareButtons() const animatedStyle = useAnimatedStyle(() => { return { @@ -1062,9 +1064,9 @@ function ReaderHideRepliesButton({onPress}: {onPress: () => void}) { a.gap_xs, a.px_lg, a.py_sm, - a.rounded_full, + enableSquareButtons ? a.rounded_sm : a.rounded_full, a.border, - a.shadow_md, + t.atoms.shadow_sm, t.atoms.border_contrast_low, hovered || pressed ? t.atoms.bg_contrast_25 : t.atoms.bg, ]}> diff --git a/src/screens/PostThread/reader.ts b/src/screens/PostThread/reader.ts index 21fb67d56..1497117ba 100644 --- a/src/screens/PostThread/reader.ts +++ b/src/screens/PostThread/reader.ts @@ -23,6 +23,12 @@ export type ReaderSeam = { */ continuationUri: string href: string + /** + * True when this is the last post in the OP chain and no "Read more + * replies" follows - the visual end of the thread on this screen. + * Seams here get a trailing spacer so the bracket can close above it. + */ + isThreadEnd: boolean } export type ReaderSegmentItem = { @@ -33,6 +39,12 @@ export type ReaderSegmentItem = { item: ThreadPostItem /** The seam rendered after this post, within its bracket. */ seam: ReaderSeam + /** + * A "read more" that belonged immediately after this segment's post in the + * linear thread. Rendered under the seam (and hidden while the seam is + * expanded) so its connector can meet the bracket hairline. + */ + trailingReadMore?: Extract } export type ReaderItem = ThreadItem | ReaderSegmentItem @@ -113,10 +125,10 @@ export function buildReaderThread( /* * A "read more" immediately following the chain belongs to the chain's - * branch (sibling branches always start with a depth-1 post), so it moves - * with the chain and renders after the last segment. + * branch (sibling branches always start with a depth-1 post), so it is + * attached to the last segment and rendered under its seam. */ - let chainReadMore: ThreadItem | undefined + let chainReadMore: Extract | undefined const afterChain = items[chainEnd + 1] if (afterChain?.type === 'readMore') { chainReadMore = afterChain @@ -128,23 +140,28 @@ export function buildReaderThread( let expandedSeam: ReaderSeam | undefined /* - * The anchor's seam uses the full replyCount: every direct reply other than - * the chain start is dropped from the read and reachable only here. + * The anchor's seam has a continuation (the first OP-chain segment) already + * on screen, so its count must exclude that reply - same idea as mid-chain + * seams using moreReplies. createSeam subtracts the continuation when + * fullCount is set. */ const anchorSeam = createSeam(anchor, { expandedSeamUri, continuationUri: chain[0].uri, fullCount: true, + isThreadEnd: false, }) if (anchorSeam.expanded) expandedSeam = anchorSeam for (let i = 0; i < chain.length; i++) { const post = chain[i] const continuation = chain[i + 1] + const isLast = !continuation const seam = createSeam(post, { expandedSeamUri, continuationUri: continuation?.uri ?? '', - fullCount: !continuation, + fullCount: isLast, + isThreadEnd: isLast && !chainReadMore, }) if (seam.expanded) expandedSeam = seam result.push({ @@ -154,13 +171,16 @@ export function buildReaderThread( depth: post.depth, item: post, seam, + /* + * Attach to the last segment so the connector can meet the seam + * hairline inside the same bracket. Omitted while that seam is + * expanded - those unreplied descendants are behind the opened replies. + */ + trailingReadMore: + isLast && chainReadMore && !seam.expanded ? chainReadMore : undefined, }) } - if (chainReadMore) { - result.push(chainReadMore) - } - return {items: result, anchorSeam, expandedSeam} } @@ -306,28 +326,32 @@ function createSeam( expandedSeamUri, continuationUri, fullCount, + isThreadEnd, }: { expandedSeamUri: string | null continuationUri: string fullCount: boolean + isThreadEnd: boolean }, ): ReaderSeam { const urip = new AtUri(post.uri) /* - * Mid-chain seams have a continuation, so use moreReplies (replies not - * already in the response). The anchor and the last post use the full - * replyCount, since none of their replies render below them. - * - * moreReplies is exact only because reader view fetches with branchingFactor - * 1 (READER_VIEW_BELOW); a higher factor would undercount here. + * Mid-chain seams have a continuation on screen, so use moreReplies (exact + * with branchingFactor 1). The last post uses replyCount - every reply is + * behind the seam. The anchor also uses replyCount but with a continuation, + * so we subtract 1 for the chain start already rendered as a segment. */ + const replyCount = post.value.post.replyCount ?? 0 const hiddenReplyCount = fullCount - ? (post.value.post.replyCount ?? 0) + ? continuationUri + ? Math.max(0, replyCount - 1) + : replyCount : post.value.moreReplies || 0 return { expanded: expandedSeamUri === post.uri, hiddenReplyCount, continuationUri, href: makeProfileLink(post.value.post.author, 'post', urip.rkey), + isThreadEnd, } }