diff --git a/src/analytics/metrics/types.ts b/src/analytics/metrics/types.ts index 862a7255b..269d4dcf5 100644 --- a/src/analytics/metrics/types.ts +++ b/src/analytics/metrics/types.ts @@ -1109,6 +1109,8 @@ export type Events = { [key: string]: unknown } 'thread:click:headerMenuOpen': {} + 'thread:click:readerToggle': {enabled: boolean; via: 'header' | 'menu'} + 'thread:click:readerSeamToggle': {expanded: boolean} 'thread:click:editOwnThreadgate': {} 'thread:click:viewSomeoneElsesThreadgate': {} 'activitySubscription:enable': { diff --git a/src/components/PostEditedIndicator.tsx b/src/components/PostEditedIndicator.tsx new file mode 100644 index 000000000..9dc758862 --- /dev/null +++ b/src/components/PostEditedIndicator.tsx @@ -0,0 +1,151 @@ +import {View} from 'react-native' +import {type AppBskyFeedPost} from '@atproto/api' +import {Trans, useLingui} from '@lingui/react/macro' + +import {getPostEditInfo} from '#/lib/edit-post' +import {atoms as a, useTheme, web} from '#/alf' +import * as Dialog from '#/components/Dialog' +import {Text} from '#/components/Typography' + +/** + * The "· Edited" badge next to a post's timestamp. Renders nothing unless the + * post was edited; tapping it opens the original-vs-current history. + */ +export function PostEditedIndicator({ + record, + size = 'md', +}: { + record: AppBskyFeedPost.Record + size?: 'sm' | 'md' +}) { + const t = useTheme() + const {t: l} = useLingui() + const control = Dialog.useDialogControl() + const {isEdited, originalText, updatedAt} = getPostEditInfo(record) + + if (!isEdited) { + return null + } + + return ( + <> + control.open()} + style={[ + a.pl_xs, + size === 'sm' ? a.text_sm : a.text_md, + a.leading_tight, + t.atoms.text_contrast_medium, + web({whiteSpace: 'nowrap', cursor: 'pointer'}), + ]}> + · Edited + + + + ) +} + +function PostEditHistoryDialog({ + control, + originalText, + currentText, + createdAt, + updatedAt, +}: { + control: Dialog.DialogControlProps + originalText: string | undefined + currentText: string + createdAt: string + updatedAt: string | undefined +}) { + const {t: l, i18n} = useLingui() + + return ( + + + + + + Edit history + + + + + + + + + + ) +} + +function TimelineEntry({ + label, + text, + isCurrent = false, + isLast = false, +}: { + label: string + text: string + isCurrent?: boolean + isLast?: boolean +}) { + const t = useTheme() + return ( + + + + {!isLast && ( + + )} + + + + {label} + + + {text} + + + + ) +} diff --git a/src/components/RichText.tsx b/src/components/RichText.tsx index 0dd348a71..d0225b157 100644 --- a/src/components/RichText.tsx +++ b/src/components/RichText.tsx @@ -1,4 +1,4 @@ -import {useMemo} from 'react' +import {useMemo, type ReactNode} from 'react' import {type StyleProp, type TextStyle} from 'react-native' import {AppBskyRichtextFacet, RichText as RichTextAPI} from '@atproto/api' @@ -27,6 +27,13 @@ export type RichTextProps = TextStyleProp & interactiveStyle?: StyleProp emojiMultiplier?: number shouldProxyLinks?: boolean + /** + * Rendered inline after the last text segment, so it flows with the final + * line and wraps with the text, e.g. the thread position indicator in the + * post thread's linear view. Must be text-compatible (a string or nested + * ). + */ + trailing?: ReactNode /** * DANGEROUS: Disable facet lexicon validation * @@ -55,6 +62,7 @@ export function RichText({ onTextLayout, shouldProxyLinks, disableMentionFacetValidation, + trailing, }: RichTextProps) { const richText = useMemo(() => { if (value instanceof RichTextAPI) { @@ -87,6 +95,7 @@ export function RichText({ // @ts-ignore web only -prf dataSet={WORD_WRAP}> {text} + {trailing} ) } @@ -102,6 +111,7 @@ export function RichText({ // @ts-ignore web only -prf dataSet={WORD_WRAP}> {text} + {trailing} ) } @@ -188,6 +198,7 @@ export function RichText({ // @ts-ignore web only -prf dataSet={WORD_WRAP}> {els} + {trailing} ) } diff --git a/src/lib/edit-post.ts b/src/lib/edit-post.ts new file mode 100644 index 000000000..bc1823e60 --- /dev/null +++ b/src/lib/edit-post.ts @@ -0,0 +1,62 @@ +import {type AppBskyFeedPost} from '@atproto/api' + +// Posts can only be edited for a few minutes after going up. +export const EDIT_WINDOW_MS = 5 * 60 * 1000 + +// Small speed bump against throwaway accounts: must be a week old to edit. +export const MIN_ACCOUNT_AGE_MS = 7 * 24 * 60 * 60 * 1000 + +/** Extra fields we write onto a post record on edit — not part of the lexicon. */ +export type PostEditFields = { + /** When the edit happened. Absent means never edited. */ + updatedAt?: string + /** The original text, kept for history. */ + originalText?: string +} + +export type EditedPostRecord = AppBskyFeedPost.Record & PostEditFields + +export function getPostEditInfo(record: AppBskyFeedPost.Record): { + isEdited: boolean + updatedAt: string | undefined + originalText: string | undefined +} { + const {updatedAt, originalText} = record as EditedPostRecord + return { + isEdited: typeof updatedAt === 'string', + updatedAt: typeof updatedAt === 'string' ? updatedAt : undefined, + originalText: typeof originalText === 'string' ? originalText : undefined, + } +} + +/** + * Whether the user can edit this post right now. These are UX guardrails only - + * nothing stops another client from writing to the repo directly. If we can't + * work out the account's age, we fail closed. + */ +export function canEditPost({ + isAuthor, + createdAt, + updatedAt, + accountCreatedAt, + now = Date.now(), +}: { + isAuthor: boolean + createdAt: string + updatedAt: unknown + accountCreatedAt: string | undefined + now?: number +}): boolean { + if (!isAuthor) return false + if (typeof updatedAt === 'string') return false + + const accountAgeMs = accountCreatedAt + ? now - Date.parse(accountCreatedAt) + : NaN + if (Number.isNaN(accountAgeMs) || accountAgeMs < MIN_ACCOUNT_AGE_MS) { + return false + } + + const postAgeMs = now - Date.parse(createdAt) + return postAgeMs >= 0 && postAgeMs <= EDIT_WINDOW_MS +} diff --git a/src/lib/routes/types.ts b/src/lib/routes/types.ts index f1d0a65cb..3affe50f0 100644 --- a/src/lib/routes/types.ts +++ b/src/lib/routes/types.ts @@ -36,7 +36,7 @@ export type CommonNavigatorParams = { ProfileKnownFollowers: {name: string} ProfileSearch: {name: string} & SearchParams ProfileList: {name: string; rkey: string} - PostThread: {name: string; rkey: string} + PostThread: {name: string; rkey: string; view?: 'reader'} PostLikedBy: {name: string; rkey: string} PostRepostedBy: {name: string; rkey: string} PostQuotes: {name: string; rkey: string} diff --git a/src/screens/PostThread/__tests__/reader.test.ts b/src/screens/PostThread/__tests__/reader.test.ts new file mode 100644 index 000000000..46f4cf5be --- /dev/null +++ b/src/screens/PostThread/__tests__/reader.test.ts @@ -0,0 +1,398 @@ +import {type ThreadItem} from '#/state/queries/usePostThread/types' +import { + buildReaderThread, + computeSelfThreadPositions, + type ReaderSegmentItem, + type ThreadPostItem, +} from '../reader' + +const OP_DID = 'did:plc:op' + +function post({ + rkey, + depth, + did = OP_DID, + opThread = false, + moreReplies = 0, + replyCount = 0, + isReply = false, + moreParents = false, +}: { + rkey: string + depth: number + did?: string + opThread?: boolean + moreReplies?: number + replyCount?: number + isReply?: boolean + moreParents?: boolean +}): ThreadPostItem { + const uri = `at://${did}/app.bsky.feed.post/${rkey}` + return { + type: 'threadPost', + key: uri, + uri, + depth, + value: { + post: { + uri, + author: {did, handle: 'user.test'}, + record: isReply ? {reply: {root: {uri: 'at://root'}}} : {}, + replyCount, + }, + opThread, + moreReplies, + moreParents, + }, + isBlurred: false, + moderation: {}, + ui: { + isAnchor: depth === 0, + showParentReplyLine: depth > 0, + showChildReplyLine: false, + indent: depth, + isLastChild: false, + skippedIndentIndices: new Set(), + precedesChildReadMore: false, + }, + } as unknown as ThreadPostItem +} + +const composer: ThreadItem = {type: 'replyComposer', key: 'replyComposer'} + +const NO_SEAMS = {expandedSeamUri: null} + +const segmentsOf = (items: ReturnType['items']) => + items.filter((i): i is ReaderSegmentItem => i.type === 'readerSegment') + +describe('buildReaderThread', () => { + it('collapses an OP chain into segments with seams', () => { + const anchor = post({rkey: 'a', depth: 0, replyCount: 4}) + const one = post({ + rkey: 'b', + depth: 1, + opThread: true, + moreReplies: 3, + replyCount: 4, + }) + const two = post({rkey: 'c', depth: 2, opThread: true, replyCount: 2}) + const {items, anchorSeam, expandedSeam} = buildReaderThread( + [anchor, composer, one, two], + NO_SEAMS, + ) + + expect(items.map(i => i.type)).toEqual([ + 'threadPost', + 'readerSegment', + 'readerSegment', + ]) + + const segments = segmentsOf(items) + expect(segments[0].item).toBe(one) + // mid-chain seams count only replies missing from the response + expect(segments[0].seam.expanded).toBe(false) + expect(segments[0].seam.hiddenReplyCount).toBe(3) + expect(segments[0].seam.continuationUri).toBe(two.uri) + // 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('') + + // the anchor seam counts all of its replies and continues into the chain + expect(anchorSeam).toBeDefined() + expect(anchorSeam?.expanded).toBe(false) + expect(anchorSeam?.hiddenReplyCount).toBe(4) + expect(anchorSeam?.continuationUri).toBe(one.uri) + + expect(expandedSeam).toBeUndefined() + }) + + it('returns input unchanged when there is no OP chain', () => { + const anchor = post({rkey: 'a', depth: 0}) + const reply = post({rkey: 'b', depth: 1, did: 'did:plc:other'}) + const input = [anchor, composer, reply] + const {items, anchorSeam, expandedSeam} = buildReaderThread(input, NO_SEAMS) + + expect(anchorSeam).toBeUndefined() + expect(expandedSeam).toBeUndefined() + expect(items).toBe(input) + }) + + it('drops the standalone composer even when the last seam is expanded', () => { + const anchor = post({rkey: 'a', depth: 0}) + const one = post({rkey: 'b', depth: 1, opThread: true}) + const two = post({rkey: 'c', depth: 2, opThread: true}) + const {items} = buildReaderThread([anchor, composer, one, two], { + expandedSeamUri: two.uri, + }) + + expect(items.map(i => i.type)).toEqual([ + 'threadPost', + 'readerSegment', + 'readerSegment', + ]) + }) + + it('ends the chain when the author changes', () => { + const anchor = post({rkey: 'a', depth: 0}) + const one = post({rkey: 'b', depth: 1, opThread: true}) + const interloper = post({ + rkey: 'c', + depth: 2, + did: 'did:plc:other', + opThread: true, + }) + const {items} = buildReaderThread([anchor, one, interloper], NO_SEAMS) + + // the interloper is dropped: it is reachable via the last seam + expect(items.map(i => i.type)).toEqual(['threadPost', 'readerSegment']) + }) + + it('ends the chain when opThread is false', () => { + const anchor = post({rkey: 'a', depth: 0}) + const one = post({rkey: 'b', depth: 1, opThread: true}) + const selfReplyOffThread = post({rkey: 'c', depth: 2, opThread: false}) + const {items} = buildReaderThread( + [anchor, one, selfReplyOffThread], + NO_SEAMS, + ) + + expect(items.map(i => i.type)).toEqual(['threadPost', 'readerSegment']) + }) + + it('marks the expanded seam from the expanded uri', () => { + const anchor = post({rkey: 'a', depth: 0}) + const one = post({rkey: 'b', depth: 1, opThread: true}) + const two = post({rkey: 'c', depth: 2, opThread: true}) + const three = post({rkey: 'd', depth: 3, opThread: true}) + const {items, anchorSeam, expandedSeam} = buildReaderThread( + [anchor, one, two, three], + {expandedSeamUri: two.uri}, + ) + + const segments = segmentsOf(items) + expect(segments.map(s => s.seam.expanded)).toEqual([false, true, false]) + expect(anchorSeam?.expanded).toBe(false) + expect(expandedSeam).toBe(segments[1].seam) + }) + + it('keeps a trailing read more with the chain', () => { + 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 reply = post({rkey: 'd', depth: 1, did: 'did:plc:other'}) + const {items} = buildReaderThread( + [anchor, composer, one, readMore, reply], + NO_SEAMS, + ) + + expect(items.map(i => i.type)).toEqual([ + 'threadPost', + 'readerSegment', + 'readMore', + ]) + }) + + it('drops sibling replies even when the chain is not the first sibling', () => { + const anchor = post({rkey: 'a', depth: 0}) + const otherReply = post({rkey: 'b', depth: 1, did: 'did:plc:other'}) + const one = post({rkey: 'c', depth: 1, opThread: true}) + const two = post({rkey: 'd', depth: 2, opThread: true}) + const {items, anchorSeam} = buildReaderThread( + [anchor, composer, otherReply, one, two], + NO_SEAMS, + ) + + // otherReply is dropped: it is reachable via the anchor's seam + expect(items.map(i => i.type)).toEqual([ + 'threadPost', + 'readerSegment', + 'readerSegment', + ]) + expect(segmentsOf(items)[0].item).toBe(one) + expect(anchorSeam?.continuationUri).toBe(one.uri) + }) + + it('passes through items above the anchor unchanged', () => { + const parent = post({rkey: 'p', depth: -1}) + const anchor = post({rkey: 'a', depth: 0}) + const one = post({rkey: 'b', depth: 1, opThread: true}) + const {items} = buildReaderThread([parent, anchor, one], NO_SEAMS) + + expect(items[0]).toBe(parent) + expect(items[1]).toBe(anchor) + }) +}) + +describe('computeSelfThreadPositions', () => { + it('numbers the OP chain from the root', () => { + const root = post({rkey: 'a', depth: 0}) + const one = post({rkey: 'b', depth: 1, opThread: true, isReply: true}) + const two = post({rkey: 'c', depth: 2, opThread: true, isReply: true}) + const positions = computeSelfThreadPositions([root, composer, one, two]) + + expect(positions?.get(root.uri)).toEqual({position: 1, postCount: 3}) + expect(positions?.get(one.uri)).toEqual({position: 2, postCount: 3}) + expect(positions?.get(two.uri)).toEqual({position: 3, postCount: 3}) + }) + + it('numbers across parents when anchored mid-chain', () => { + const root = post({rkey: 'a', depth: -2}) + const parent = post({rkey: 'b', depth: -1, opThread: true, isReply: true}) + const anchor = post({rkey: 'c', depth: 0, opThread: true, isReply: true}) + const below = post({rkey: 'd', depth: 1, opThread: true, isReply: true}) + const positions = computeSelfThreadPositions([root, parent, anchor, below]) + + expect(positions?.get(root.uri)).toEqual({position: 1, postCount: 4}) + expect(positions?.get(anchor.uri)).toEqual({position: 3, postCount: 4}) + expect(positions?.get(below.uri)).toEqual({position: 4, postCount: 4}) + }) + + it('returns undefined when there is no chain', () => { + const root = post({rkey: 'a', depth: 0}) + const reply = post({ + rkey: 'b', + depth: 1, + did: 'did:plc:other', + isReply: true, + }) + + expect(computeSelfThreadPositions([root, reply])).toBeUndefined() + }) + + it('numbers a reply chain by another author', () => { + const root = post({rkey: 'a', depth: 0}) + const replyA = post({ + rkey: 'b', + depth: 1, + did: 'did:plc:other', + isReply: true, + }) + const replyB = post({ + rkey: 'c', + depth: 2, + did: 'did:plc:other', + isReply: true, + }) + const positions = computeSelfThreadPositions([root, replyA, replyB]) + + expect(positions?.get(root.uri)).toBeUndefined() + expect(positions?.get(replyA.uri)).toEqual({position: 1, postCount: 2}) + expect(positions?.get(replyB.uri)).toEqual({position: 2, postCount: 2}) + }) + + it('finds the continuation among non-adjacent siblings of the anchor', () => { + const root = post({rkey: 'a', depth: 0}) + const other = post({ + rkey: 'b', + depth: 1, + did: 'did:plc:other', + isReply: true, + }) + const one = post({rkey: 'c', depth: 1, opThread: true, isReply: true}) + const two = post({rkey: 'd', depth: 2, opThread: true, isReply: true}) + const positions = computeSelfThreadPositions([root, other, one, two]) + + expect(positions?.get(root.uri)).toEqual({position: 1, postCount: 3}) + expect(positions?.get(one.uri)).toEqual({position: 2, postCount: 3}) + expect(positions?.get(two.uri)).toEqual({position: 3, postCount: 3}) + expect(positions?.get(other.uri)).toBeUndefined() + }) + + it('skips chains whose start has no hydrated parent', () => { + const notRoot = post({rkey: 'b', depth: 0, opThread: true, isReply: true}) + const below = post({rkey: 'c', depth: 1, opThread: true, isReply: true}) + + expect(computeSelfThreadPositions([notRoot, below])).toBeUndefined() + }) + + it('skips chains truncated above, without renumbering their tail', () => { + const truncated = post({ + rkey: 'a', + depth: -1, + opThread: true, + isReply: true, + moreParents: true, + }) + const anchor = post({rkey: 'b', depth: 0, opThread: true, isReply: true}) + const below = post({rkey: 'c', depth: 1, opThread: true, isReply: true}) + + expect( + computeSelfThreadPositions([truncated, anchor, below]), + ).toBeUndefined() + }) + + it('skips a non-OP chain cut off by the depth cap', () => { + const root = post({rkey: 'a', depth: 0}) + const replyA = post({ + rkey: 'b', + depth: 1, + did: 'did:plc:other', + isReply: true, + }) + const replyB = post({ + rkey: 'c', + depth: 2, + did: 'did:plc:other', + isReply: true, + moreReplies: 1, + }) + const readMore: ThreadItem = { + type: 'readMore', + key: `readMore:${replyB.uri}`, + depth: 3, + href: '/x', + moreReplies: 1, + skippedIndentIndices: new Set(), + } + const positions = computeSelfThreadPositions([ + root, + replyA, + replyB, + readMore, + ]) + + expect(positions).toBeUndefined() + }) + + it('numbers the OP chain even with a trailing read more', () => { + const root = post({rkey: 'a', depth: 0}) + const one = post({rkey: 'b', depth: 1, opThread: true, isReply: true}) + const two = post({rkey: 'c', depth: 2, opThread: true, isReply: true}) + const readMore: ThreadItem = { + type: 'readMore', + key: `readMore:${two.uri}`, + depth: 3, + href: '/x', + moreReplies: 2, + skippedIndentIndices: new Set(), + } + const positions = computeSelfThreadPositions([root, one, two, readMore]) + + expect(positions?.get(two.uri)).toEqual({position: 3, postCount: 3}) + }) + + it('ends the chain at an interloper and numbers only the prefix', () => { + const root = post({rkey: 'a', depth: 0}) + const one = post({rkey: 'b', depth: 1, opThread: true, isReply: true}) + const interloper = post({ + rkey: 'c', + depth: 2, + did: 'did:plc:other', + opThread: true, + isReply: true, + }) + const positions = computeSelfThreadPositions([root, one, interloper]) + + expect(positions?.get(root.uri)).toEqual({position: 1, postCount: 2}) + expect(positions?.get(one.uri)).toEqual({position: 2, postCount: 2}) + expect(positions?.get(interloper.uri)).toBeUndefined() + }) +}) diff --git a/src/screens/PostThread/components/HeaderDropdown.tsx b/src/screens/PostThread/components/HeaderDropdown.tsx index 9d7ba24c4..89a4d3974 100644 --- a/src/screens/PostThread/components/HeaderDropdown.tsx +++ b/src/screens/PostThread/components/HeaderDropdown.tsx @@ -15,10 +15,11 @@ export function HeaderDropdown({ view, setSort, setView, -}: Pick< - ThreadPreferences, - 'sort' | 'setSort' | 'view' | 'setView' ->): React.ReactNode { + showReader = true, +}: Pick & { + /** Whether to offer reader view, matching the header toggle's gating. */ + showReader?: boolean +}): React.ReactNode { const ax = useAnalytics() const {_} = useLingui() @@ -69,6 +70,22 @@ export function HeaderDropdown({ + {showReader && ( + { + ax.metric('thread:click:readerToggle', { + enabled: true, + via: 'menu', + }) + setView('reader') + }}> + + Reader + + + + )} diff --git a/src/screens/PostThread/components/HeaderReaderToggle.tsx b/src/screens/PostThread/components/HeaderReaderToggle.tsx new file mode 100644 index 000000000..a0bfc5bb1 --- /dev/null +++ b/src/screens/PostThread/components/HeaderReaderToggle.tsx @@ -0,0 +1,32 @@ +import {useLingui} from '@lingui/react/macro' + +import {HITSLOP_10} from '#/lib/constants' +import {Button, ButtonIcon} from '#/components/Button' +import {PageText_Stroke2_Corner0_Rounded as PageText} from '#/components/icons/PageText' + +/** + * Quick toggle for the thread reader view, shown when the thread contains an + * OP self-thread chain. + */ +export function HeaderReaderToggle({ + active, + onPress, +}: { + active: boolean + onPress: () => void +}) { + const {t: l} = useLingui() + + return ( + + ) +} diff --git a/src/screens/PostThread/components/ReaderSeam.tsx b/src/screens/PostThread/components/ReaderSeam.tsx new file mode 100644 index 000000000..c841efbc4 --- /dev/null +++ b/src/screens/PostThread/components/ReaderSeam.tsx @@ -0,0 +1,329 @@ +import {useState} from 'react' +import {View} from 'react-native' +import Animated, {FadeIn} from 'react-native-reanimated' +import {type AppBskyFeedDefs, type AppBskyFeedThreadgate} from '@atproto/api' +import {plural} from '@lingui/core/macro' +import {useLingui} from '@lingui/react/macro' + +import {CountWheel} from '#/lib/custom-animations/CountWheel' +import {AnimatedLikeIcon} from '#/lib/custom-animations/LikeIcon' +import {useOpenComposer} from '#/lib/hooks/useOpenComposer' +import { + POST_TOMBSTONE, + type Shadow, + usePostShadow, +} from '#/state/cache/post-shadow' +import {useFeedFeedback} from '#/state/feed-feedback' +import { + usePostLikeMutationQueue, + usePostRepostMutationQueue, +} from '#/state/queries/post' +import {useRequireAuth, useSession} from '#/state/session' +import {type OnPostSuccessData} from '#/state/shell/composer' +import {ReaderSeamReplies} from '#/screens/PostThread/components/ReaderSeamControls' +import {ThreadComposePromptPill} from '#/screens/PostThread/components/ThreadComposePrompt' +import { + OUTER_SPACE, + READER_BRACKET_WIDTH, + READER_SEAM_HEIGHT, +} from '#/screens/PostThread/const' +import {type ThreadPostItem} from '#/screens/PostThread/reader' +import {atoms as a, useTheme} from '#/alf' +import {Reply as ReplyIcon} from '#/components/icons/Reply' +import { + PostControlButton, + PostControlButtonIcon, + PostControlButtonText, +} from '#/components/PostControls/PostControlButton' +import {RepostButton} from '#/components/PostControls/RepostButton' +import {useFormatPostStatCount} from '#/components/PostControls/util' +import * as Toast from '#/components/Toast' + +/** + * The toggle rendered after a reader post, inside its bracket. Collapsed it + * shows a compact, right-aligned action bar (reply, repost, like) on a + * hairline; tapping reply reveals the post's replies and a compose prompt. + * Expects its parent to provide the post's horizontal padding; replies break + * out to full width. + */ +export function ReaderSeam({ + post, + expanded, + hiddenReplyCount, + continuationUri, + href, + sort, + onToggle, + onPostSuccess, + threadgateRecord, +}: { + post: ThreadPostItem + expanded: boolean + hiddenReplyCount: number + continuationUri: string + href: string + sort: string + onToggle: () => void + onPostSuccess?: (data: OnPostSuccessData) => void + threadgateRecord?: AppBskyFeedThreadgate.Record +}) { + const postShadow = usePostShadow(post.value.post) + + if (postShadow === POST_TOMBSTONE) { + return null + } + + return ( + + ) +} + +function ReaderSeamInner({ + post, + postShadow, + expanded, + hiddenReplyCount, + continuationUri, + href, + sort, + onToggle, + onPostSuccess, + threadgateRecord, +}: { + post: ThreadPostItem + postShadow: Shadow + expanded: boolean + hiddenReplyCount: number + continuationUri: string + href: string + sort: string + onToggle: () => void + onPostSuccess?: (data: OnPostSuccessData) => void + threadgateRecord?: AppBskyFeedThreadgate.Record +}) { + const t = useTheme() + const {t: l} = useLingui() + const {hasSession} = useSession() + const [hovered, setHovered] = useState(false) + const lineVisible = hovered || expanded + const requireAuth = useRequireAuth() + const {openComposer} = useOpenComposer() + const formatPostStatCount = useFormatPostStatCount() + const feedFeedback = useFeedFeedback(undefined, hasSession) + const logContext = 'PostThreadItem' + + const shadow = postShadow + const record = post.value.post.record + const moderation = post.moderation + + const [queueLike, queueUnlike] = usePostLikeMutationQueue( + shadow, + undefined, + feedFeedback.feedDescriptor, + logContext, + ) + const [queueRepost, queueUnrepost] = usePostRepostMutationQueue( + shadow, + undefined, + feedFeedback.feedDescriptor, + logContext, + ) + + const isBlocked = Boolean( + shadow.author.viewer?.blocking || + shadow.author.viewer?.blockedBy || + shadow.author.viewer?.blockingByList, + ) + + const [hasLikeBeenToggled, setHasLikeBeenToggled] = useState(false) + + const onPressToggleLike = async () => { + if (isBlocked) { + Toast.show(l`Cannot interact with a blocked user`, {type: 'warning'}) + return + } + try { + setHasLikeBeenToggled(true) + if (!shadow.viewer?.like) { + await queueLike() + } else { + await queueUnlike() + } + } catch (err) { + if ((err as Error)?.name !== 'AbortError') throw err + } + } + + const onRepost = async () => { + if (isBlocked) { + Toast.show(l`Cannot interact with a blocked user`, {type: 'warning'}) + return + } + try { + if (!shadow.viewer?.repost) { + await queueRepost() + } else { + await queueUnrepost() + } + } catch (err) { + if ((err as Error)?.name !== 'AbortError') throw err + } + } + + const onQuote = () => { + if (isBlocked) { + Toast.show(l`Cannot interact with a blocked user`, {type: 'warning'}) + return + } + openComposer({quote: shadow, logContext: 'QuotePost'}) + } + + const onPressReply = () => { + openComposer({ + replyTo: { + uri: shadow.uri, + cid: shadow.cid, + text: record.text, + author: shadow.author, + embed: shadow.embed, + moderation, + langs: record.langs, + }, + onPostSuccess, + logContext: 'PostReply', + }) + } + + return ( + <> + {/* Fixed height + align_center keep the hairline at the icons' vertical + center, where the bracket's bottom cap is placed to meet it */} + + {/* flex_1 keeps the line left of the buttons. alignSelf + marginTop + place it at exactly READER_SEAM_HEIGHT/2 - 1 from the row top, + matching the bracket borderBottom at bottom: READER_SEAM_HEIGHT/2 */} + + setHovered(true)} + onMouseLeave={() => setHovered(false)} + style={[a.flex_row, a.align_center, a.ml_auto]}> + onToggle()} + label={l({ + message: `Replies (${plural(hiddenReplyCount, { + one: '# reply', + other: '# replies', + })})`, + comment: 'Reader seam reply toggle, noun form with reply count', + })}> + + {hiddenReplyCount > 0 && ( + + {formatPostStatCount(hiddenReplyCount)} + + )} + + void onRepost()} + onQuote={onQuote} + embeddingDisabled={Boolean(shadow.viewer?.embeddingDisabled)} + /> + requireAuth(() => onPressToggleLike())} + label={ + shadow.viewer?.like + ? l({ + message: `Unlike (${plural(shadow.likeCount || 0, { + one: '# like', + other: '# likes', + })})`, + comment: 'Like button, liked state', + }) + : l({ + message: `Like (${plural(shadow.likeCount || 0, { + one: '# like', + other: '# likes', + })})`, + comment: 'Like button, unliked state', + }) + }> + + ( + + {formatPostStatCount(count)} + + )} + /> + + + + + {expanded && ( + + + + + )} + + {expanded && hiddenReplyCount > 0 && ( + + + + )} + {expanded && } + + ) +} diff --git a/src/screens/PostThread/components/ReaderSeamControls.tsx b/src/screens/PostThread/components/ReaderSeamControls.tsx new file mode 100644 index 000000000..342ef6f1d --- /dev/null +++ b/src/screens/PostThread/components/ReaderSeamControls.tsx @@ -0,0 +1,586 @@ +import {Fragment, type ReactNode, useMemo} from 'react' +import {Text as RNText, View} from 'react-native' +import { + AppBskyFeedDefs, + AppBskyFeedPost, + type AppBskyFeedThreadgate, + AtUri, + RichText as RichTextAPI, +} from '@atproto/api' +import {Plural, Trans, useLingui} from '@lingui/react/macro' +import {useQuery} from '@tanstack/react-query' + +import {useOpenComposer} from '#/lib/hooks/useOpenComposer' +import {makeProfileLink} from '#/lib/routes/links' +import {niceDate} from '#/lib/strings/time' +import { + POST_TOMBSTONE, + type Shadow, + usePostShadow, +} from '#/state/cache/post-shadow' +import {FeedFeedbackProvider, useFeedFeedback} from '#/state/feed-feedback' +import {useModerationOpts} from '#/state/preferences/moderation-opts' +import {sortAndAnnotateThreadItems} from '#/state/queries/usePostThread/traversal' +import {postThreadQueryKeyRoot} from '#/state/queries/usePostThread/types' +import {useAgent, useSession} from '#/state/session' +import {type OnPostSuccessData} from '#/state/shell/composer' +import {useMergedThreadgateHiddenReplies} from '#/state/threadgate-hidden-replies' +import {type PostSource} from '#/state/unstable-post-source' +import {ThreadComposePromptPill} from '#/screens/PostThread/components/ThreadComposePrompt' +import { + ThreadItemPost, + ThreadItemPostSkeleton, +} from '#/screens/PostThread/components/ThreadItemPost' +import {ThreadItemReadMore} from '#/screens/PostThread/components/ThreadItemReadMore' +import { + OUTER_SPACE, + READER_BRACKET_WIDTH, + READER_LINE_INDENT, + REPLY_LINE_WIDTH, +} from '#/screens/PostThread/const' +import {type ThreadPostItem} from '#/screens/PostThread/reader' +import {atoms as a, useTheme} from '#/alf' +import {Button} from '#/components/Button' +import {CalendarClock_Stroke2_Corner0_Rounded as CalendarClockIcon} from '#/components/icons/CalendarClock' +import {Link} from '#/components/Link' +import {PostControls} from '#/components/PostControls' +import {useFormatPostStatCount} from '#/components/PostControls/util' +import {PostEditedIndicator} from '#/components/PostEditedIndicator' +import * as Prompt from '#/components/Prompt' +import {Text} from '#/components/Typography' +import {WhoCanReply} from '#/components/WhoCanReply' +import * as bsky from '#/types/bsky' + +/** + * The bracket drawn in a reader segment's gutter, spanning the post content + * 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. + */ +export function ReaderBracket({ + left, + bottom = READER_LINE_INDENT, +}: { + left: number + bottom?: number +}) { + const t = useTheme() + + return ( + + ) +} + +/** + * The details, stats, and interaction rows below a post's content. Shown for + * the anchor post in every view, and revealed by the seam for reader + * segments. + */ +export function ReaderSeamControls({ + post: postItem, + postSource, + showComposePrompt = false, + showDetails = true, + onPostSuccess, + threadgateRecord, +}: { + post: ThreadPostItem + postSource?: PostSource + showComposePrompt?: boolean + /** + * Whether to show the full date and engagement-count rows above the action + * bar. Expanded reader segments set false: their PostMeta header shows the + * timestamp, and the action bar's own counts cover the stats. + */ + showDetails?: boolean + onPostSuccess?: (data: OnPostSuccessData) => void + threadgateRecord?: AppBskyFeedThreadgate.Record +}) { + const postShadow = usePostShadow(postItem.value.post) + + if (postShadow === POST_TOMBSTONE) { + return null + } + + return ( + + ) +} + +function ReaderSeamControlsInner({ + post: postItem, + postShadow, + postSource, + showComposePrompt, + showDetails, + onPostSuccess, + threadgateRecord, +}: { + post: ThreadPostItem + postShadow: Shadow + postSource?: PostSource + showComposePrompt?: boolean + showDetails?: boolean + onPostSuccess?: (data: OnPostSuccessData) => void + threadgateRecord?: AppBskyFeedThreadgate.Record +}) { + const t = useTheme() + const {t: l, i18n} = useLingui() + const {currentAccount, hasSession} = useSession() + const {openComposer} = useOpenComposer() + const formatPostStatCount = useFormatPostStatCount() + const feedFeedback = useFeedFeedback(postSource?.feedSourceInfo, hasSession) + + const post = postShadow + const record = postItem.value.post.record + const moderation = postItem.moderation + const isRootPost = !record.reply + const isThreadAuthor = post.author.did === currentAccount?.did + + const richText = useMemo( + () => new RichTextAPI({text: record.text, facets: record.facets}), + [record], + ) + + const urip = new AtUri(post.uri) + const likesHref = makeProfileLink(post.author, 'post', urip.rkey, 'liked-by') + const repostsHref = makeProfileLink( + post.author, + 'post', + urip.rkey, + 'reposted-by', + ) + const quotesHref = makeProfileLink(post.author, 'post', urip.rkey, 'quotes') + + const reason = postSource?.post.reason + const viaRepost = + AppBskyFeedDefs.isReasonRepost(reason) && reason.uri && reason.cid + ? {uri: reason.uri, cid: reason.cid} + : undefined + + const onPressReply = () => { + openComposer({ + replyTo: { + uri: post.uri, + cid: post.cid, + text: record.text, + author: post.author, + embed: post.embed, + moderation, + langs: record.langs, + }, + onPostSuccess: onPostSuccess, + logContext: 'PostReply', + }) + + if (postSource) { + feedFeedback.sendInteraction({ + item: post.uri, + event: 'app.bsky.feed.defs#interactionReply', + feedContext: postSource.post.feedContext, + reqId: postSource.post.reqId, + }) + } + } + + const hasEngagement = + post.repostCount || post.likeCount || post.quoteCount || post.bookmarkCount + + return ( + + {showDetails && } + + {showDetails && ( + + + {niceDate(i18n, post.indexedAt, 'dot separated')} + + + {isRootPost && ( + + )} + + )} + + {/* Engagement counts */} + {showDetails && hasEngagement ? ( + + {post.repostCount ? ( + + + + + {formatPostStatCount(post.repostCount)} + {' '} + + + + + ) : null} + {post.quoteCount && !post.viewer?.embeddingDisabled ? ( + + + + + {formatPostStatCount(post.quoteCount)} + {' '} + + + + + ) : null} + {post.likeCount ? ( + + + + + {formatPostStatCount(post.likeCount)} + {' '} + + + + + ) : null} + {post.bookmarkCount ? ( + + + + {formatPostStatCount(post.bookmarkCount)} + {' '} + + + + ) : null} + + ) : null} + + {/* Action bar + optional compose prompt as one gap item */} + + + + + + + {showComposePrompt && ( + + {/* Hold the pill at the post's content edges */} + + + + + )} + + + ) +} + +function BackdatedPostIndicator({post}: {post: AppBskyFeedDefs.PostView}) { + const t = useTheme() + const {t: l, i18n} = useLingui() + const control = Prompt.usePromptControl() + + const indexedAt = new Date(post.indexedAt) + const createdAt = bsky.dangerousIsType( + post.record, + AppBskyFeedPost.isRecord, + ) + ? new Date(post.record.createdAt) + : new Date(post.indexedAt) + + // backdated if createdAt is 24 hours or more before indexedAt + const isBackdated = + indexedAt.getTime() - createdAt.getTime() > 24 * 60 * 60 * 1000 + + if (!isBackdated) return null + + return ( + <> + + + + + + Archived post + + + + This post claims to have been created on{' '} + + {niceDate(i18n, createdAt)} + + , but was first seen by Bluesky on{' '} + + {niceDate(i18n, indexedAt)} + + . + + + + + Bluesky cannot confirm the authenticity of the claimed date. + + + + + {}} /> + + + + ) +} + +/** + * Replies to a post, fetched when its seam is expanded, closed by a + * horizontal rule at the bracket edge. The next post in the OP chain is + * excluded since it renders as the following segment. + */ +export function ReaderSeamReplies({ + uri, + continuationUri, + hiddenReplyCount, + href, + sort, + onPostSuccess, + threadgateRecord, +}: { + uri: string + continuationUri: string + hiddenReplyCount: number + href: string + sort: string + onPostSuccess?: (data: OnPostSuccessData) => void + threadgateRecord?: AppBskyFeedThreadgate.Record +}) { + const t = useTheme() + const {t: l} = useLingui() + const agent = useAgent() + const moderationOpts = useModerationOpts() + const threadgateHiddenReplies = useMergedThreadgateHiddenReplies({ + threadgateRecord, + }) + + const {data, isError} = useQuery({ + enabled: !!moderationOpts, + queryKey: [ + postThreadQueryKeyRoot, + 'readerSeamReplies', + {anchor: uri, sort}, + ], + async queryFn() { + const {data: res} = await agent.app.bsky.unspecced.getPostThreadV2({ + anchor: uri, + below: 1, + sort, + }) + /* + * Everything under the `post-thread-v2` query key root gets scanned by + * the post shadow cache integration, which expects a `{thread}` shape. + */ + return {thread: res.thread || []} + }, + }) + + const replyItems = useMemo(() => { + if (!data || !moderationOpts) return [] + const {threadItems} = sortAndAnnotateThreadItems(data.thread, { + view: 'linear', + skipModerationHandling: true, + threadgateHiddenReplies, + moderationOpts, + }) + return threadItems + .filter(reply => { + if (reply.type === 'threadPost') { + return reply.depth > 0 && reply.uri !== continuationUri + } + if (reply.type === 'readMore') { + return reply.key !== `readMore:${continuationUri}` + } + return false + }) + .map(reply => { + if (reply.type === 'threadPost' && reply.ui.showParentReplyLine) { + /* + * The post these replies belong to renders above the seam, not + * directly above the reply, so drop the connecting line. This also + * gives each reply its separating top border. + */ + return {...reply, ui: {...reply.ui, showParentReplyLine: false}} + } + return reply + }) + }, [data, moderationOpts, threadgateHiddenReplies, continuationUri]) + + let body: ReactNode + if (isError) { + /* + * These replies are supplementary, so on error just link out to the post + * where they can be read. + */ + body = ( + + + + + + + + ) + } else if (!data || !moderationOpts) { + body = + } else { + body = replyItems.map((reply, index) => { + if (reply.type === 'threadPost') { + return ( + + {/* Inset separator starting at the bracket edge, in place of + the post's own full-bleed top border */} + {index > 0 && !reply.ui.showParentReplyLine && ( + + )} + + + ) + } else if (reply.type === 'readMore') { + return + } + return null + }) + } + + return ( + <> + {body} + + + ) +} diff --git a/src/screens/PostThread/components/ThreadComposePrompt.tsx b/src/screens/PostThread/components/ThreadComposePrompt.tsx index e45d681dc..01974fc2f 100644 --- a/src/screens/PostThread/components/ThreadComposePrompt.tsx +++ b/src/screens/PostThread/components/ThreadComposePrompt.tsx @@ -23,18 +23,8 @@ export function ThreadComposePrompt({ onPressCompose: () => void style?: StyleProp }) { - const {currentAccount} = useSession() - const {data: profile} = useProfileQuery({did: currentAccount?.did}) - const {_} = useLingui() const {gtMobile} = useBreakpoints() const t = useTheme() - const playHaptic = useHaptics() - const enableSquareButtons = useEnableSquareButtons() - const { - state: hovered, - onIn: onHoverIn, - onOut: onHoverOut, - } = useInteractionState() useHideBottomBarBorderForScreen() @@ -60,39 +50,62 @@ export function ThreadComposePrompt({ style={[a.absolute, a.inset_0]} /> )} - { - onPressCompose() - playHaptic('Light') - }} - onLongPress={ios(() => { - onPressCompose() - playHaptic('Heavy') - })} - onHoverIn={onHoverIn} - onHoverOut={onHoverOut} - style={[ - a.flex_row, - a.align_center, - a.p_sm, - a.gap_sm, - enableSquareButtons ? a.rounded_sm : a.rounded_full, - (!gtMobile || hovered) && t.atoms.bg_contrast_25, - native([a.border, t.atoms.border_contrast_low]), - a.transition_color, - ]}> - - - Write your reply - - + ) } + +/** + * The "Write your reply" pill. Shared by the sticky thread prompt and the + * inline reader-seam prompt so the two stay visually identical. + */ +export function ThreadComposePromptPill({onPress}: {onPress: () => void}) { + const t = useTheme() + const {_} = useLingui() + const {gtMobile} = useBreakpoints() + const {currentAccount} = useSession() + const {data: profile} = useProfileQuery({did: currentAccount?.did}) + const playHaptic = useHaptics() + const enableSquareButtons = useEnableSquareButtons() + const { + state: hovered, + onIn: onHoverIn, + onOut: onHoverOut, + } = useInteractionState() + + return ( + { + onPress() + playHaptic('Light') + }} + onLongPress={ios(() => { + onPress() + playHaptic('Heavy') + })} + onHoverIn={onHoverIn} + onHoverOut={onHoverOut} + style={[ + a.flex_row, + a.align_center, + a.p_sm, + a.gap_sm, + enableSquareButtons ? a.rounded_sm : a.rounded_full, + (!gtMobile || hovered) && t.atoms.bg_contrast_25, + native([a.border, t.atoms.border_contrast_low]), + a.transition_color, + ]}> + + + Write your reply + + + ) +} diff --git a/src/screens/PostThread/components/ThreadItemAnchor.tsx b/src/screens/PostThread/components/ThreadItemAnchor.tsx index 6609be139..cd4c24f6f 100644 --- a/src/screens/PostThread/components/ThreadItemAnchor.tsx +++ b/src/screens/PostThread/components/ThreadItemAnchor.tsx @@ -45,12 +45,21 @@ import {type OnPostSuccessData} from '#/state/shell/composer' import {useMergedThreadgateHiddenReplies} from '#/state/threadgate-hidden-replies' import {type PostSource} from '#/state/unstable-post-source' import {PreviewableUserAvatar} from '#/view/com/util/UserAvatar' +import {ReaderSeam} from '#/screens/PostThread/components/ReaderSeam' +import {ReaderBracket} from '#/screens/PostThread/components/ReaderSeamControls' import {ThreadItemAnchorFollowButton} from '#/screens/PostThread/components/ThreadItemAnchorFollowButton' +import {ThreadPositionChip} from '#/screens/PostThread/components/ThreadPositionChip' import { LINEAR_AVI_WIDTH, OUTER_SPACE, + READER_LINE_INDENT, + READER_SEAM_HEIGHT, REPLY_LINE_WIDTH, } from '#/screens/PostThread/const' +import { + type ReaderSeam as ReaderSeamData, + type ThreadPostPosition, +} from '#/screens/PostThread/reader' import {atoms as a, useTheme} from '#/alf' import {Button} from '#/components/Button' import {DebugFieldDisplay} from '#/components/DebugFieldDisplay' @@ -76,13 +85,30 @@ import {useAnalytics} from '#/analytics' import {useActorStatus} from '#/features/liveNow' import * as bsky from '#/types/bsky' +export type ThreadItemAnchorReaderSeam = ReaderSeamData & { + onToggle: () => void + sort: string +} + export function ThreadItemAnchor({ item, + readerSeam, + threadPosition, onPostSuccess, threadgateRecord, postSource, }: { item: Extract + /** + * Set in reader view: renders a bracket in the gutter and moves the anchor's + * controls and replies into a seam below the post body. + */ + readerSeam?: ThreadItemAnchorReaderSeam + /** + * Set in linear view when the anchor is part of a self-thread: renders a + * "(x/n)" position chip at the end of the post text. + */ + threadPosition?: ThreadPostPosition onPostSuccess?: (data: OnPostSuccessData) => void threadgateRecord?: AppBskyFeedThreadgate.Record postSource?: PostSource @@ -101,6 +127,8 @@ export function ThreadItemAnchor({ key={postShadow.uri} item={item} isRoot={isRoot} + readerSeam={readerSeam} + threadPosition={threadPosition} postShadow={postShadow} onPostSuccess={onPostSuccess} threadgateRecord={threadgateRecord} @@ -194,6 +222,8 @@ function ThreadItemAnchorParentReplyLine({ const ThreadItemAnchorInner = memo(function ThreadItemAnchorInner({ item, isRoot, + readerSeam, + threadPosition, postShadow, onPostSuccess, threadgateRecord, @@ -201,11 +231,14 @@ const ThreadItemAnchorInner = memo(function ThreadItemAnchorInner({ }: { item: Extract isRoot: boolean + readerSeam?: ThreadItemAnchorReaderSeam + threadPosition?: ThreadPostPosition postShadow: Shadow onPostSuccess?: (data: OnPostSuccessData) => void threadgateRecord?: AppBskyFeedThreadgate.Record postSource?: PostSource }) { + const inReader = !!readerSeam const t = useTheme() const ax = useAnalytics() const {t: l} = useLingui() @@ -446,7 +479,16 @@ const ThreadItemAnchorInner = memo(function ThreadItemAnchorInner({ /> - + + {inReader && ( + + )} + + ) : undefined + } /> + ) : threadPosition ? ( + // Text-less anchors (e.g. image-only) still show their + // position so the numbering reads without gaps. + ) : undefined} )} - + + + )} + + {readerSeam && ( + + )} diff --git a/src/screens/PostThread/components/ThreadItemPost.tsx b/src/screens/PostThread/components/ThreadItemPost.tsx index 8969ecb85..8db22ce03 100644 --- a/src/screens/PostThread/components/ThreadItemPost.tsx +++ b/src/screens/PostThread/components/ThreadItemPost.tsx @@ -1,5 +1,5 @@ import {memo, type ReactNode, useCallback, useMemo, useState} from 'react' -import {View} from 'react-native' +import {type StyleProp, View, type ViewStyle} from 'react-native' import { type AppBskyFeedDefs, type AppBskyFeedThreadgate, @@ -25,11 +25,13 @@ import {type OnPostSuccessData} from '#/state/shell/composer' import {useMergedThreadgateHiddenReplies} from '#/state/threadgate-hidden-replies' import {PostMeta} from '#/view/com/util/PostMeta' import {PreviewableUserAvatar} from '#/view/com/util/UserAvatar' +import {ThreadPositionChip} from '#/screens/PostThread/components/ThreadPositionChip' import { LINEAR_AVI_WIDTH, OUTER_SPACE, REPLY_LINE_WIDTH, } from '#/screens/PostThread/const' +import {type ThreadPostPosition} from '#/screens/PostThread/reader' import {atoms as a, useTheme} from '#/alf' import {DebugFieldDisplay} from '#/components/DebugFieldDisplay' import {useInteractionState} from '#/components/hooks/useInteractionState' @@ -58,6 +60,15 @@ export type ThreadItemPostProps = { moderation?: boolean topBorder?: boolean } + /** + * Adjusts the hover overlay, e.g. to start at the reader bracket edge. + */ + hoverStyle?: StyleProp + /** + * Set in linear view when this post is part of a self-thread: renders a + * "(x/n)" position chip at the end of the post text. + */ + threadPosition?: ThreadPostPosition onPostSuccess?: (data: OnPostSuccessData) => void threadgateRecord?: AppBskyFeedThreadgate.Record } @@ -65,6 +76,8 @@ export type ThreadItemPostProps = { export function ThreadItemPost({ item, overrides, + hoverStyle, + threadPosition, onPostSuccess, threadgateRecord, }: ThreadItemPostProps) { @@ -80,6 +93,8 @@ export function ThreadItemPost({ postShadow={postShadow} threadgateRecord={threadgateRecord} overrides={overrides} + hoverStyle={hoverStyle} + threadPosition={threadPosition} onPostSuccess={onPostSuccess} /> ) @@ -199,6 +214,8 @@ const ThreadItemPostInner = memo(function ThreadItemPostInner({ item, postShadow, overrides, + hoverStyle, + threadPosition, onPostSuccess, threadgateRecord, }: ThreadItemPostProps & { @@ -270,7 +287,7 @@ const ThreadItemPostInner = memo(function ThreadItemPostInner({ const {isActive: live} = useActorStatus(post.author) return ( - + + ) : undefined + } /> {limitLines && ( )} + ) : threadPosition ? ( + // Text-less posts (e.g. image-only) still show their position + // so the numbering reads without gaps. + + + ) : undefined} {post.embed && ( @@ -394,7 +422,13 @@ const ThreadItemPostInner = memo(function ThreadItemPostInner({ ) }) -function SubtleHoverWrapper({children}: {children: ReactNode}) { +function SubtleHoverWrapper({ + hoverStyle, + children, +}: { + hoverStyle?: StyleProp + children: ReactNode +}) { const { state: hover, onIn: onHoverIn, @@ -405,7 +439,7 @@ function SubtleHoverWrapper({children}: {children: ReactNode}) { onPointerEnter={onHoverIn} onPointerLeave={onHoverOut} style={a.pointer}> - + {children} ) diff --git a/src/screens/PostThread/components/ThreadItemReaderSegment.tsx b/src/screens/PostThread/components/ThreadItemReaderSegment.tsx new file mode 100644 index 000000000..52db47a32 --- /dev/null +++ b/src/screens/PostThread/components/ThreadItemReaderSegment.tsx @@ -0,0 +1,188 @@ +import {memo, useMemo} from 'react' +import {View} from 'react-native' +import { + type AppBskyFeedDefs, + type AppBskyFeedThreadgate, + RichText as RichTextAPI, +} from '@atproto/api' +import {Trans} from '@lingui/react/macro' + +import { + POST_TOMBSTONE, + type Shadow, + usePostShadow, +} from '#/state/cache/post-shadow' +import {type OnPostSuccessData} from '#/state/shell/composer' +import {ReaderSeam} from '#/screens/PostThread/components/ReaderSeam' +import {ReaderBracket} from '#/screens/PostThread/components/ReaderSeamControls' +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 {Trash_Stroke2_Corner0_Rounded as TrashIcon} from '#/components/icons/Trash' +import {GalleryBleed} from '#/components/images/Gallery' +import {LabelsOnMyPost} from '#/components/moderation/LabelsOnMe' +import {PostAlerts} from '#/components/moderation/PostAlerts' +import {Embed, PostEmbedViewContext} from '#/components/Post/Embed' +import {TranslatedPost} from '#/components/Post/Translated' +import {RichText} from '#/components/RichText' +import {Text} from '#/components/Typography' + +export type ThreadItemReaderSegmentProps = { + item: ReaderSegmentItem + sort: string + onToggleSeam: (uri: string) => void + onPostSuccess?: (data: OnPostSuccessData) => void + threadgateRecord?: AppBskyFeedThreadgate.Record +} + +/** + * A continuation post in reader view. Renders the post body beside the bracket + * gutter, with no avatar or meta row, so consecutive segments read as one + * continuous post, followed by its seam toggle inside the same bracket. + */ +export function ThreadItemReaderSegment({ + item, + sort, + onToggleSeam, + onPostSuccess, + threadgateRecord, +}: ThreadItemReaderSegmentProps) { + const postShadow = usePostShadow(item.item.value.post) + + if (postShadow === POST_TOMBSTONE) { + return + } + + return ( + + ) +} + +function ThreadItemReaderSegmentDeleted() { + const t = useTheme() + + return ( + + + + + Post has been deleted + + + + ) +} + +const ThreadItemReaderSegmentInner = memo( + function ThreadItemReaderSegmentInner({ + item, + postShadow, + sort, + onToggleSeam, + onPostSuccess, + threadgateRecord, + }: ThreadItemReaderSegmentProps & { + postShadow: Shadow + }) { + const post = postShadow + const seam = item.seam + const record = item.item.value.post.record + const moderation = item.item.moderation + const richText = useMemo( + () => + new RichTextAPI({ + text: record.text, + facets: record.facets, + }), + [record], + ) + return ( + + + + + {seam.expanded && ( + <> + + + + )} + {richText?.text ? ( + + {/* Intentionally not line-limited: reader view is for + uninterrupted reading */} + + + ) : undefined} + + {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. */} + + + ) + }, +) diff --git a/src/screens/PostThread/components/ThreadPositionChip.tsx b/src/screens/PostThread/components/ThreadPositionChip.tsx new file mode 100644 index 000000000..33bd9a500 --- /dev/null +++ b/src/screens/PostThread/components/ThreadPositionChip.tsx @@ -0,0 +1,23 @@ +import {type ThreadPostPosition} from '#/screens/PostThread/reader' +import {atoms as a, useTheme} from '#/alf' +import {Text} 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. + */ +export function ThreadPositionChip({ + threadPosition, +}: { + threadPosition: ThreadPostPosition +}) { + const t = useTheme() + + return ( + + {` (${threadPosition.position}/${threadPosition.postCount})`} + + ) +} diff --git a/src/screens/PostThread/const.ts b/src/screens/PostThread/const.ts index cf559ac4e..09f8feb8b 100644 --- a/src/screens/PostThread/const.ts +++ b/src/screens/PostThread/const.ts @@ -5,3 +5,8 @@ export const TREE_AVI_WIDTH = 24 export const LINEAR_AVI_WIDTH = 42 export const REPLY_LINE_WIDTH = 2 export const OUTER_SPACE = tokens.space.lg +export const READER_LINE_INDENT = tokens.space.sm +export const READER_BRACKET_WIDTH = 1 +// Fixed height for the seam's interaction row, so the bracket's bottom can be +// placed at its vertical center (level with the action icons). +export const READER_SEAM_HEIGHT = 28 diff --git a/src/screens/PostThread/index.tsx b/src/screens/PostThread/index.tsx index 88a1510ce..632f4605d 100644 --- a/src/screens/PostThread/index.tsx +++ b/src/screens/PostThread/index.tsx @@ -7,10 +7,11 @@ import { useState, } from 'react' import {useWindowDimensions, View} from 'react-native' -import Animated, {useAnimatedStyle} from 'react-native-reanimated' +import Animated, {FadeIn, useAnimatedStyle} from 'react-native-reanimated' import {moderatePost} from '@atproto/api' -import {Trans} from '@lingui/react/macro' +import {Trans, useLingui} from '@lingui/react/macro' +import {HITSLOP_10} from '#/lib/constants' import {useInitialNumToRender} from '#/lib/hooks/useInitialNumToRender' import {useNonReactiveCallback} from '#/lib/hooks/useNonReactiveCallback' import {useOpenComposer} from '#/lib/hooks/useOpenComposer' @@ -24,10 +25,9 @@ import { ALSO_LIKED_PAGE_SIZE, usePostAlsoLikedQuery, } from '#/state/queries/post-also-liked' -import {type ThreadViewOption} from '#/state/queries/preferences/useThreadPreferences' +import {type ThreadView} from '#/state/queries/preferences/useThreadPreferences' import { PostThreadContextProvider, - type ThreadItem, usePostThread, } from '#/state/queries/usePostThread' import {useSession} from '#/state/session' @@ -36,6 +36,7 @@ import {useShellLayout} from '#/state/shell/shell-layout' import {useUnstablePostSource} from '#/state/unstable-post-source' import {List, type ListMethods} from '#/view/com/util/List' import {HeaderDropdown} from '#/screens/PostThread/components/HeaderDropdown' +import {HeaderReaderToggle} from '#/screens/PostThread/components/HeaderReaderToggle' import {ThreadAlsoLiked} from '#/screens/PostThread/components/ThreadAlsoLiked' import {ThreadComposePrompt} from '#/screens/PostThread/components/ThreadComposePrompt' import {ThreadError} from '#/screens/PostThread/components/ThreadError' @@ -50,6 +51,7 @@ import { } from '#/screens/PostThread/components/ThreadItemPost' import {ThreadItemPostNoUnauthenticated} from '#/screens/PostThread/components/ThreadItemPostNoUnauthenticated' import {ThreadItemPostTombstone} from '#/screens/PostThread/components/ThreadItemPostTombstone' +import {ThreadItemReaderSegment} from '#/screens/PostThread/components/ThreadItemReaderSegment' import {ThreadItemReadMore} from '#/screens/PostThread/components/ThreadItemReadMore' import {ThreadItemReadMoreUp} from '#/screens/PostThread/components/ThreadItemReadMoreUp' import {ThreadItemReplyComposerSkeleton} from '#/screens/PostThread/components/ThreadItemReplyComposer' @@ -58,15 +60,30 @@ import { ThreadItemTreePost, ThreadItemTreePostSkeleton, } from '#/screens/PostThread/components/ThreadItemTreePost' -import {atoms as a, native, platform, useBreakpoints, web} from '#/alf' +import { + buildReaderThread, + computeSelfThreadPositions, + type ReaderItem, +} from '#/screens/PostThread/reader' +import {atoms as a, native, platform, tokens, useBreakpoints, useTheme, web} from '#/alf' +import {Button} from '#/components/Button' +import {ChevronTop_Stroke2_Corner0_Rounded as ChevronTopIcon} from '#/components/icons/Chevron' import * as Layout from '#/components/Layout' +import {ListFooter} from '#/components/Lists' +import {Text} from '#/components/Typography' import {useAnalytics} from '#/analytics' import {IS_NATIVE} from '#/env' const PARENT_CHUNK_SIZE = IS_NATIVE ? 5 : 20 const CHILDREN_CHUNK_SIZE = 50 -export function PostThread({uri}: {uri: string}) { +export function PostThread({ + uri, + initialView, +}: { + uri: string + initialView?: 'reader' +}) { const ax = useAnalytics() const {gtMobile} = useBreakpoints() const {hasSession} = useSession() @@ -79,10 +96,19 @@ export function PostThread({uri}: {uri: string}) { hasSession, ) + /* + * Reader view always reads the full thread from the start, so while it's + * active the query is re-anchored at the thread root. When entering the + * screen directly in reader view, the anchor data isn't available yet, so + * the root starts as the anchor itself and the correction effect below + * re-anchors once data arrives. + */ + const [readerRoot, setReaderRoot] = useState(null) + /* * One query to rule them all */ - const thread = usePostThread({anchor: uri}) + const thread = usePostThread({anchor: readerRoot ?? uri, initialView}) // eslint-disable-next-line react-hooks/preserve-manual-memoization -- restored memoization const {anchor, hasParents} = useMemo(() => { let hasParents = false @@ -392,20 +418,103 @@ export function PostThread({uri}: {uri: string}) { const setSortWrapped = useCallback( (sort: string) => { + setExpandedSeamUri(null) prepareForParamsUpdate() thread.actions.setSort(sort) }, [thread, prepareForParamsUpdate], ) + /* + * Reader view state. At most one seam is open at a time, tracked by its post + * URI (null when none). Resets any time thread params change. + */ + const [expandedSeamUri, setExpandedSeamUri] = useState(null) + const toggleSeam = useCallback( + (seamUri: string) => { + const willExpand = expandedSeamUri !== seamUri + ax.metric('thread:click:readerSeamToggle', {expanded: willExpand}) + setExpandedSeamUri(willExpand ? seamUri : null) + }, + [ax, expandedSeamUri], + ) + /** + * The view to restore when the header reader toggle is switched off. Null + * until a non-reader view is seen this visit (e.g. when deep-linked + * directly into reader view), in which case the user's saved preference is + * used instead. + */ + const lastNonReaderView = useRef(null) + const setViewWrapped = useCallback( - (view: ThreadViewOption) => { + (view: ThreadView) => { + if (view === 'reader') { + if (thread.state.view !== 'reader') { + lastNonReaderView.current = thread.state.view + } + /* + * Reader always reads from the start of the thread. If the anchor + * data hasn't loaded yet, the effect below corrects the root once it + * arrives. + */ + setReaderRoot( + anchor?.value.post.record.reply?.root?.uri ?? anchor?.uri ?? uri, + ) + } else { + setReaderRoot(null) + } + setExpandedSeamUri(null) prepareForParamsUpdate() thread.actions.setView(view) }, - [thread, prepareForParamsUpdate], + [thread, anchor, uri, prepareForParamsUpdate], ) + /* + * If reader view was entered before the thread data was available, the + * root couldn't be derived at toggle time. Correct it once data arrives. + */ + const readerRootCorrection = + thread.state.view === 'reader' && anchor?.type === 'threadPost' + ? (anchor.value.post.record.reply?.root?.uri ?? null) + : null + useEffect(() => { + if (readerRootCorrection && readerRootCorrection !== readerRoot) { + /* + * One-shot correction after async data arrival (cold deep-link into a + * mid-thread post). Re-anchoring requires the same scroll and + * pagination reset as any other params change. + */ + // eslint-disable-next-line react-hooks/set-state-in-effect + prepareForParamsUpdate() + setReaderRoot(readerRootCorrection) + } + }, [readerRootCorrection, readerRoot, prepareForParamsUpdate]) + + const onPressReaderToggle = useCallback(() => { + const enabled = thread.state.view !== 'reader' + ax.metric('thread:click:readerToggle', {enabled, via: 'header'}) + setViewWrapped( + enabled + ? 'reader' + : (lastNonReaderView.current ?? thread.state.savedView), + ) + }, [ax, thread.state.view, thread.state.savedView, setViewWrapped]) + + /** + * Whether to surface the reader toggle. The anchor must itself be part of the + * OP self-thread - the root, or an `opThread` post mid-chain - otherwise the + * chain is just incidental context above a reply the user navigated into. + */ + const hasOpThreadChain = useMemo(() => { + if (anchor?.type !== 'threadPost') return false + if (!(isRoot || anchor.value.opThread)) return false + return thread.data.items.some( + item => + item.type === 'threadPost' && item.depth !== 0 && item.value.opThread, + ) + }, [thread.data.items, anchor, isRoot]) + const onStartReached = () => { if (thread.state.isFetching) return // can be true after `prepareForParamsUpdate` is called @@ -444,18 +553,46 @@ export function PostThread({uri}: {uri: string}) { } } + /* + * In reader view, the OP self-thread chain is collapsed into continuous + * segments separated by expandable seams before pagination slicing. + */ + const reader = useMemo(() => { + return thread.state.view === 'reader' + ? buildReaderThread(thread.data.items, {expandedSeamUri}) + : null + }, [thread.state.view, thread.data.items, expandedSeamUri]) + const sourceItems = reader?.items ?? thread.data.items + + /* + * In linear view, self-thread posts - the OP thread and multi-part replies + * alike - get "(x/n)" position chips at the end of their text. + */ + const threadPositions = useMemo(() => { + return thread.state.view === 'linear' + ? computeSelfThreadPositions(thread.data.items) + : undefined + }, [thread.state.view, thread.data.items]) + + /* + * Show a floating collapse button when the open seam has replies, so the + * user can close them without scrolling back up. + */ + const showHideRepliesButton = + !!expandedSeamUri && (reader?.expandedSeam?.hiddenReplyCount ?? 0) > 0 + const slices = useMemo(() => { - const results: ThreadItem[] = [] + const results: ReaderItem[] = [] - if (!thread.data.items.length) return results + if (!sourceItems.length) return results /* * Pagination hack, tracks the # of items below the anchor post. */ let childrenCount = 0 - for (let i = 0; i < thread.data.items.length; i++) { - const item = thread.data.items[i] + for (let i = 0; i < sourceItems.length; i++) { + const item = sourceItems[i] /* * Need to check `depth`, since not found or blocked posts are not * `threadPost`s, but still have `depth`. @@ -471,7 +608,7 @@ export function PostThread({uri}: {uri: string}) { // Recalculate total parents current index. totalParentCount.current = i // Recalculate total children using (length - 1) - current index. - totalChildrenCount.current = thread.data.items.length - 1 - i + totalChildrenCount.current = sourceItems.length - 1 - i /* * Walk up the parents, limiting by `maxParentCount` @@ -481,7 +618,7 @@ export function PostThread({uri}: {uri: string}) { if (start >= 0) { const limit = Math.max(0, start - maxParentCount) for (let pi = start; pi >= limit; pi--) { - results.unshift(thread.data.items[pi]) + results.unshift(sourceItems[pi]) } } } @@ -497,7 +634,7 @@ export function PostThread({uri}: {uri: string}) { } return results - }, [thread, deferParents, maxParentCount, maxChildrenCount]) + }, [sourceItems, deferParents, maxParentCount, maxChildrenCount]) /** * Defer rendering reply skeletons so that the anchor post (from cache) @@ -529,7 +666,7 @@ export function PostThread({uri}: {uri: string}) { }, [deferredSlices]) const renderItem = useCallback( - ({item, index}: {item: ThreadItem; index: number}) => { + ({item, index}: {item: ReaderItem; index: number}) => { if (item.type === 'threadPost') { if (item.depth < 0) { return ( @@ -539,6 +676,7 @@ export function PostThread({uri}: {uri: string}) { overrides={{ topBorder: index === 0, }} + threadPosition={threadPositions?.get(item.uri)} onPostSuccess={optimisticOnPostReply} /> ) @@ -566,6 +704,16 @@ export function PostThread({uri}: {uri: string}) { /> toggleSeam(item.uri), + sort: thread.state.sort, + } + : undefined + } + threadPosition={threadPositions?.get(item.uri)} threadgateRecord={thread.data.threadgate?.record ?? undefined} onPostSuccess={optimisticOnPostReply} postSource={anchorPostSource} @@ -592,11 +740,22 @@ export function PostThread({uri}: {uri: string}) { overrides={{ moderation: thread.state.otherItemsVisible && item.depth > 0, }} + threadPosition={threadPositions?.get(item.uri)} onPostSuccess={optimisticOnPostReply} /> ) } } + } else if (item.type === 'readerSegment') { + return ( + + ) } else if (item.type === 'threadPostNoUnauthenticated') { if (item.depth < 0) { return @@ -643,6 +802,9 @@ export function PostThread({uri}: {uri: string}) { }, [ thread, + reader, + threadPositions, + toggleSeam, optimisticOnPostReply, onReplyToAnchor, gtMobile, @@ -757,12 +919,21 @@ export function PostThread({uri}: {uri: string}) { )} + {(hasOpThreadChain || thread.state.view === 'reader') && ( + + + + )} @@ -787,10 +958,12 @@ export function PostThread({uri}: {uri: string}) { onEndReachedThreshold={2} onStartReachedThreshold={1} onScrollOffsetChange={handleScrollOffsetChange} - onItemSeen={item => { + onItemSeen={(item: ReaderItem) => { // Track post:view for parent posts and replies (non-anchor posts) if (item.type === 'threadPost' && item.depth !== 0) { trackThreadItemView(item.value.post) + } else if (item.type === 'readerSegment') { + trackThreadItemView(item.item.value.post) } }} /** @@ -846,13 +1019,70 @@ export function PostThread({uri}: {uri: string}) { /> )} - {!gtMobile && canReply && hasSession && ( + {!gtMobile && + canReply && + hasSession && + thread.state.view !== 'reader' && ( )} + + {showHideRepliesButton && ( + toggleSeam(expandedSeamUri)} /> + )} ) } +/** + * Floating pill shown while a reader seam is expanded to show replies, so + * they can be collapsed from anywhere without scrolling back to the seam. + */ +function ReaderHideRepliesButton({onPress}: {onPress: () => void}) { + const t = useTheme() + const {t: l} = useLingui() + const {footerHeight} = useShellLayout() + + const animatedStyle = useAnimatedStyle(() => { + return { + bottom: footerHeight.get() + tokens.space.lg, + } + }) + + return ( + + + + ) +} + function MobileComposePrompt({onPressReply}: {onPressReply: () => unknown}) { const {footerHeight} = useShellLayout() @@ -869,7 +1099,7 @@ function MobileComposePrompt({onPressReply}: {onPressReply: () => unknown}) { ) } -const keyExtractor = (item: ThreadItem) => { +const keyExtractor = (item: ReaderItem) => { return item.key } diff --git a/src/screens/PostThread/reader.ts b/src/screens/PostThread/reader.ts new file mode 100644 index 000000000..21fb67d56 --- /dev/null +++ b/src/screens/PostThread/reader.ts @@ -0,0 +1,333 @@ +import {AtUri} from '@atproto/api' + +import {makeProfileLink} from '#/lib/routes/links' +import {type ThreadItem} from '#/state/queries/usePostThread/types' + +export type ThreadPostItem = Extract + +/** + * A toggle rendered after a post, inside its bracket. Expanding it reveals the + * post's actions and replies, so the post bodies themselves stay plain, + * selectable text. + */ +export type ReaderSeam = { + expanded: boolean + /** + * Number of replies to this post that are not hydrated in the linear + * response and are therefore not rendered anywhere on this screen. + */ + hiddenReplyCount: number + /** + * URI of the next post in the OP chain, which renders as its own segment. + * Excluded from the seam's fetched replies. + */ + continuationUri: string + href: string +} + +export type ReaderSegmentItem = { + type: 'readerSegment' + key: string + uri: string + depth: number + item: ThreadPostItem + /** The seam rendered after this post, within its bracket. */ + seam: ReaderSeam +} + +export type ReaderItem = ThreadItem | ReaderSegmentItem + +/** + * Transforms linear thread items into the reader view: the anchor post plus + * the OP's contiguous self-thread collapse into continuous "segments" of plain + * text, with a "seam" toggle after each post that reveals its actions and + * replies, so a multi-part thread reads as one post. + * + * Items before the anchor (parents) pass through unchanged. If no OP chain is + * found, the input is returned as-is. + */ +export function buildReaderThread( + items: ThreadItem[], + {expandedSeamUri}: {expandedSeamUri: string | null}, +): { + items: ReaderItem[] + /** The anchor's seam, rendered inside the anchor post. */ + anchorSeam?: ReaderSeam + /** The open seam, if any. Resolved here to spare consumers a scan. */ + expandedSeam?: ReaderSeam +} { + const noChain = { + items: items as ReaderItem[], + anchorSeam: undefined as ReaderSeam | undefined, + expandedSeam: undefined as ReaderSeam | undefined, + } + + const anchorIndex = items.findIndex( + item => item.type === 'threadPost' && item.depth === 0, + ) + if (anchorIndex === -1) return noChain + + const anchor = items[anchorIndex] as ThreadPostItem + const anchorDid = anchor.value.post.author.did + + /* + * Locate the start of the OP self-thread chain: a depth-1 reply by the + * anchor author marked `opThread` by the appview. The appview usually + * serves it directly below the anchor, but depending on sort it may not be + * the first sibling, so scan all hydrated depth-1 replies. + */ + const chainStart = items.findIndex( + (item, i) => + i > anchorIndex && + item.type === 'threadPost' && + item.depth === 1 && + item.value.opThread && + item.value.post.author.did === anchorDid, + ) + if (chainStart === -1) return noChain + + /* + * Collect the contiguous chain. Branches are served depth-first, one post + * per level in linear view, so the chain is a contiguous run of items each + * one level deeper than the last. Anything else - a tombstone, a read more + * link, a reply by someone else - ends the chain, and we collapse only the + * clean prefix. + */ + const chain: ThreadPostItem[] = [items[chainStart] as ThreadPostItem] + let chainEnd = chainStart + for (let i = chainStart + 1; i < items.length; i++) { + const item = items[i] + const prev = chain[chain.length - 1] + if ( + item.type === 'threadPost' && + item.depth === prev.depth + 1 && + item.value.opThread && + item.value.post.author.did === anchorDid + ) { + chain.push(item) + chainEnd = i + } else { + break + } + } + + /* + * 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. + */ + let chainReadMore: ThreadItem | undefined + const afterChain = items[chainEnd + 1] + if (afterChain?.type === 'readMore') { + chainReadMore = afterChain + chainEnd++ + } + + const result: ReaderItem[] = items.slice(0, anchorIndex + 1) + + 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. + */ + const anchorSeam = createSeam(anchor, { + expandedSeamUri, + continuationUri: chain[0].uri, + fullCount: true, + }) + if (anchorSeam.expanded) expandedSeam = anchorSeam + + for (let i = 0; i < chain.length; i++) { + const post = chain[i] + const continuation = chain[i + 1] + const seam = createSeam(post, { + expandedSeamUri, + continuationUri: continuation?.uri ?? '', + fullCount: !continuation, + }) + if (seam.expanded) expandedSeam = seam + result.push({ + type: 'readerSegment', + key: `readerSegment:${post.uri}`, + uri: post.uri, + depth: post.depth, + item: post, + seam, + }) + } + + if (chainReadMore) { + result.push(chainReadMore) + } + + return {items: result, anchorSeam, expandedSeam} +} + +export type ThreadPostPosition = { + /** 1-based position in the self-thread, where its first post is 1. */ + position: number + /** Number of posts in the self-thread. */ + postCount: number +} + +/** + * Maps each post of a self-thread - a run of consecutive replies by one + * author, i.e. the OP thread from the root or a multi-part reply - to its + * "(x/n)" position for the linear view's indicator chips. + * + * Chains are only numbered when the numbers can be trusted: + * + * - The start must be verifiably the chain's first post: the thread root, or + * a reply whose parent is hydrated and by another author. Same-author + * forks and posts with unhydrated parents are skipped since their true + * position is unknowable. + * - The OP chain is fetched exhaustively (see `extendSelfThreadChain`), so it is + * always complete. Other chains are cut off by the fetch depth cap, which + * surfaces as a "read more" after the last post - skip those rather than + * show a wrong total. + */ +export function computeSelfThreadPositions( + items: ThreadItem[], +): Map | undefined { + const positions = new Map() + const claimed = new Set() + + for (let i = 0; i < items.length; i++) { + const item = items[i] + if (item.type !== 'threadPost' || claimed.has(item.uri)) continue + if (!isValidChainStart(items, i)) continue + + const {chain, tipIndex} = collectChain(items, i) + for (const post of chain) claimed.add(post.uri) + if (chain.length < 2) continue + + const tip = chain[chain.length - 1] + if (!tip.value.opThread && isCutOffByDepthCap(items, tipIndex)) continue + + for (let k = 0; k < chain.length; k++) { + positions.set(chain[k].uri, { + position: k + 1, + postCount: chain.length, + }) + } + } + + return positions.size ? positions : undefined +} + +/** + * A chain start is trustworthy when it is the thread root, or a reply whose + * parent - the nearest preceding item one level up - is hydrated and by + * another author. A hydrated same-author parent means this post is a fork + * off a chain that was numbered (or skipped) already. + */ +function isValidChainStart(items: ThreadItem[], index: number): boolean { + const item = items[index] as ThreadPostItem + if (!item.value.post.record.reply) return true + const did = item.value.post.author.did + for (let j = index - 1; j >= 0; j--) { + const prev = items[j] + if (prev.type === 'replyComposer') continue + if (!('depth' in prev)) return false + if (prev.depth < item.depth) { + return ( + prev.depth === item.depth - 1 && + prev.type === 'threadPost' && + prev.value.post.author.did !== did + ) + } + } + return false +} + +/** + * Collects the self-thread starting at `startIndex`: repeatedly finds the + * next same-author child. Children are scanned across the parent's whole + * subtree, not just the adjacent item, since the anchor's direct replies are + * all hydrated and the continuation may sort after other replies. + */ +function collectChain( + items: ThreadItem[], + startIndex: number, +): {chain: ThreadPostItem[]; tipIndex: number} { + const start = items[startIndex] as ThreadPostItem + const did = start.value.post.author.did + const chain = [start] + let tipIndex = startIndex + + let next = findChainChild(items, tipIndex, did) + while (next !== -1) { + chain.push(items[next] as ThreadPostItem) + tipIndex = next + next = findChainChild(items, tipIndex, did) + } + return {chain, tipIndex} +} + +function findChainChild( + items: ThreadItem[], + parentIndex: number, + did: string, +): number { + const parent = items[parentIndex] as ThreadPostItem + for (let j = parentIndex + 1; j < items.length; j++) { + const item = items[j] + if (item.type === 'replyComposer') continue + if (!('depth' in item) || item.depth <= parent.depth) break + if ( + item.depth === parent.depth + 1 && + item.type === 'threadPost' && + item.value.post.author.did === did + ) { + return j + } + } + return -1 +} + +/** + * A "read more" immediately after a chain's last post means that post sits + * at the fetch depth cap with nothing of its subtree hydrated - the chain + * may well continue behind it. + */ +function isCutOffByDepthCap(items: ThreadItem[], tipIndex: number): boolean { + for (let j = tipIndex + 1; j < items.length; j++) { + const item = items[j] + if (item.type === 'replyComposer') continue + return item.type === 'readMore' + } + return false +} + +function createSeam( + post: ThreadPostItem, + { + expandedSeamUri, + continuationUri, + fullCount, + }: { + expandedSeamUri: string | null + continuationUri: string + fullCount: 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. + */ + const hiddenReplyCount = fullCount + ? (post.value.post.replyCount ?? 0) + : post.value.moreReplies || 0 + return { + expanded: expandedSeamUri === post.uri, + hiddenReplyCount, + continuationUri, + href: makeProfileLink(post.value.post.author, 'post', urip.rkey), + } +} diff --git a/src/state/queries/preferences/useThreadPreferences.ts b/src/state/queries/preferences/useThreadPreferences.ts index 629d1c61b..aa5e5a82d 100644 --- a/src/state/queries/preferences/useThreadPreferences.ts +++ b/src/state/queries/preferences/useThreadPreferences.ts @@ -17,18 +17,38 @@ export type ThreadSortOption = Literal< string > export type ThreadViewOption = 'linear' | 'tree' +/** + * All views supported by the thread screen. `reader` is a per-visit, + * client-only view and is never persisted to server preferences. + */ +export type ThreadView = ThreadViewOption | 'reader' export type ThreadPreferences = { isLoaded: boolean isSaving: boolean sort: ThreadSortOption setSort: (sort: string) => void - view: ThreadViewOption - setView: (view: ThreadViewOption) => void + view: ThreadView + setView: (view: ThreadView) => void + /** + * The user's persisted view preference. Reader view is never persisted, so + * this is always a concrete linear/tree value, e.g. for restoring the view + * when exiting reader. + */ + savedView: ThreadViewOption } export function useThreadPreferences({ save, -}: {save?: boolean} = {}): ThreadPreferences { + initialView, +}: { + save?: boolean + /** + * Overrides the view for the first render, e.g. when deep-linking into + * reader view. Server prefs sync still applies, but never clobbers an + * active reader view. + */ + initialView?: ThreadView +} = {}): ThreadPreferences { const ax = useAnalytics() const {data: preferences} = usePreferencesQuery() const serverPrefs = preferences?.threadViewPrefs @@ -38,10 +58,12 @@ export function useThreadPreferences({ * Create local state representations of server state */ const [sort, setSort] = useState(normalizeSort(serverPrefs?.sort || 'top')) - const [view, setView] = useState( - normalizeView({ - treeViewEnabled: !!serverPrefs?.lab_treeViewEnabled, - }), + const [view, setView] = useState( + () => + initialView ?? + normalizeView({ + treeViewEnabled: !!serverPrefs?.lab_treeViewEnabled, + }), ) /** @@ -53,14 +75,17 @@ export function useThreadPreferences({ setPrevServerPrefs(serverPrefs) /* - * Update + * Update. Reader view is client-only, so a background preferences + * refetch should not kick the user out of it. */ setSort(normalizeSort(serverPrefs.sort)) - setView( - normalizeView({ - treeViewEnabled: !!serverPrefs.lab_treeViewEnabled, - }), - ) + if (view !== 'reader') { + setView( + normalizeView({ + treeViewEnabled: !!serverPrefs.lab_treeViewEnabled, + }), + ) + } once(() => { ax.metric('thread:preferences:load', { @@ -104,10 +129,14 @@ export function useThreadPreferences({ ) if (save && userUpdatedPrefs.current) { - savePrefs({ - sort, - lab_treeViewEnabled: view === 'tree', - }) + // Reader view is never persisted, and must not clobber the user's saved + // linear/tree preference. + if (view !== 'reader') { + savePrefs({ + sort, + lab_treeViewEnabled: view === 'tree', + }) + } userUpdatedPrefs.current = false } @@ -119,13 +148,17 @@ export function useThreadPreferences({ [setSort], ) const setViewWrapped = useCallback( - (next: ThreadViewOption) => { + (next: ThreadView) => { userUpdatedPrefs.current = true setView(next) }, [setView], ) + const savedView = normalizeView({ + treeViewEnabled: !!serverPrefs?.lab_treeViewEnabled, + }) + return useMemo( () => ({ isLoaded, @@ -134,8 +167,9 @@ export function useThreadPreferences({ setSort: setSortWrapped, view, setView: setViewWrapped, + savedView, }), - [isLoaded, isSaving, sort, setSortWrapped, view, setViewWrapped], + [isLoaded, isSaving, sort, setSortWrapped, view, setViewWrapped, savedView], ) } diff --git a/src/state/queries/usePostThread/__tests__/selfThreadChain.test.ts b/src/state/queries/usePostThread/__tests__/selfThreadChain.test.ts new file mode 100644 index 000000000..bdf0484bc --- /dev/null +++ b/src/state/queries/usePostThread/__tests__/selfThreadChain.test.ts @@ -0,0 +1,202 @@ +import { + AppBskyUnspeccedDefs, + type AppBskyUnspeccedGetPostThreadV2, +} from '@atproto/api' + +import {extendSelfThreadChain} from '../selfThreadChain' + +type RawThreadItem = AppBskyUnspeccedGetPostThreadV2.ThreadItem + +const OP_DID = 'did:plc:op' + +function post({ + rkey, + depth, + did = OP_DID, + opThread = true, + moreReplies = 0, + replyCount = 0, +}: { + rkey: string + depth: number + did?: string + opThread?: boolean + moreReplies?: number + replyCount?: number +}): RawThreadItem { + const uri = `at://${did}/app.bsky.feed.post/${rkey}` + return { + uri, + depth, + value: { + $type: 'app.bsky.unspecced.defs#threadItemPost', + post: { + uri, + author: {did, handle: 'user.test'}, + record: {}, + replyCount, + }, + moreParents: false, + moreReplies, + opThread, + hiddenByThreadgate: false, + mutedByViewer: false, + }, + } as unknown as RawThreadItem +} + +const rkeys = (thread: RawThreadItem[]) => + thread.map(item => item.uri.split('/').pop()) + +const depths = (thread: RawThreadItem[]) => thread.map(item => item.depth) + +describe('extendSelfThreadChain', () => { + it('does not fetch when the chain tip has a hydrated child', async () => { + const fetchBelow = jest.fn() + // the tip has replies, but one is hydrated below it, so the tip did not + // sit at the response's depth cap and the chain ended for real + const thread = [ + post({rkey: 'a', depth: 0, replyCount: 1}), + post({rkey: 'b', depth: 1, replyCount: 1}), + post({rkey: 'c', depth: 2, replyCount: 1, moreReplies: 0}), + post({rkey: 'x', depth: 3, did: 'did:plc:other'}), + ] + const result = await extendSelfThreadChain({thread, fetchBelow}) + + expect(fetchBelow).not.toHaveBeenCalled() + expect(result).toBe(thread) + }) + + it('extends a chain truncated at the depth cap', async () => { + const thread = [ + post({rkey: 'a', depth: 0, replyCount: 1}), + post({rkey: 'b', depth: 1, replyCount: 1}), + post({rkey: 'c', depth: 2, replyCount: 1}), + post({rkey: 'd', depth: 3, replyCount: 2, moreReplies: 2}), + ] + const fetchBelow = jest + .fn() + .mockResolvedValue([ + post({rkey: 'd', depth: 0, replyCount: 2, moreReplies: 1}), + post({rkey: 'e', depth: 1, replyCount: 1}), + post({rkey: 'f', depth: 2}), + ]) + const result = await extendSelfThreadChain({thread, fetchBelow}) + + expect(fetchBelow).toHaveBeenCalledTimes(1) + expect(fetchBelow).toHaveBeenCalledWith(thread[3].uri) + expect(rkeys(result)).toEqual(['a', 'b', 'c', 'd', 'e', 'f']) + // continuation depths are made absolute + expect(depths(result)).toEqual([0, 1, 2, 3, 4, 5]) + // the old tip's continuation is now hydrated, so moreReplies drops by one + const tipValue = result[3].value + if (!AppBskyUnspeccedDefs.isThreadItemPost(tipValue)) { + throw new Error('expected tip to be a post') + } + expect(tipValue.moreReplies).toBe(1) + }) + + it('keeps fetching until the chain ends naturally', async () => { + const thread = [ + post({rkey: 'a', depth: 0, replyCount: 1}), + post({rkey: 'b', depth: 1, replyCount: 1}), + post({rkey: 'c', depth: 2, replyCount: 1}), + post({rkey: 'd', depth: 3, replyCount: 1, moreReplies: 1}), + ] + const pages: RawThreadItem[][] = [ + [ + post({rkey: 'd', depth: 0, replyCount: 1}), + post({rkey: 'e', depth: 1, replyCount: 1}), + post({rkey: 'f', depth: 2, replyCount: 1}), + post({rkey: 'g', depth: 3, replyCount: 1, moreReplies: 1}), + ], + [post({rkey: 'g', depth: 0, replyCount: 1}), post({rkey: 'h', depth: 1})], + ] + const fetchBelow = jest.fn(() => Promise.resolve(pages.shift()!)) + const result = await extendSelfThreadChain({thread, fetchBelow}) + + expect(fetchBelow).toHaveBeenCalledTimes(2) + expect(rkeys(result)).toEqual(['a', 'b', 'c', 'd', 'e', 'f', 'g', 'h']) + expect(depths(result)).toEqual([0, 1, 2, 3, 4, 5, 6, 7]) + }) + + it('stops at maxFetches even if the chain continues', async () => { + const thread = [ + post({rkey: 'a', depth: 0, replyCount: 1}), + post({rkey: 'b0', depth: 1, replyCount: 1}), + post({rkey: 'b1', depth: 2, replyCount: 1}), + post({rkey: 'b2', depth: 3, replyCount: 1, moreReplies: 1}), + ] + let n = 2 + const fetchBelow = jest.fn((anchorUri: string) => { + const anchorRkey = anchorUri.split('/').pop() + return Promise.resolve([ + post({rkey: anchorRkey!, depth: 0, replyCount: 1}), + post({rkey: `c${n}`, depth: 1, replyCount: 1}), + post({rkey: `c${n + 1}`, depth: 2, replyCount: 1}), + post({rkey: `c${n++}x`, depth: 3, replyCount: 1, moreReplies: 1}), + ]) + }) + const result = await extendSelfThreadChain({ + thread, + maxFetches: 2, + fetchBelow, + }) + + expect(fetchBelow).toHaveBeenCalledTimes(2) + expect(result.length).toBe(4 + 2 * 3) + }) + + it('stops when the continuation has no OP chain', async () => { + const thread = [ + post({rkey: 'a', depth: 0, replyCount: 1}), + post({rkey: 'b', depth: 1, replyCount: 1}), + post({rkey: 'c', depth: 2, replyCount: 1}), + post({rkey: 'd', depth: 3, replyCount: 1, moreReplies: 1}), + ] + const fetchBelow = jest + .fn() + .mockResolvedValue([ + post({rkey: 'd', depth: 0, replyCount: 1}), + post({rkey: 'x', depth: 1, did: 'did:plc:other'}), + ]) + const result = await extendSelfThreadChain({thread, fetchBelow}) + + expect(fetchBelow).toHaveBeenCalledTimes(1) + expect(rkeys(result)).toEqual(['a', 'b', 'c', 'd']) + }) + + it('skips the fetch when the tip has no replies', async () => { + const fetchBelow = jest.fn() + const thread = [ + post({rkey: 'a', depth: 0, replyCount: 1}), + post({rkey: 'b', depth: 1, replyCount: 1}), + post({rkey: 'c', depth: 2, replyCount: 1}), + post({rkey: 'd', depth: 3, replyCount: 0, moreReplies: 0}), + ] + const result = await extendSelfThreadChain({thread, fetchBelow}) + + expect(fetchBelow).not.toHaveBeenCalled() + expect(rkeys(result)).toEqual(['a', 'b', 'c', 'd']) + }) + + it('splices the continuation before trailing sibling branches', async () => { + const thread = [ + post({rkey: 'a', depth: 0, replyCount: 2}), + post({rkey: 'b', depth: 1, replyCount: 1}), + post({rkey: 'c', depth: 2, replyCount: 1}), + post({rkey: 'd', depth: 3, replyCount: 1, moreReplies: 1}), + post({rkey: 'z', depth: 1, did: 'did:plc:other', opThread: false}), + ] + const fetchBelow = jest + .fn() + .mockResolvedValue([ + post({rkey: 'd', depth: 0, replyCount: 1}), + post({rkey: 'e', depth: 1}), + ]) + const result = await extendSelfThreadChain({thread, fetchBelow}) + + expect(rkeys(result)).toEqual(['a', 'b', 'c', 'd', 'e', 'z']) + expect(depths(result)).toEqual([0, 1, 2, 3, 4, 1]) + }) +}) diff --git a/src/state/queries/usePostThread/const.ts b/src/state/queries/usePostThread/const.ts index 9b7436130..e0b2a27bb 100644 --- a/src/state/queries/usePostThread/const.ts +++ b/src/state/queries/usePostThread/const.ts @@ -25,3 +25,22 @@ export const TREE_VIEW_BF = undefined * See the `below` param on {@link AppBskyUnspeccedGetPostThreadV2.QueryParams} */ export const TREE_VIEW_BELOW_DESKTOP = 6 + +/** + * Reader view anchors at the thread root and reads the OP chain straight + * down, so it requests the maximum depth the endpoint allows. See the + * `below` param on {@link AppBskyUnspeccedGetPostThreadV2.QueryParams} + * + * Reader view fetches with branchingFactor 1 ({@link LINEAR_VIEW_BF}); the + * seam transform relies on that, see `createSeam` in PostThread/reader.ts. + */ +export const READER_VIEW_BELOW = 20 + +/** + * Upper bound on continuation fetches when extending an OP self-thread past + * the server's depth cap, i.e. at most `(SELF_THREAD_CHAIN_MAX_FETCHES + 1) * + * ` chain posts load per view. The appview currently + * serves at most 10 levels per request regardless of the requested `below`. + * See `extendSelfThreadChain`. + */ +export const SELF_THREAD_CHAIN_MAX_FETCHES = 10 diff --git a/src/state/queries/usePostThread/index.ts b/src/state/queries/usePostThread/index.ts index ceb87fe47..857e2e6c2 100644 --- a/src/state/queries/usePostThread/index.ts +++ b/src/state/queries/usePostThread/index.ts @@ -2,10 +2,14 @@ import {useCallback, useMemo, useState} from 'react' import {useQuery, useQueryClient} from '@tanstack/react-query' import {useModerationOpts} from '#/state/preferences/moderation-opts' -import {useThreadPreferences} from '#/state/queries/preferences/useThreadPreferences' +import { + type ThreadView, + useThreadPreferences, +} from '#/state/queries/preferences/useThreadPreferences' import { LINEAR_VIEW_BELOW, LINEAR_VIEW_BF, + READER_VIEW_BELOW, TREE_VIEW_BELOW, TREE_VIEW_BELOW_DESKTOP, TREE_VIEW_BF, @@ -15,6 +19,7 @@ import { createCacheMutator, getThreadPlaceholder, } from '#/state/queries/usePostThread/queryCache' +import {extendSelfThreadChain} from '#/state/queries/usePostThread/selfThreadChain' import { buildThread, sortAndAnnotateThreadItems, @@ -36,7 +41,13 @@ export * from '#/state/queries/usePostThread/context' export {useUpdatePostThreadThreadgateQueryCache} from '#/state/queries/usePostThread/queryCache' export * from '#/state/queries/usePostThread/types' -export function usePostThread({anchor}: {anchor?: string}) { +export function usePostThread({ + anchor, + initialView, +}: { + anchor?: string + initialView?: ThreadView +}) { const qc = useQueryClient() const agent = useAgent() const {hasSession} = useSession() @@ -49,8 +60,16 @@ export function usePostThread({anchor}: {anchor?: string}) { setSort: baseSetSort, view, setView: baseSetView, - } = useThreadPreferences() + savedView, + } = useThreadPreferences({initialView}) + /* + * Reader view is a client-side presentation of linear-shaped data, fetched + * deeper since it reads the OP chain from the root straight down. The + * data is annotated and cache-mutated as linear via `apiView`. + */ + const apiView: 'linear' | 'tree' = view === 'reader' ? 'linear' : view const below = useMemo(() => { + if (view === 'reader') return READER_VIEW_BELOW return view === 'linear' ? LINEAR_VIEW_BELOW : IS_WEB && gtPhone @@ -73,11 +92,37 @@ export function usePostThread({anchor}: {anchor?: string}) { async queryFn(ctx) { const {data} = await agent.app.bsky.unspecced.getPostThreadV2({ anchor: anchor!, - branchingFactor: view === 'linear' ? LINEAR_VIEW_BF : TREE_VIEW_BF, + branchingFactor: apiView === 'linear' ? LINEAR_VIEW_BF : TREE_VIEW_BF, below, sort: sort, }) + let threadData = data.thread || [] + + /* + * An OP self-thread longer than the depth the server serves arrives + * truncated. Reader view reads the whole chain and linear view numbers + * it with "(x/n)" chips, so extend it with follow-up fetches anchored + * progressively deeper. + */ + if (apiView === 'linear') { + threadData = await extendSelfThreadChain({ + thread: threadData, + fetchBelow: async anchorUri => { + const {data: more} = await agent.app.bsky.unspecced.getPostThreadV2( + { + anchor: anchorUri, + above: false, + branchingFactor: LINEAR_VIEW_BF, + below, + sort: sort, + }, + ) + return more.thread || [] + }, + }) + } + /* * Initialize `ctx.meta` to track if we know we have additional replies * we could fetch once we hit the end. @@ -94,7 +139,7 @@ export function usePostThread({anchor}: {anchor?: string}) { } const result = { - thread: data.thread || [], + thread: threadData, threadgate: data.threadgate, hasOtherReplies: !!ctx.meta.hasOtherReplies, } @@ -144,12 +189,12 @@ export function usePostThread({anchor}: {anchor?: string}) { const mutator = useMemo( () => createCacheMutator({ - params: {view, below}, + params: {view: apiView, below}, postThreadQueryKey, postThreadOtherQueryKey, queryClient: qc, }), - [qc, view, below, postThreadQueryKey, postThreadOtherQueryKey], + [qc, apiView, below, postThreadQueryKey, postThreadOtherQueryKey], ) /** @@ -187,7 +232,7 @@ export function usePostThread({anchor}: {anchor?: string}) { const {threadItems} = sortAndAnnotateThreadItems( additionalItemsQuery.data.thread, { - view, + view: apiView, skipModerationHandling: true, threadgateHiddenReplies: mergeThreadgateHiddenReplies( threadgate?.record, @@ -200,7 +245,7 @@ export function usePostThread({anchor}: {anchor?: string}) { return [] } }, [ - view, + apiView, additionalQueryEnabled, additionalItemsQuery, mergeThreadgateHiddenReplies, @@ -236,7 +281,7 @@ export function usePostThread({anchor}: {anchor?: string}) { */ const {threadItems, otherThreadItems} = useMemo(() => { return sortAndAnnotateThreadItems(thread, { - view: view, + view: apiView, threadgateHiddenReplies: mergeThreadgateHiddenReplies(threadgate?.record), moderationOpts: moderationOpts!, }) @@ -245,7 +290,7 @@ export function usePostThread({anchor}: {anchor?: string}) { threadgate?.record, mergeThreadgateHiddenReplies, moderationOpts, - view, + apiView, ]) /* @@ -294,6 +339,7 @@ export function usePostThread({anchor}: {anchor?: string}) { */ sort, view, + savedView, otherItemsVisible, }, data: { @@ -319,6 +365,7 @@ export function usePostThread({anchor}: {anchor?: string}) { otherItemsVisible, sort, view, + savedView, setSort, setView, threadgate, diff --git a/src/state/queries/usePostThread/selfThreadChain.ts b/src/state/queries/usePostThread/selfThreadChain.ts new file mode 100644 index 000000000..2c4cc6584 --- /dev/null +++ b/src/state/queries/usePostThread/selfThreadChain.ts @@ -0,0 +1,137 @@ +import { + AppBskyUnspeccedDefs, + type AppBskyUnspeccedGetPostThreadV2, +} from '@atproto/api' + +import {SELF_THREAD_CHAIN_MAX_FETCHES} from '#/state/queries/usePostThread/const' + +type RawThreadItem = AppBskyUnspeccedGetPostThreadV2.ThreadItem + +function isOpChainPost( + item: RawThreadItem, + did: string, +): item is RawThreadItem & { + value: AppBskyUnspeccedDefs.ThreadItemPost +} { + return ( + AppBskyUnspeccedDefs.isThreadItemPost(item.value) && + item.value.opThread && + item.value.post.author.did === did + ) +} + +/** + * Locates the OP self-thread chain hanging off the response anchor: the first + * depth-1 `opThread` post by `did`, plus the contiguous one-level-deeper run + * below it. Mirrors the chain walk in PostThread/reader.ts, but over raw + * response items. Chain items are contiguous indices `start..end` because + * branches are served depth-first. + */ +function findChain( + thread: RawThreadItem[], + did: string, +): {start: number; end: number} | undefined { + const start = thread.findIndex( + item => item.depth === 1 && isOpChainPost(item, did), + ) + if (start === -1) return undefined + let end = start + while (end + 1 < thread.length) { + const next = thread[end + 1] + if (next.depth === thread[end].depth + 1 && isOpChainPost(next, did)) { + end++ + } else { + break + } + } + return {start, end} +} + +/** + * The endpoint caps the depth served per request, and linear-shaped fetches + * descend the OP self-thread one level per post, so a long enough + * self-thread arrives truncated. When the chain ends at the depth cap, + * refetch anchored at the deepest post and splice the continuation into the + * response, repeating until the chain ends naturally or `maxFetches` is + * reached. + * + * The cap is whatever the server actually enforces, which can be lower than + * the requested `below` (the appview currently clamps to 10 despite the + * lexicon allowing 20), so truncation is detected structurally: a chain tip + * with no hydrated child sat at the cap, while a tip followed by any + * hydrated child - another author's reply, a tombstone - ended for real. + * + * The splice keeps the response linear-shaped (depth-first, depths made + * absolute), so downstream traversal and the reader transform are unaware of + * the stitching. The old tip's `moreReplies` is decremented since its chain + * continuation is now present in the response - reader seam counts rely on + * that value being exact, see `createSeam` in PostThread/reader.ts. + */ +export async function extendSelfThreadChain({ + thread, + maxFetches = SELF_THREAD_CHAIN_MAX_FETCHES, + fetchBelow, +}: { + thread: RawThreadItem[] + maxFetches?: number + /** Fetches more levels anchored at the given post, without parents. */ + fetchBelow: (anchorUri: string) => Promise +}): Promise { + const anchor = thread.find(item => item.depth === 0) + if (!anchor || !AppBskyUnspeccedDefs.isThreadItemPost(anchor.value)) { + return thread + } + const did = anchor.value.post.author.did + + const chain = findChain(thread, did) + if (!chain) return thread + + let result = thread + let tipIndex = chain.end + let tipMaybeTruncated = !hasHydratedChild(thread, chain.end) + + for (let i = 0; i < maxFetches && tipMaybeTruncated; i++) { + const tip = result[tipIndex] + const tipValue = tip.value + if (!AppBskyUnspeccedDefs.isThreadItemPost(tipValue)) break + if (tipValue.moreReplies === 0 && (tipValue.post.replyCount ?? 0) === 0) { + // the tip has no replies at all, so there is nothing to extend + break + } + + const continuation = await fetchBelow(tip.uri) + const contChain = findChain(continuation, did) + if (!contChain) break + + const contItems = continuation + .slice(contChain.start, contChain.end + 1) + .map(item => ({...item, depth: item.depth + tip.depth})) + const settledTip: RawThreadItem = { + ...tip, + value: { + ...tipValue, + moreReplies: Math.max(0, tipValue.moreReplies - 1), + }, + } + result = [ + ...result.slice(0, tipIndex), + settledTip, + ...contItems, + ...result.slice(tipIndex + 1), + ] + tipIndex += contItems.length + tipMaybeTruncated = !hasHydratedChild(continuation, contChain.end) + } + + return result +} + +/** + * Whether the post at `index` has any hydrated child - an item directly + * after it one level deeper. In a depth-first response a post's subtree + * follows it immediately, so an absent or shallower next item means nothing + * below this post was served. + */ +function hasHydratedChild(thread: RawThreadItem[], index: number): boolean { + return thread[index + 1]?.depth === thread[index].depth + 1 +} diff --git a/src/state/queries/usePostThread/types.ts b/src/state/queries/usePostThread/types.ts index 295fd8bd3..0879f1c39 100644 --- a/src/state/queries/usePostThread/types.ts +++ b/src/state/queries/usePostThread/types.ts @@ -28,7 +28,7 @@ export type PostThreadParams = Pick< 'sort' > & { anchor?: string - view: 'tree' | 'linear' + view: 'tree' | 'linear' | 'reader' } export type UsePostThreadQueryResult = { diff --git a/src/view/com/composer/Composer.tsx b/src/view/com/composer/Composer.tsx index e6b3796b3..a7ee46851 100644 --- a/src/view/com/composer/Composer.tsx +++ b/src/view/com/composer/Composer.tsx @@ -472,21 +472,6 @@ export const ComposePost = ({ [activePost.id], ) - const onConvertActiveOverflowToThread = useCallback(() => { - const splitPosts = splitOverflowPostIntoThreadTexts( - activePost.richtext.text, - ) - if (splitPosts.length < 2) { - return - } - - setError('') - composerDispatch({ - type: 'replace_post_with_thread', - postId: activePost.id, - texts: splitPosts, - }) - }, [activePost.id, activePost.richtext.text, composerDispatch]) const selectVideo = useCallback( async (postId: string, asset: ImagePickerAsset) => { /* @@ -1538,7 +1523,6 @@ export const ComposePost = ({ onSelectLanguage={onSelectLanguage} languageNudgeAt={languageNudgeAt} openGallery={openGallery} - onConvertOverLimitToThread={onConvertActiveOverflowToThread} textInputRef={textInputRef} /> @@ -2410,7 +2394,6 @@ function ComposerFooter({ onSelectLanguage, languageNudgeAt, openGallery, - onConvertOverLimitToThread, textInputRef, }: { post: PostDraft @@ -2426,7 +2409,6 @@ function ComposerFooter({ onSelectLanguage?: (language: string) => void languageNudgeAt: number openGallery?: boolean - onConvertOverLimitToThread: () => void textInputRef: React.RefObject }) { const t = useTheme() @@ -2446,8 +2428,6 @@ function ComposerFooter({ const video = media?.type === 'video' ? media.video : null const isMaxImages = images.length >= MAX_GALLERY_IMAGES const isMaxVideos = !!video - const isOverLimit = post.shortenedGraphemeLength > MAX_GRAPHEME_LENGTH - let selectedAssetsCount = 0 let isMediaSelectionDisabled = false @@ -2520,10 +2500,6 @@ function ComposerFooter({ [post.id, onSelectVideo, onImageAdd], ) - const onPressConvertToThread = useCallback(() => { - onConvertOverLimitToThread() - }, [onConvertOverLimitToThread]) - return ( - - {isOverLimit && ( - - - Convert to thread - - - )} - - - - - + ) @@ -2819,137 +2771,6 @@ function isEmptyPost(post: PostDraft) { ) } -function splitOverflowPostIntoThreadTexts( - text: string, - maxLength = MAX_GRAPHEME_LENGTH, -): string[] { - const trimmed = text.trim() - if (!trimmed) return [] - - const words = trimmed.split(/\s+/) - if (words.length < 2) return [] - - const firstWord = words[0] - const rest = words.slice(1) - - let totalGuess = 2 - let parts: string[] = [] - - // Suffix length changes with total digit count; converge on a stable total. - for (let i = 0; i < 10; i++) { - parts = splitIntoThreadParts({ - firstWord, - rest, - total: totalGuess, - maxLength, - }) - if (parts.length <= 1) { - return [] - } - if (parts.length === totalGuess) { - break - } - totalGuess = parts.length - } - - const total = parts.length - const numbered = parts.map((part, idx) => `${part} (${idx + 1}/${total})`) - - if (numbered.some(part => getGraphemeLength(part) > maxLength)) { - return [] - } - - return numbered -} - -function splitIntoThreadParts({ - firstWord, - rest, - total, - maxLength, -}: { - firstWord: string - rest: string[] - total: number - maxLength: number -}): string[] { - const firstPart = `${firstWord} 🧵` - const firstLimit = getPartContentLimit(1, total, maxLength) - if (getGraphemeLength(firstPart) > firstLimit) { - return [] - } - - const parts = [firstPart] - - for (const originalWord of rest) { - let word = originalWord - - if (parts.length === 1) { - // Keep post 1 fixed as " 🧵". - parts.push('') - } - - while (word.length > 0) { - const partNumber = parts.length - const limit = getPartContentLimit(partNumber, total, maxLength) - if (limit <= 0) { - return parts - } - - const current = parts[partNumber - 1] - const next = current ? `${current} ${word}` : word - if (getGraphemeLength(next) <= limit) { - parts[partNumber - 1] = next - break - } - - if (current) { - parts.push('') - continue - } - - // If a single word exceeds the limit, hard-wrap it by grapheme. - const [head, tail] = splitAtGrapheme(word, limit) - if (!head) { - return parts - } - parts[partNumber - 1] = head - word = tail - if (word.length > 0) { - parts.push('') - } - } - } - - return parts.filter(Boolean) -} - -function getPartContentLimit( - partNumber: number, - total: number, - maxLength: number, -) { - return maxLength - getGraphemeLength(` (${partNumber}/${total})`) -} - -function splitAtGrapheme(text: string, limit: number): [string, string] { - if (limit <= 0) return ['', text] - const graphemes = splitGraphemes(text) - return [graphemes.slice(0, limit).join(''), graphemes.slice(limit).join('')] -} - -function getGraphemeLength(text: string): number { - return new RichText({text}).graphemeLength -} - -function splitGraphemes(text: string): string[] { - if (typeof Intl !== 'undefined' && 'Segmenter' in Intl) { - const segmenter = new Intl.Segmenter(undefined, {granularity: 'grapheme'}) - return Array.from(segmenter.segment(text), segment => segment.segment) - } - return Array.from(text) -} - function useHideKeyboardOnBackground() { const appState = useAppState() diff --git a/src/view/com/composer/state/composer.ts b/src/view/com/composer/state/composer.ts index 717a0b74b..2c980da57 100644 --- a/src/view/com/composer/state/composer.ts +++ b/src/view/com/composer/state/composer.ts @@ -132,11 +132,6 @@ export type ComposerAction = | { type: 'add_post' } - | { - type: 'replace_post_with_thread' - postId: string - texts: string[] - } | { type: 'remove_post' postId: string @@ -263,49 +258,6 @@ export function composerReducer( }, } } - case 'replace_post_with_thread': { - const {postId, texts} = action - if (texts.length === 0) { - return state - } - - const postIndex = state.thread.posts.findIndex(p => p.id === postId) - if (postIndex === -1) { - return state - } - - const postToReplace = state.thread.posts[postIndex] - const replacementPosts = texts.map((text, index) => - createPostDraftFromText(text, { - labels: index === 0 ? postToReplace.labels : [], - embed: - index === 0 - ? postToReplace.embed - : { - quote: undefined, - media: undefined, - link: undefined, - }, - }), - ) - - const nextPosts = [ - ...state.thread.posts.slice(0, postIndex), - ...replacementPosts, - ...state.thread.posts.slice(postIndex + 1), - ] - - return { - ...state, - isDirty: true, - activePostIndex: postIndex, - mutableNeedsFocusActive: true, - thread: { - ...state.thread, - posts: nextPosts, - }, - } - } case 'remove_post': { if (state.thread.posts.length < 2) { return state @@ -824,26 +776,3 @@ function getShortenedLength(rt: RichText) { return shortenLinks(newRt).graphemeLength } -function createPostDraftFromText( - text: string, - overrides?: { - id?: string - labels?: SelfLabel[] - embed?: EmbedDraft - }, -): PostDraft { - const richtext = new RichText({text}) - richtext.detectFacetsWithoutResolution() - - return { - id: overrides?.id ?? nanoid(), - richtext, - shortenedGraphemeLength: getShortenedLength(richtext), - labels: overrides?.labels ?? [], - embed: overrides?.embed ?? { - quote: undefined, - media: undefined, - link: undefined, - }, - } -} diff --git a/src/view/screens/PostThread.tsx b/src/view/screens/PostThread.tsx index ba903c0d5..750e2e4da 100644 --- a/src/view/screens/PostThread.tsx +++ b/src/view/screens/PostThread.tsx @@ -15,7 +15,7 @@ import * as Layout from '#/components/Layout' type Props = NativeStackScreenProps export function PostThreadScreen({route}: Props) { const {_} = useLingui() - const {name, rkey} = route.params + const {name, rkey, view} = route.params const uri = makeRecordUri(name, 'app.bsky.feed.post', rkey) const {data: resolvedDid} = useResolveDidQuery(name) @@ -25,7 +25,11 @@ export function PostThreadScreen({route}: Props) { return ( - + ) }