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 (
-
+
)
}