},
+
+ pointer: web({
+ cursor: 'pointer',
+ }),
} as const
diff --git a/src/alf/util/__tests__/colors.test.ts b/src/alf/util/__tests__/colors.test.ts
new file mode 100644
index 0000000000..350b6ff4a4
--- /dev/null
+++ b/src/alf/util/__tests__/colors.test.ts
@@ -0,0 +1,48 @@
+import {jest} from '@jest/globals'
+
+import {logger} from '#/logger'
+import {transparentifyColor} from '../colorGeneration'
+
+jest.mock('#/logger', () => ({
+ logger: {warn: jest.fn()},
+}))
+
+describe('transparentifyColor', () => {
+ beforeEach(() => {
+ jest.clearAllMocks()
+ })
+
+ it('converts hsl() to hsla()', () => {
+ const result = transparentifyColor('hsl(120 100% 50%)', 0.5)
+ expect(result).toBe('hsla(120 100% 50%, 0.5)')
+ })
+
+ it('converts hsl() to hsla() - fully transparent', () => {
+ const result = transparentifyColor('hsl(120 100% 50%)', 0)
+ expect(result).toBe('hsla(120 100% 50%, 0)')
+ })
+
+ it('converts rgb() to rgba()', () => {
+ const result = transparentifyColor('rgb(255 0 0)', 0.75)
+ expect(result).toBe('rgba(255 0 0, 0.75)')
+ })
+
+ it('expands 3-digit hex and appends alpha channel', () => {
+ const result = transparentifyColor('#abc', 0.4)
+ expect(result).toBe('#aabbcc66')
+ })
+
+ it('appends alpha to 6-digit hex', () => {
+ const result = transparentifyColor('#aabbcc', 0.4)
+ expect(result).toBe('#aabbcc66')
+ })
+
+ it('returns the original string and warns for unsupported formats', () => {
+ const unsupported = 'blue'
+ const result = transparentifyColor(unsupported, 0.5)
+ expect(result).toBe(unsupported)
+ expect(logger.warn).toHaveBeenCalledWith(
+ `Could not make '${unsupported}' transparent`,
+ )
+ })
+})
diff --git a/src/alf/util/colorGeneration.ts b/src/alf/util/colorGeneration.ts
index 8d769b51b1..574ab0a496 100644
--- a/src/alf/util/colorGeneration.ts
+++ b/src/alf/util/colorGeneration.ts
@@ -1,3 +1,5 @@
+import {logger} from '#/logger'
+
export const BLUE_HUE = 211
export const RED_HUE = 346
export const GREEN_HUE = 152
@@ -19,3 +21,29 @@ export function generateScale(start: number, end: number) {
export const defaultScale = generateScale(6, 100)
// dim shifted 6% lighter
export const dimScale = generateScale(12, 100)
+
+export function transparentifyColor(color: string, alpha: number) {
+ if (color.startsWith('hsl(')) {
+ return 'hsla(' + color.slice('hsl('.length, -1) + `, ${alpha})`
+ } else if (color.startsWith('rgb(')) {
+ return 'rgba(' + color.slice('rgb('.length, -1) + `, ${alpha})`
+ } else if (color.startsWith('#')) {
+ if (color.length === 7) {
+ const alphaHex = Math.round(alpha * 255).toString(16)
+ // Per MDN: If there is only one number, it is duplicated: e means ee
+ // https://developer.mozilla.org/en-US/docs/Web/CSS/hex-color
+ return color.slice(0, 7) + alphaHex.padStart(2, alphaHex)
+ } else if (color.length === 4) {
+ // convert to 6-digit hex before adding alpha
+ const [r, g, b] = color.slice(1).split('')
+ const alphaHex = Math.round(alpha * 255).toString(16)
+ return `#${r.repeat(2)}${g.repeat(2)}${b.repeat(2)}${alphaHex.padStart(
+ 2,
+ alphaHex,
+ )}`
+ }
+ } else {
+ logger.warn(`Could not make '${color}' transparent`)
+ }
+ return color
+}
diff --git a/src/components/Skeleton.tsx b/src/components/Skeleton.tsx
new file mode 100644
index 0000000000..14c3177c54
--- /dev/null
+++ b/src/components/Skeleton.tsx
@@ -0,0 +1,107 @@
+import {type ReactNode} from 'react'
+import {View} from 'react-native'
+
+import {
+ atoms as a,
+ flatten,
+ type TextStyleProp,
+ useAlf,
+ useTheme,
+ type ViewStyleProp,
+} from '#/alf'
+import {normalizeTextStyles} from '#/alf/typography'
+
+type SkeletonProps = {
+ blend?: boolean
+}
+
+export function Text({blend, style}: TextStyleProp & SkeletonProps) {
+ const {fonts, flags, theme: t} = useAlf()
+ const {width, ...flattened} = flatten(style)
+ const {lineHeight = 14, ...rest} = normalizeTextStyles(
+ [a.text_sm, a.leading_snug, flattened],
+ {
+ fontScale: fonts.scaleMultiplier,
+ fontFamily: fonts.family,
+ flags,
+ },
+ )
+ return (
+
+
+
+ )
+}
+
+export function Circle({
+ children,
+ size,
+ blend,
+ style,
+}: ViewStyleProp & {children?: ReactNode; size: number} & SkeletonProps) {
+ const t = useTheme()
+ return (
+
+ {children}
+
+ )
+}
+
+export function Pill({
+ size,
+ blend,
+ style,
+}: ViewStyleProp & {size: number} & SkeletonProps) {
+ const t = useTheme()
+ return (
+
+ )
+}
+
+export function Col({
+ children,
+ style,
+}: ViewStyleProp & {children?: React.ReactNode}) {
+ return {children}
+}
+
+export function Row({
+ children,
+ style,
+}: ViewStyleProp & {children?: React.ReactNode}) {
+ return {children}
+}
diff --git a/src/components/icons/ArrowTopCircle.tsx b/src/components/icons/ArrowTopCircle.tsx
new file mode 100644
index 0000000000..2d250367f3
--- /dev/null
+++ b/src/components/icons/ArrowTopCircle.tsx
@@ -0,0 +1,5 @@
+import {createSinglePathSVG} from './TEMPLATE'
+
+export const ArrowTopCircle_Stroke2_Corner0_Rounded = createSinglePathSVG({
+ path: 'M12 2c5.523 0 10 4.477 10 10s-4.477 10-10 10S2 17.523 2 12 6.477 2 12 2Zm0 2a8 8 0 1 0 0 16 8 8 0 0 0 0-16Zm-.63 3.225a1 1 0 0 1 1.337.068l3 3 .068.076a1 1 0 0 1-1.406 1.406l-.076-.068L13 10.414V16a1 1 0 1 1-2 0v-5.586l-1.293 1.293a1 1 0 1 1-1.414-1.414l3-3 .076-.068Z',
+})
diff --git a/src/components/icons/CirclePlus.tsx b/src/components/icons/CirclePlus.tsx
new file mode 100644
index 0000000000..690e77326e
--- /dev/null
+++ b/src/components/icons/CirclePlus.tsx
@@ -0,0 +1,5 @@
+import {createSinglePathSVG} from './TEMPLATE'
+
+export const CirclePlus_Stroke2_Corner0_Rounded = createSinglePathSVG({
+ path: 'M12 2c5.523 0 10 4.477 10 10s-4.477 10-10 10S2 17.523 2 12 6.477 2 12 2Zm0 2a8 8 0 1 0 0 16 8 8 0 0 0 0-16Zm0 3a1 1 0 0 1 1 1v3h3l.102.005a1 1 0 0 1 0 1.99L16 13h-3v3a1 1 0 1 1-2 0v-3H8a1 1 0 0 1 0-2h3V8a1 1 0 0 1 1-1Z',
+})
diff --git a/src/components/icons/Tree.tsx b/src/components/icons/Tree.tsx
new file mode 100644
index 0000000000..5c2c798720
--- /dev/null
+++ b/src/components/icons/Tree.tsx
@@ -0,0 +1,5 @@
+import {createSinglePathSVG} from './TEMPLATE'
+
+export const Tree_Stroke2_Corner0_Rounded = createSinglePathSVG({
+ path: 'M6 2a2.998 2.998 0 0 1 1 5.825V8a2 2 0 0 0 2 2h1.174c.412-1.165 1.52-2 2.826-2h5a3 3 0 1 1 0 6h-5a2.998 2.998 0 0 1-2.826-2H9a3.98 3.98 0 0 1-2-.537V16a2 2 0 0 0 2 2h1.174c.412-1.165 1.52-2 2.826-2h5a3 3 0 1 1 0 6h-5a2.998 2.998 0 0 1-2.826-2H9a4 4 0 0 1-4-4V7.825A2.998 2.998 0 0 1 6 2Zm7 16a1 1 0 1 0 0 2h5a1 1 0 1 0 0-2h-5Zm0-8a1 1 0 1 0 0 2h5a1 1 0 1 0 0-2h-5ZM6 4a1 1 0 1 0 0 2 1 1 0 0 0 0-2Z',
+})
diff --git a/src/lib/async/retry.ts b/src/lib/async/retry.ts
index abf78de55f..8a1729091d 100644
--- a/src/lib/async/retry.ts
+++ b/src/lib/async/retry.ts
@@ -1,17 +1,22 @@
+import {timeout} from '#/lib/async/timeout'
import {isNetworkError} from '#/lib/strings/errors'
export async function retry(
retries: number,
- cond: (err: any) => boolean,
- fn: () => Promise
,
+ shouldRetry: (err: any) => boolean,
+ action: () => Promise
,
+ delay?: number,
): Promise
{
let lastErr
while (retries > 0) {
try {
- return await fn()
+ return await action()
} catch (e: any) {
lastErr = e
- if (cond(e)) {
+ if (shouldRetry(e)) {
+ if (delay) {
+ await timeout(delay)
+ }
retries--
continue
}
diff --git a/src/lib/hooks/useCallOnce.ts b/src/lib/hooks/useCallOnce.ts
new file mode 100644
index 0000000000..fa01cf4aa3
--- /dev/null
+++ b/src/lib/hooks/useCallOnce.ts
@@ -0,0 +1,20 @@
+import {useCallback} from 'react'
+
+export enum OnceKey {
+ PreferencesThread = 'preferences:thread',
+}
+
+const called: Record = {
+ [OnceKey.PreferencesThread]: false,
+}
+
+export function useCallOnce(key: OnceKey) {
+ return useCallback(
+ (cb: () => void) => {
+ if (called[key] === true) return
+ called[key] = true
+ cb()
+ },
+ [key],
+ )
+}
diff --git a/src/lib/hooks/useHideBottomBarBorder.tsx b/src/lib/hooks/useHideBottomBarBorder.tsx
new file mode 100644
index 0000000000..e21184fda4
--- /dev/null
+++ b/src/lib/hooks/useHideBottomBarBorder.tsx
@@ -0,0 +1,50 @@
+import {createContext, useCallback, useContext, useState} from 'react'
+import {useFocusEffect} from '@react-navigation/native'
+
+type HideBottomBarBorderSetter = () => () => void
+
+const HideBottomBarBorderContext = createContext(false)
+const HideBottomBarBorderSetterContext =
+ createContext(null)
+
+export function useHideBottomBarBorderSetter() {
+ const hideBottomBarBorder = useContext(HideBottomBarBorderSetterContext)
+ if (!hideBottomBarBorder) {
+ throw new Error(
+ 'useHideBottomBarBorderSetter must be used within a HideBottomBarBorderProvider',
+ )
+ }
+ return hideBottomBarBorder
+}
+
+export function useHideBottomBarBorderForScreen() {
+ const hideBorder = useHideBottomBarBorderSetter()
+
+ useFocusEffect(
+ useCallback(() => {
+ const cleanup = hideBorder()
+ return () => cleanup()
+ }, [hideBorder]),
+ )
+}
+
+export function useHideBottomBarBorder() {
+ return useContext(HideBottomBarBorderContext)
+}
+
+export function Provider({children}: {children: React.ReactNode}) {
+ const [refCount, setRefCount] = useState(0)
+
+ const setter = useCallback(() => {
+ setRefCount(prev => prev + 1)
+ return () => setRefCount(prev => prev - 1)
+ }, [])
+
+ return (
+
+ 0}>
+ {children}
+
+
+ )
+}
diff --git a/src/lib/statsig/gates.ts b/src/lib/statsig/gates.ts
index c67bb60a3a..3b1106480d 100644
--- a/src/lib/statsig/gates.ts
+++ b/src/lib/statsig/gates.ts
@@ -6,6 +6,7 @@ export type Gate =
| 'explore_show_suggested_feeds'
| 'old_postonboarding'
| 'onboarding_add_video_feed'
+ | 'post_threads_v2_unspecced'
| 'remove_show_latest_button'
| 'test_gate_1'
| 'test_gate_2'
diff --git a/src/logger/metrics.ts b/src/logger/metrics.ts
index d01a92825b..31af1be2b0 100644
--- a/src/logger/metrics.ts
+++ b/src/logger/metrics.ts
@@ -434,4 +434,13 @@ export type MetricEvents = {
'share:press:dmSelected': {}
'share:press:recentDm': {}
'share:press:embed': {}
+
+ 'thread:click:showOtherReplies': {}
+ 'thread:preferences:load': {
+ [key: string]: any
+ }
+ 'thread:preferences:update': {
+ [key: string]: any
+ }
+ 'thread:click:headerMenuOpen': {}
}
diff --git a/src/screens/Messages/components/MessagesList.tsx b/src/screens/Messages/components/MessagesList.tsx
index ce33ca3aa9..c84371f2c0 100644
--- a/src/screens/Messages/components/MessagesList.tsx
+++ b/src/screens/Messages/components/MessagesList.tsx
@@ -16,6 +16,7 @@ import {
RichText,
} from '@atproto/api'
+import {useHideBottomBarBorderForScreen} from '#/lib/hooks/useHideBottomBarBorder'
import {ScrollProvider} from '#/lib/ScrollContext'
import {shortenLinks, stripInvalidMentions} from '#/lib/strings/rich-text-manip'
import {
@@ -106,6 +107,8 @@ export function MessagesList({
const getPost = useGetPost()
const {embedUri, setEmbed} = useMessageEmbed()
+ useHideBottomBarBorderForScreen()
+
const flatListRef = useAnimatedRef()
const [newMessagesPill, setNewMessagesPill] = useState({
diff --git a/src/screens/PostThread/components/HeaderDropdown.tsx b/src/screens/PostThread/components/HeaderDropdown.tsx
new file mode 100644
index 0000000000..def3979b78
--- /dev/null
+++ b/src/screens/PostThread/components/HeaderDropdown.tsx
@@ -0,0 +1,106 @@
+import {msg, Trans} from '@lingui/macro'
+import {useLingui} from '@lingui/react'
+
+import {HITSLOP_10} from '#/lib/constants'
+import {logger} from '#/logger'
+import {type ThreadPreferences} from '#/state/queries/preferences/useThreadPreferences'
+import {Button, ButtonIcon} from '#/components/Button'
+import {SettingsSliderVertical_Stroke2_Corner0_Rounded as SettingsSlider} from '#/components/icons/SettingsSlider'
+import * as Menu from '#/components/Menu'
+
+export function HeaderDropdown({
+ sort,
+ view,
+ setSort,
+ setView,
+}: Pick<
+ ThreadPreferences,
+ 'sort' | 'setSort' | 'view' | 'setView'
+>): React.ReactNode {
+ const {_} = useLingui()
+ return (
+
+
+ {({props: {onPress, ...props}}) => (
+
+ )}
+
+
+
+ Show replies as
+
+
+ {
+ setView('linear')
+ }}>
+
+ Linear
+
+
+
+ {
+ setView('tree')
+ }}>
+
+ Threaded
+
+
+
+
+
+
+ Reply sorting
+
+
+ {
+ setSort('top')
+ }}>
+
+ Top replies first
+
+
+
+ {
+ setSort('oldest')
+ }}>
+
+ Oldest replies first
+
+
+
+ {
+ setSort('newest')
+ }}>
+
+ Newest replies first
+
+
+
+
+
+
+ )
+}
diff --git a/src/screens/PostThread/components/ThreadError.tsx b/src/screens/PostThread/components/ThreadError.tsx
new file mode 100644
index 0000000000..e1ca23cf97
--- /dev/null
+++ b/src/screens/PostThread/components/ThreadError.tsx
@@ -0,0 +1,89 @@
+import {useMemo} from 'react'
+import {View} from 'react-native'
+import {msg, Trans} from '@lingui/macro'
+import {useLingui} from '@lingui/react'
+
+import {useCleanError} from '#/lib/hooks/useCleanError'
+import {OUTER_SPACE} from '#/screens/PostThread/const'
+import {atoms as a, useTheme} from '#/alf'
+import {Button, ButtonIcon, ButtonText} from '#/components/Button'
+import {ArrowRotateCounterClockwise_Stroke2_Corner0_Rounded as RetryIcon} from '#/components/icons/ArrowRotateCounterClockwise'
+import * as Layout from '#/components/Layout'
+import {Text} from '#/components/Typography'
+
+export function ThreadError({
+ error,
+ onRetry,
+}: {
+ error: Error
+ onRetry: () => void
+}) {
+ const t = useTheme()
+ const {_} = useLingui()
+ const cleanError = useCleanError()
+
+ const {title, message} = useMemo(() => {
+ let title = _(msg`Error loading post`)
+ let message = _(msg`Something went wrong. Please try again in a moment.`)
+
+ const {raw, clean} = cleanError(error)
+
+ if (error.message.startsWith('Post not found')) {
+ title = _(msg`Post not found`)
+ message = clean || raw || message
+ }
+
+ return {title, message}
+ }, [_, error, cleanError])
+
+ return (
+
+
+
+
+
+ {title}
+
+
+ {message}
+
+
+
+
+
+
+ )
+}
diff --git a/src/screens/PostThread/components/ThreadItemAnchor.tsx b/src/screens/PostThread/components/ThreadItemAnchor.tsx
new file mode 100644
index 0000000000..0aacd4e771
--- /dev/null
+++ b/src/screens/PostThread/components/ThreadItemAnchor.tsx
@@ -0,0 +1,706 @@
+import {memo, useCallback, useMemo} from 'react'
+import {type GestureResponderEvent, Text as RNText, View} from 'react-native'
+import {
+ AppBskyFeedDefs,
+ AppBskyFeedPost,
+ type AppBskyFeedThreadgate,
+ AtUri,
+ RichText as RichTextAPI,
+} from '@atproto/api'
+import {msg, Plural, Trans} from '@lingui/macro'
+import {useLingui} from '@lingui/react'
+
+import {useActorStatus} from '#/lib/actor-status'
+import {useOpenComposer} from '#/lib/hooks/useOpenComposer'
+import {useOpenLink} from '#/lib/hooks/useOpenLink'
+import {makeProfileLink} from '#/lib/routes/links'
+import {sanitizeDisplayName} from '#/lib/strings/display-names'
+import {sanitizeHandle} from '#/lib/strings/handles'
+import {niceDate} from '#/lib/strings/time'
+import {s} from '#/lib/styles'
+import {getTranslatorLink, isPostInLanguage} from '#/locale/helpers'
+import {logger} from '#/logger'
+import {
+ POST_TOMBSTONE,
+ type Shadow,
+ usePostShadow,
+} from '#/state/cache/post-shadow'
+import {useProfileShadow} from '#/state/cache/profile-shadow'
+import {FeedFeedbackProvider, useFeedFeedback} from '#/state/feed-feedback'
+import {useLanguagePrefs} from '#/state/preferences'
+import {type ThreadItem} from '#/state/queries/usePostThread/types'
+import {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 {PostThreadFollowBtn} from '#/view/com/post-thread/PostThreadFollowBtn'
+import {Link} from '#/view/com/util/Link'
+import {formatCount} from '#/view/com/util/numeric/format'
+import {PostEmbeds, PostEmbedViewContext} from '#/view/com/util/post-embeds'
+import {PreviewableUserAvatar} from '#/view/com/util/UserAvatar'
+import {
+ LINEAR_AVI_WIDTH,
+ OUTER_SPACE,
+ REPLY_LINE_WIDTH,
+} from '#/screens/PostThread/const'
+import {atoms as a, useTheme} from '#/alf'
+import {colors} from '#/components/Admonition'
+import {Button} from '#/components/Button'
+import {CalendarClock_Stroke2_Corner0_Rounded as CalendarClockIcon} from '#/components/icons/CalendarClock'
+import {Trash_Stroke2_Corner0_Rounded as TrashIcon} from '#/components/icons/Trash'
+import {InlineLinkText} from '#/components/Link'
+import {ContentHider} from '#/components/moderation/ContentHider'
+import {LabelsOnMyPost} from '#/components/moderation/LabelsOnMe'
+import {PostAlerts} from '#/components/moderation/PostAlerts'
+import {type AppModerationCause} from '#/components/Pills'
+import {PostControls} from '#/components/PostControls'
+import * as Prompt from '#/components/Prompt'
+import {RichText} from '#/components/RichText'
+import * as Skele from '#/components/Skeleton'
+import {Text} from '#/components/Typography'
+import {VerificationCheckButton} from '#/components/verification/VerificationCheckButton'
+import {WhoCanReply} from '#/components/WhoCanReply'
+import * as bsky from '#/types/bsky'
+
+export function ThreadItemAnchor({
+ item,
+ onPostSuccess,
+ threadgateRecord,
+ postSource,
+}: {
+ item: Extract
+ onPostSuccess?: (data: OnPostSuccessData) => void
+ threadgateRecord?: AppBskyFeedThreadgate.Record
+ postSource?: PostSource
+}) {
+ const postShadow = usePostShadow(item.value.post)
+ const threadRootUri = item.value.post.record.reply?.root?.uri || item.uri
+ const isRoot = threadRootUri === item.uri
+
+ if (postShadow === POST_TOMBSTONE) {
+ return
+ }
+
+ return (
+
+ )
+}
+
+function ThreadItemAnchorDeleted({isRoot}: {isRoot: boolean}) {
+ const t = useTheme()
+
+ return (
+ <>
+
+
+
+
+
+
+
+
+ Post has been deleted
+
+
+
+ >
+ )
+}
+
+function ThreadItemAnchorParentReplyLine({isRoot}: {isRoot: boolean}) {
+ const t = useTheme()
+
+ return !isRoot ? (
+
+
+
+
+
+ ) : null
+}
+
+const ThreadItemAnchorInner = memo(function ThreadItemAnchorInner({
+ item,
+ isRoot,
+ postShadow,
+ onPostSuccess,
+ threadgateRecord,
+ postSource,
+}: {
+ item: Extract
+ isRoot: boolean
+ postShadow: Shadow
+ onPostSuccess?: (data: OnPostSuccessData) => void
+ threadgateRecord?: AppBskyFeedThreadgate.Record
+ postSource?: PostSource
+}) {
+ const t = useTheme()
+ const {_, i18n} = useLingui()
+ const {openComposer} = useOpenComposer()
+ const {currentAccount, hasSession} = useSession()
+ const feedFeedback = useFeedFeedback(postSource?.feed, hasSession)
+
+ const post = item.value.post
+ const record = item.value.post.record
+ const moderation = item.moderation
+ const authorShadow = useProfileShadow(post.author)
+ const {isActive: live} = useActorStatus(post.author)
+ const richText = useMemo(
+ () =>
+ new RichTextAPI({
+ text: record.text,
+ facets: record.facets,
+ }),
+ [record],
+ )
+
+ const threadRootUri = record.reply?.root?.uri || post.uri
+ const authorHref = makeProfileLink(post.author)
+ const authorTitle = post.author.handle
+ const isThreadAuthor = getThreadAuthor(post, record) === currentAccount?.did
+
+ const likesHref = useMemo(() => {
+ const urip = new AtUri(post.uri)
+ return makeProfileLink(post.author, 'post', urip.rkey, 'liked-by')
+ }, [post.uri, post.author])
+ const repostsHref = useMemo(() => {
+ const urip = new AtUri(post.uri)
+ return makeProfileLink(post.author, 'post', urip.rkey, 'reposted-by')
+ }, [post.uri, post.author])
+ const quotesHref = useMemo(() => {
+ const urip = new AtUri(post.uri)
+ return makeProfileLink(post.author, 'post', urip.rkey, 'quotes')
+ }, [post.uri, post.author])
+
+ const threadgateHiddenReplies = useMergedThreadgateHiddenReplies({
+ threadgateRecord,
+ })
+ const additionalPostAlerts: AppModerationCause[] = useMemo(() => {
+ const isPostHiddenByThreadgate = threadgateHiddenReplies.has(post.uri)
+ const isControlledByViewer =
+ new AtUri(threadRootUri).host === currentAccount?.did
+ return isControlledByViewer && isPostHiddenByThreadgate
+ ? [
+ {
+ type: 'reply-hidden',
+ source: {type: 'user', did: currentAccount?.did},
+ priority: 6,
+ },
+ ]
+ : []
+ }, [post, currentAccount?.did, threadgateHiddenReplies, threadRootUri])
+ const onlyFollowersCanReply = !!threadgateRecord?.allow?.find(
+ rule => rule.$type === 'app.bsky.feed.threadgate#followerRule',
+ )
+ const showFollowButton =
+ currentAccount?.did !== post.author.did && !onlyFollowersCanReply
+
+ const viaRepost = useMemo(() => {
+ const reason = postSource?.post.reason
+
+ if (AppBskyFeedDefs.isReasonRepost(reason) && reason.uri && reason.cid) {
+ return {
+ uri: reason.uri,
+ cid: reason.cid,
+ }
+ }
+ }, [postSource])
+
+ const onPressReply = useCallback(() => {
+ openComposer({
+ replyTo: {
+ uri: post.uri,
+ cid: post.cid,
+ text: record.text,
+ author: post.author,
+ embed: post.embed,
+ moderation,
+ },
+ onPostSuccess: onPostSuccess,
+ })
+
+ if (postSource) {
+ feedFeedback.sendInteraction({
+ item: post.uri,
+ event: 'app.bsky.feed.defs#interactionReply',
+ feedContext: postSource.post.feedContext,
+ reqId: postSource.post.reqId,
+ })
+ }
+ }, [
+ openComposer,
+ post,
+ record,
+ onPostSuccess,
+ moderation,
+ postSource,
+ feedFeedback,
+ ])
+
+ const onOpenAuthor = () => {
+ if (postSource) {
+ feedFeedback.sendInteraction({
+ item: post.uri,
+ event: 'app.bsky.feed.defs#clickthroughAuthor',
+ feedContext: postSource.post.feedContext,
+ reqId: postSource.post.reqId,
+ })
+ }
+ }
+
+ const onOpenEmbed = () => {
+ if (postSource) {
+ feedFeedback.sendInteraction({
+ item: post.uri,
+ event: 'app.bsky.feed.defs#clickthroughEmbed',
+ feedContext: postSource.post.feedContext,
+ reqId: postSource.post.reqId,
+ })
+ }
+ }
+
+ return (
+ <>
+
+
+
+
+
+
+
+
+
+ {sanitizeDisplayName(
+ post.author.displayName ||
+ sanitizeHandle(post.author.handle),
+ moderation.ui('displayName'),
+ )}
+
+
+
+
+
+
+
+
+
+ {sanitizeHandle(post.author.handle, '@')}
+
+
+
+ {showFollowButton && (
+
+
+
+ )}
+
+
+
+
+
+ {richText?.text ? (
+
+ ) : undefined}
+ {post.embed && (
+
+
+
+ )}
+
+
+ {post.repostCount !== 0 ||
+ post.likeCount !== 0 ||
+ post.quoteCount !== 0 ? (
+ // Show this section unless we're *sure* it has no engagement.
+
+ {post.repostCount != null && post.repostCount !== 0 ? (
+
+
+
+ {formatCount(i18n, post.repostCount)}
+ {' '}
+
+
+
+ ) : null}
+ {post.quoteCount != null &&
+ post.quoteCount !== 0 &&
+ !post.viewer?.embeddingDisabled ? (
+
+
+
+ {formatCount(i18n, post.quoteCount)}
+ {' '}
+
+
+
+ ) : null}
+ {post.likeCount != null && post.likeCount !== 0 ? (
+
+
+
+ {formatCount(i18n, post.likeCount)}
+ {' '}
+
+
+
+ ) : null}
+
+ ) : null}
+
+
+
+
+
+
+
+ >
+ )
+})
+
+function ExpandedPostDetails({
+ post,
+ isThreadAuthor,
+}: {
+ post: Extract['value']['post']
+ isThreadAuthor: boolean
+}) {
+ const t = useTheme()
+ const {_, i18n} = useLingui()
+ const openLink = useOpenLink()
+ const langPrefs = useLanguagePrefs()
+
+ const translatorUrl = getTranslatorLink(
+ post.record?.text || '',
+ langPrefs.primaryLanguage,
+ )
+ const needsTranslation = useMemo(
+ () =>
+ Boolean(
+ langPrefs.primaryLanguage &&
+ !isPostInLanguage(post, [langPrefs.primaryLanguage]),
+ ),
+ [post, langPrefs.primaryLanguage],
+ )
+
+ const onTranslatePress = useCallback(
+ (e: GestureResponderEvent) => {
+ e.preventDefault()
+ openLink(translatorUrl, true)
+
+ if (
+ bsky.dangerousIsType(
+ post.record,
+ AppBskyFeedPost.isRecord,
+ )
+ ) {
+ logger.metric('translate', {
+ sourceLanguages: post.record.langs ?? [],
+ targetLanguage: langPrefs.primaryLanguage,
+ textLength: post.record.text.length,
+ })
+ }
+
+ return false
+ },
+ [openLink, translatorUrl, langPrefs, post],
+ )
+
+ return (
+
+
+
+
+ {niceDate(i18n, post.indexedAt)}
+
+
+ {needsTranslation && (
+ <>
+
+ ·
+
+
+
+ Translate
+
+ >
+ )}
+
+
+ )
+}
+
+function BackdatedPostIndicator({post}: {post: AppBskyFeedDefs.PostView}) {
+ const t = useTheme()
+ const {_, 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
+
+ const orange = t.name === 'light' ? colors.warning.dark : colors.warning.light
+
+ 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.
+
+
+
+ {}} />
+
+
+ >
+ )
+}
+
+function getThreadAuthor(
+ post: AppBskyFeedDefs.PostView,
+ record: AppBskyFeedPost.Record,
+): string {
+ if (!record.reply) {
+ return post.author.did
+ }
+ try {
+ return new AtUri(record.reply.root.uri).host
+ } catch {
+ return ''
+ }
+}
+
+export function ThreadItemAnchorSkeleton() {
+ return (
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ )
+}
diff --git a/src/screens/PostThread/components/ThreadItemAnchorNoUnauthenticated.tsx b/src/screens/PostThread/components/ThreadItemAnchorNoUnauthenticated.tsx
new file mode 100644
index 0000000000..c8477e211f
--- /dev/null
+++ b/src/screens/PostThread/components/ThreadItemAnchorNoUnauthenticated.tsx
@@ -0,0 +1,32 @@
+import {View} from 'react-native'
+import {Trans} from '@lingui/macro'
+
+import {atoms as a, useTheme} from '#/alf'
+import {Lock_Stroke2_Corner0_Rounded as LockIcon} from '#/components/icons/Lock'
+import * as Skele from '#/components/Skeleton'
+import {Text} from '#/components/Typography'
+
+export function ThreadItemAnchorNoUnauthenticated() {
+ const t = useTheme()
+
+ return (
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ You must sign in to view this post.
+
+
+
+ )
+}
diff --git a/src/screens/PostThread/components/ThreadItemPost.tsx b/src/screens/PostThread/components/ThreadItemPost.tsx
new file mode 100644
index 0000000000..1f63b10cd9
--- /dev/null
+++ b/src/screens/PostThread/components/ThreadItemPost.tsx
@@ -0,0 +1,405 @@
+import {memo, type ReactNode, useCallback, useMemo, useState} from 'react'
+import {View} from 'react-native'
+import {
+ type AppBskyFeedDefs,
+ type AppBskyFeedThreadgate,
+ AtUri,
+ RichText as RichTextAPI,
+} from '@atproto/api'
+import {msg, Trans} from '@lingui/macro'
+import {useLingui} from '@lingui/react'
+
+import {useActorStatus} from '#/lib/actor-status'
+import {MAX_POST_LINES} from '#/lib/constants'
+import {useOpenComposer} from '#/lib/hooks/useOpenComposer'
+import {usePalette} from '#/lib/hooks/usePalette'
+import {makeProfileLink} from '#/lib/routes/links'
+import {countLines} from '#/lib/strings/helpers'
+import {
+ POST_TOMBSTONE,
+ type Shadow,
+ usePostShadow,
+} from '#/state/cache/post-shadow'
+import {type ThreadItem} from '#/state/queries/usePostThread/types'
+import {useSession} from '#/state/session'
+import {type OnPostSuccessData} from '#/state/shell/composer'
+import {useMergedThreadgateHiddenReplies} from '#/state/threadgate-hidden-replies'
+import {TextLink} from '#/view/com/util/Link'
+import {PostEmbeds, PostEmbedViewContext} from '#/view/com/util/post-embeds'
+import {PostMeta} from '#/view/com/util/PostMeta'
+import {PreviewableUserAvatar} from '#/view/com/util/UserAvatar'
+import {
+ LINEAR_AVI_WIDTH,
+ OUTER_SPACE,
+ REPLY_LINE_WIDTH,
+} from '#/screens/PostThread/const'
+import {atoms as a, useTheme} from '#/alf'
+import {useInteractionState} from '#/components/hooks/useInteractionState'
+import {Trash_Stroke2_Corner0_Rounded as TrashIcon} from '#/components/icons/Trash'
+import {LabelsOnMyPost} from '#/components/moderation/LabelsOnMe'
+import {PostAlerts} from '#/components/moderation/PostAlerts'
+import {PostHider} from '#/components/moderation/PostHider'
+import {type AppModerationCause} from '#/components/Pills'
+import {PostControls} from '#/components/PostControls'
+import {RichText} from '#/components/RichText'
+import * as Skele from '#/components/Skeleton'
+import {SubtleWebHover} from '#/components/SubtleWebHover'
+import {Text} from '#/components/Typography'
+
+export type ThreadItemPostProps = {
+ item: Extract
+ overrides?: {
+ moderation?: boolean
+ topBorder?: boolean
+ }
+ onPostSuccess?: (data: OnPostSuccessData) => void
+ threadgateRecord?: AppBskyFeedThreadgate.Record
+}
+
+export function ThreadItemPost({
+ item,
+ overrides,
+ onPostSuccess,
+ threadgateRecord,
+}: ThreadItemPostProps) {
+ const postShadow = usePostShadow(item.value.post)
+
+ if (postShadow === POST_TOMBSTONE) {
+ return
+ }
+
+ return (
+
+ )
+}
+
+function ThreadItemPostDeleted({
+ item,
+ overrides,
+}: Pick) {
+ const t = useTheme()
+
+ return (
+
+
+
+
+
+
+
+
+ Post has been deleted
+
+
+
+
+
+ )
+}
+
+const ThreadItemPostOuterWrapper = memo(function ThreadItemPostOuterWrapper({
+ item,
+ overrides,
+ children,
+}: Pick & {
+ children: ReactNode
+}) {
+ const t = useTheme()
+ const showTopBorder =
+ !item.ui.showParentReplyLine && overrides?.topBorder !== true
+
+ return (
+
+ {children}
+
+ )
+})
+
+/**
+ * Provides some space between posts as well as contains the reply line
+ */
+const ThreadItemPostParentReplyLine = memo(
+ function ThreadItemPostParentReplyLine({
+ item,
+ }: Pick) {
+ const t = useTheme()
+ return (
+
+
+ {item.ui.showParentReplyLine && (
+
+ )}
+
+
+ )
+ },
+)
+
+const ThreadItemPostInner = memo(function ThreadItemPostInner({
+ item,
+ postShadow,
+ overrides,
+ onPostSuccess,
+ threadgateRecord,
+}: ThreadItemPostProps & {
+ postShadow: Shadow
+}) {
+ const t = useTheme()
+ const pal = usePalette('default')
+ const {_} = useLingui()
+ const {openComposer} = useOpenComposer()
+ const {currentAccount} = useSession()
+
+ const post = item.value.post
+ const record = item.value.post.record
+ const moderation = item.moderation
+ const richText = useMemo(
+ () =>
+ new RichTextAPI({
+ text: record.text,
+ facets: record.facets,
+ }),
+ [record],
+ )
+ const [limitLines, setLimitLines] = useState(
+ () => countLines(richText?.text) >= MAX_POST_LINES,
+ )
+ const threadRootUri = record.reply?.root?.uri || post.uri
+ const postHref = useMemo(() => {
+ const urip = new AtUri(post.uri)
+ return makeProfileLink(post.author, 'post', urip.rkey)
+ }, [post.uri, post.author])
+ const threadgateHiddenReplies = useMergedThreadgateHiddenReplies({
+ threadgateRecord,
+ })
+ const additionalPostAlerts: AppModerationCause[] = useMemo(() => {
+ const isPostHiddenByThreadgate = threadgateHiddenReplies.has(post.uri)
+ const isControlledByViewer =
+ new AtUri(threadRootUri).host === currentAccount?.did
+ return isControlledByViewer && isPostHiddenByThreadgate
+ ? [
+ {
+ type: 'reply-hidden',
+ source: {type: 'user', did: currentAccount?.did},
+ priority: 6,
+ },
+ ]
+ : []
+ }, [post, currentAccount?.did, threadgateHiddenReplies, threadRootUri])
+
+ const onPressReply = useCallback(() => {
+ openComposer({
+ replyTo: {
+ uri: post.uri,
+ cid: post.cid,
+ text: record.text,
+ author: post.author,
+ embed: post.embed,
+ moderation,
+ },
+ onPostSuccess: onPostSuccess,
+ })
+ }, [openComposer, post, record, onPostSuccess, moderation])
+
+ const onPressShowMore = useCallback(() => {
+ setLimitLines(false)
+ }, [setLimitLines])
+
+ const {isActive: live} = useActorStatus(post.author)
+
+ return (
+
+
+
+
+
+
+
+
+
+ {(item.ui.showChildReplyLine ||
+ item.ui.precedesChildReadMore) && (
+
+ )}
+
+
+
+
+
+
+ {richText?.text ? (
+
+ ) : undefined}
+ {limitLines ? (
+
+ ) : undefined}
+ {post.embed && (
+
+
+
+ )}
+
+
+
+
+
+
+ )
+})
+
+function SubtleHover({children}: {children: ReactNode}) {
+ const {
+ state: hover,
+ onIn: onHoverIn,
+ onOut: onHoverOut,
+ } = useInteractionState()
+ return (
+
+
+ {children}
+
+ )
+}
+
+export function ThreadItemPostSkeleton({index}: {index: number}) {
+ const even = index % 2 === 0
+ return (
+
+
+
+
+
+
+
+
+
+
+
+ {even ? (
+ <>
+
+
+ >
+ ) : (
+
+ )}
+
+
+
+
+
+
+
+
+
+
+
+
+ )
+}
diff --git a/src/screens/PostThread/components/ThreadItemPostNoUnauthenticated.tsx b/src/screens/PostThread/components/ThreadItemPostNoUnauthenticated.tsx
new file mode 100644
index 0000000000..552d8f813e
--- /dev/null
+++ b/src/screens/PostThread/components/ThreadItemPostNoUnauthenticated.tsx
@@ -0,0 +1,74 @@
+import {View} from 'react-native'
+import {Trans} from '@lingui/macro'
+
+import {type ThreadItem} from '#/state/queries/usePostThread/types'
+import {
+ LINEAR_AVI_WIDTH,
+ OUTER_SPACE,
+ REPLY_LINE_WIDTH,
+} from '#/screens/PostThread/const'
+import {atoms as a, useTheme} from '#/alf'
+import {Lock_Stroke2_Corner0_Rounded as LockIcon} from '#/components/icons/Lock'
+import * as Skele from '#/components/Skeleton'
+import {Text} from '#/components/Typography'
+
+export function ThreadItemPostNoUnauthenticated({
+ item,
+}: {
+ item: Extract
+}) {
+ const t = useTheme()
+
+ return (
+
+
+
+ {item.ui.showParentReplyLine && (
+
+ )}
+
+
+
+
+
+
+
+
+ You must sign in to view this post.
+
+
+
+ {item.ui.showChildReplyLine && (
+
+ )}
+
+
+ )
+}
diff --git a/src/screens/PostThread/components/ThreadItemPostTombstone.tsx b/src/screens/PostThread/components/ThreadItemPostTombstone.tsx
new file mode 100644
index 0000000000..4f1ab450bb
--- /dev/null
+++ b/src/screens/PostThread/components/ThreadItemPostTombstone.tsx
@@ -0,0 +1,55 @@
+import {useMemo} from 'react'
+import {View} from 'react-native'
+import {msg} from '@lingui/macro'
+import {useLingui} from '@lingui/react'
+
+import {LINEAR_AVI_WIDTH, OUTER_SPACE} from '#/screens/PostThread/const'
+import {atoms as a, useTheme} from '#/alf'
+import {PersonX_Stroke2_Corner0_Rounded as PersonXIcon} from '#/components/icons/Person'
+import {Trash_Stroke2_Corner0_Rounded as TrashIcon} from '#/components/icons/Trash'
+import {Text} from '#/components/Typography'
+
+export type ThreadItemPostTombstoneProps = {
+ type: 'not-found' | 'blocked'
+}
+
+export function ThreadItemPostTombstone({type}: ThreadItemPostTombstoneProps) {
+ const t = useTheme()
+ const {_} = useLingui()
+ const {copy, Icon} = useMemo(() => {
+ switch (type) {
+ case 'blocked':
+ return {copy: _(msg`Post blocked`), Icon: PersonXIcon}
+ case 'not-found':
+ default:
+ return {copy: _(msg`Post not found`), Icon: TrashIcon}
+ }
+ }, [_, type])
+
+ return (
+
+
+
+
+
+
+ {copy}
+
+
+
+ )
+}
diff --git a/src/screens/PostThread/components/ThreadItemReadMore.tsx b/src/screens/PostThread/components/ThreadItemReadMore.tsx
new file mode 100644
index 0000000000..22ae633951
--- /dev/null
+++ b/src/screens/PostThread/components/ThreadItemReadMore.tsx
@@ -0,0 +1,107 @@
+import {memo} from 'react'
+import {View} from 'react-native'
+import {msg, Plural, Trans} from '@lingui/macro'
+import {useLingui} from '@lingui/react'
+
+import {
+ type PostThreadParams,
+ type ThreadItem,
+} from '#/state/queries/usePostThread'
+import {
+ LINEAR_AVI_WIDTH,
+ REPLY_LINE_WIDTH,
+ TREE_AVI_WIDTH,
+ TREE_INDENT,
+} from '#/screens/PostThread/const'
+import {atoms as a, useTheme} from '#/alf'
+import {CirclePlus_Stroke2_Corner0_Rounded as CirclePlus} from '#/components/icons/CirclePlus'
+import {Link} from '#/components/Link'
+import {Text} from '#/components/Typography'
+
+export const ThreadItemReadMore = memo(function ThreadItemReadMore({
+ item,
+ view,
+}: {
+ item: Extract
+ view: PostThreadParams['view']
+}) {
+ const t = useTheme()
+ const {_} = useLingui()
+ const isTreeView = view === 'tree'
+ const indent = Math.max(0, item.depth - 1)
+
+ const spacers = isTreeView
+ ? Array.from(Array(indent)).map((_, n: number) => {
+ const isSkipped = item.skippedIndentIndices.has(n)
+ return (
+
+ )
+ })
+ : null
+
+ return (
+
+ {spacers}
+
+
+ {({hovered, pressed}) => {
+ const interacted = hovered || pressed
+ return (
+ <>
+
+
+
+ Read {item.moreReplies} more{' '}
+
+
+
+ >
+ )
+ }}
+
+
+ )
+})
diff --git a/src/screens/PostThread/components/ThreadItemReadMoreUp.tsx b/src/screens/PostThread/components/ThreadItemReadMoreUp.tsx
new file mode 100644
index 0000000000..da18a19e90
--- /dev/null
+++ b/src/screens/PostThread/components/ThreadItemReadMoreUp.tsx
@@ -0,0 +1,89 @@
+import {memo} from 'react'
+import {View} from 'react-native'
+import {msg, Trans} from '@lingui/macro'
+import {useLingui} from '@lingui/react'
+
+import {type ThreadItem} from '#/state/queries/usePostThread'
+import {
+ LINEAR_AVI_WIDTH,
+ OUTER_SPACE,
+ REPLY_LINE_WIDTH,
+} from '#/screens/PostThread/const'
+import {atoms as a, useTheme} from '#/alf'
+import {ArrowTopCircle_Stroke2_Corner0_Rounded as UpIcon} from '#/components/icons/ArrowTopCircle'
+import {Link} from '#/components/Link'
+import {Text} from '#/components/Typography'
+
+export const ThreadItemReadMoreUp = memo(function ThreadItemReadMoreUp({
+ item,
+}: {
+ item: Extract
+}) {
+ const t = useTheme()
+ const {_} = useLingui()
+
+ return (
+
+ {({hovered, pressed}) => {
+ const interacted = hovered || pressed
+ return (
+
+
+
+
+
+
+ Continue thread...
+
+
+
+
+
+
+ )
+ }}
+
+ )
+})
diff --git a/src/screens/PostThread/components/ThreadItemReplyComposer.tsx b/src/screens/PostThread/components/ThreadItemReplyComposer.tsx
new file mode 100644
index 0000000000..f1862569ea
--- /dev/null
+++ b/src/screens/PostThread/components/ThreadItemReplyComposer.tsx
@@ -0,0 +1,31 @@
+import {View} from 'react-native'
+
+import {OUTER_SPACE} from '#/screens/PostThread/const'
+import {atoms as a, useBreakpoints, useTheme} from '#/alf'
+import * as Skele from '#/components/Skeleton'
+
+/*
+ * Wacky padding here is just replicating what we have in the actual
+ * `PostThreadComposePrompt` component
+ */
+export function ThreadItemReplyComposerSkeleton() {
+ const t = useTheme()
+ const {gtMobile} = useBreakpoints()
+
+ return (
+
+
+
+
+
+
+ )
+}
diff --git a/src/screens/PostThread/components/ThreadItemShowOtherReplies.tsx b/src/screens/PostThread/components/ThreadItemShowOtherReplies.tsx
new file mode 100644
index 0000000000..e418375b65
--- /dev/null
+++ b/src/screens/PostThread/components/ThreadItemShowOtherReplies.tsx
@@ -0,0 +1,59 @@
+import {View} from 'react-native'
+import {msg} from '@lingui/macro'
+import {useLingui} from '@lingui/react'
+
+import {logger} from '#/logger'
+import {atoms as a, useTheme} from '#/alf'
+import {Button} from '#/components/Button'
+import {EyeSlash_Stroke2_Corner0_Rounded as EyeSlash} from '#/components/icons/EyeSlash'
+import {Text} from '#/components/Typography'
+
+export function ThreadItemShowOtherReplies({onPress}: {onPress: () => void}) {
+ const {_} = useLingui()
+ const t = useTheme()
+ const label = _(msg`Show more replies`)
+
+ return (
+
+ )
+}
diff --git a/src/screens/PostThread/components/ThreadItemTreePost.tsx b/src/screens/PostThread/components/ThreadItemTreePost.tsx
new file mode 100644
index 0000000000..d86d2ef6f7
--- /dev/null
+++ b/src/screens/PostThread/components/ThreadItemTreePost.tsx
@@ -0,0 +1,456 @@
+import React, {memo, useMemo} from 'react'
+import {View} from 'react-native'
+import {
+ type AppBskyFeedDefs,
+ type AppBskyFeedThreadgate,
+ AtUri,
+ RichText as RichTextAPI,
+} from '@atproto/api'
+import {msg, Trans} from '@lingui/macro'
+import {useLingui} from '@lingui/react'
+
+import {MAX_POST_LINES} from '#/lib/constants'
+import {useOpenComposer} from '#/lib/hooks/useOpenComposer'
+import {usePalette} from '#/lib/hooks/usePalette'
+import {makeProfileLink} from '#/lib/routes/links'
+import {countLines} from '#/lib/strings/helpers'
+import {
+ POST_TOMBSTONE,
+ type Shadow,
+ usePostShadow,
+} from '#/state/cache/post-shadow'
+import {type ThreadItem} from '#/state/queries/usePostThread/types'
+import {useSession} from '#/state/session'
+import {type OnPostSuccessData} from '#/state/shell/composer'
+import {useMergedThreadgateHiddenReplies} from '#/state/threadgate-hidden-replies'
+import {TextLink} from '#/view/com/util/Link'
+import {PostEmbeds, PostEmbedViewContext} from '#/view/com/util/post-embeds'
+import {PostMeta} from '#/view/com/util/PostMeta'
+import {
+ OUTER_SPACE,
+ REPLY_LINE_WIDTH,
+ TREE_AVI_WIDTH,
+ TREE_INDENT,
+} from '#/screens/PostThread/const'
+import {atoms as a, useTheme} from '#/alf'
+import {useInteractionState} from '#/components/hooks/useInteractionState'
+import {Trash_Stroke2_Corner0_Rounded as TrashIcon} from '#/components/icons/Trash'
+import {LabelsOnMyPost} from '#/components/moderation/LabelsOnMe'
+import {PostAlerts} from '#/components/moderation/PostAlerts'
+import {PostHider} from '#/components/moderation/PostHider'
+import {type AppModerationCause} from '#/components/Pills'
+import {PostControls} from '#/components/PostControls'
+import {RichText} from '#/components/RichText'
+import * as Skele from '#/components/Skeleton'
+import {SubtleWebHover} from '#/components/SubtleWebHover'
+import {Text} from '#/components/Typography'
+
+/**
+ * Mimic the space in PostMeta
+ */
+const TREE_AVI_PLUS_SPACE = TREE_AVI_WIDTH + a.gap_xs.gap
+
+export function ThreadItemTreePost({
+ item,
+ overrides,
+ onPostSuccess,
+ threadgateRecord,
+}: {
+ item: Extract
+ overrides?: {
+ moderation?: boolean
+ topBorder?: boolean
+ }
+ onPostSuccess?: (data: OnPostSuccessData) => void
+ threadgateRecord?: AppBskyFeedThreadgate.Record
+}) {
+ const postShadow = usePostShadow(item.value.post)
+
+ if (postShadow === POST_TOMBSTONE) {
+ return
+ }
+
+ return (
+
+ )
+}
+
+function ThreadItemTreePostDeleted({
+ item,
+}: {
+ item: Extract
+}) {
+ const t = useTheme()
+ return (
+
+
+
+
+
+ Post has been deleted
+
+
+ {item.ui.isLastChild && !item.ui.precedesChildReadMore && (
+
+ )}
+
+
+ )
+}
+
+const ThreadItemTreePostOuterWrapper = memo(
+ function ThreadItemTreePostOuterWrapper({
+ item,
+ children,
+ }: {
+ item: Extract
+ children: React.ReactNode
+ }) {
+ const t = useTheme()
+ const indents = Math.max(0, item.ui.indent - 1)
+
+ return (
+
+ {Array.from(Array(indents)).map((_, n: number) => {
+ const isSkipped = item.ui.skippedIndentIndices.has(n)
+ return (
+
+ )
+ })}
+ {children}
+
+ )
+ },
+)
+
+const ThreadItemTreePostInnerWrapper = memo(
+ function ThreadItemTreePostInnerWrapper({
+ item,
+ children,
+ }: {
+ item: Extract
+ children: React.ReactNode
+ }) {
+ const t = useTheme()
+ return (
+
+ {item.ui.indent > 1 && (
+
+ )}
+ {children}
+
+ )
+ },
+)
+
+const ThreadItemTreeReplyChildReplyLine = memo(
+ function ThreadItemTreeReplyChildReplyLine({
+ item,
+ }: {
+ item: Extract
+ }) {
+ const t = useTheme()
+ return (
+
+ {item.ui.showChildReplyLine && (
+
+ )}
+
+ )
+ },
+)
+
+const ThreadItemTreePostInner = memo(function ThreadItemTreePostInner({
+ item,
+ postShadow,
+ overrides,
+ onPostSuccess,
+ threadgateRecord,
+}: {
+ item: Extract
+ postShadow: Shadow
+ overrides?: {
+ moderation?: boolean
+ topBorder?: boolean
+ }
+ onPostSuccess?: (data: OnPostSuccessData) => void
+ threadgateRecord?: AppBskyFeedThreadgate.Record
+}): React.ReactNode {
+ const pal = usePalette('default')
+ const {_} = useLingui()
+ const {openComposer} = useOpenComposer()
+ const {currentAccount} = useSession()
+
+ const post = item.value.post
+ const record = item.value.post.record
+ const moderation = item.moderation
+ const richText = useMemo(
+ () =>
+ new RichTextAPI({
+ text: record.text,
+ facets: record.facets,
+ }),
+ [record],
+ )
+ const [limitLines, setLimitLines] = React.useState(
+ () => countLines(richText?.text) >= MAX_POST_LINES,
+ )
+ const threadRootUri = record.reply?.root?.uri || post.uri
+ const postHref = React.useMemo(() => {
+ const urip = new AtUri(post.uri)
+ return makeProfileLink(post.author, 'post', urip.rkey)
+ }, [post.uri, post.author])
+ const threadgateHiddenReplies = useMergedThreadgateHiddenReplies({
+ threadgateRecord,
+ })
+ const additionalPostAlerts: AppModerationCause[] = React.useMemo(() => {
+ const isPostHiddenByThreadgate = threadgateHiddenReplies.has(post.uri)
+ const isControlledByViewer =
+ new AtUri(threadRootUri).host === currentAccount?.did
+ return isControlledByViewer && isPostHiddenByThreadgate
+ ? [
+ {
+ type: 'reply-hidden',
+ source: {type: 'user', did: currentAccount?.did},
+ priority: 6,
+ },
+ ]
+ : []
+ }, [post, currentAccount?.did, threadgateHiddenReplies, threadRootUri])
+
+ const onPressReply = React.useCallback(() => {
+ openComposer({
+ replyTo: {
+ uri: post.uri,
+ cid: post.cid,
+ text: record.text,
+ author: post.author,
+ embed: post.embed,
+ moderation,
+ },
+ onPostSuccess: onPostSuccess,
+ })
+ }, [openComposer, post, record, onPostSuccess, moderation])
+
+ const onPressShowMore = React.useCallback(() => {
+ setLimitLines(false)
+ }, [setLimitLines])
+
+ return (
+
+
+
+
+
+
+
+
+
+
+
+ {richText?.text ? (
+
+
+
+ ) : undefined}
+ {limitLines ? (
+
+ ) : undefined}
+ {post.embed && (
+
+
+
+ )}
+
+
+
+
+
+
+
+
+ )
+})
+
+function SubtleHover({children}: {children: React.ReactNode}) {
+ const {
+ state: hover,
+ onIn: onHoverIn,
+ onOut: onHoverOut,
+ } = useInteractionState()
+ return (
+
+
+ {children}
+
+ )
+}
+
+export function ThreadItemTreePostSkeleton({index}: {index: number}) {
+ const t = useTheme()
+ const even = index % 2 === 0
+ return (
+
+
+
+
+
+
+
+
+
+
+
+ {even ? (
+ <>
+
+
+ >
+ ) : (
+
+ )}
+
+
+
+
+
+
+
+
+
+
+
+
+ )
+}
diff --git a/src/screens/PostThread/const.ts b/src/screens/PostThread/const.ts
new file mode 100644
index 0000000000..cf559ac4e7
--- /dev/null
+++ b/src/screens/PostThread/const.ts
@@ -0,0 +1,7 @@
+import {tokens} from '#/alf'
+
+export const TREE_INDENT = tokens.space.lg
+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
diff --git a/src/screens/PostThread/index.tsx b/src/screens/PostThread/index.tsx
new file mode 100644
index 0000000000..a4f94851ad
--- /dev/null
+++ b/src/screens/PostThread/index.tsx
@@ -0,0 +1,577 @@
+import {useCallback, useMemo, useRef, useState} from 'react'
+import {useWindowDimensions, View} from 'react-native'
+import Animated, {useAnimatedStyle} from 'react-native-reanimated'
+import {Trans} from '@lingui/macro'
+
+import {useInitialNumToRender} from '#/lib/hooks/useInitialNumToRender'
+import {useOpenComposer} from '#/lib/hooks/useOpenComposer'
+import {useFeedFeedback} from '#/state/feed-feedback'
+import {type ThreadViewOption} from '#/state/queries/preferences/useThreadPreferences'
+import {type ThreadItem, usePostThread} from '#/state/queries/usePostThread'
+import {useSession} from '#/state/session'
+import {type OnPostSuccessData} from '#/state/shell/composer'
+import {useShellLayout} from '#/state/shell/shell-layout'
+import {useUnstablePostSource} from '#/state/unstable-post-source'
+import {PostThreadComposePrompt} from '#/view/com/post-thread/PostThreadComposePrompt'
+import {List, type ListMethods} from '#/view/com/util/List'
+import {HeaderDropdown} from '#/screens/PostThread/components/HeaderDropdown'
+import {ThreadError} from '#/screens/PostThread/components/ThreadError'
+import {
+ ThreadItemAnchor,
+ ThreadItemAnchorSkeleton,
+} from '#/screens/PostThread/components/ThreadItemAnchor'
+import {ThreadItemAnchorNoUnauthenticated} from '#/screens/PostThread/components/ThreadItemAnchorNoUnauthenticated'
+import {
+ ThreadItemPost,
+ ThreadItemPostSkeleton,
+} from '#/screens/PostThread/components/ThreadItemPost'
+import {ThreadItemPostNoUnauthenticated} from '#/screens/PostThread/components/ThreadItemPostNoUnauthenticated'
+import {ThreadItemPostTombstone} from '#/screens/PostThread/components/ThreadItemPostTombstone'
+import {ThreadItemReadMore} from '#/screens/PostThread/components/ThreadItemReadMore'
+import {ThreadItemReadMoreUp} from '#/screens/PostThread/components/ThreadItemReadMoreUp'
+import {ThreadItemReplyComposerSkeleton} from '#/screens/PostThread/components/ThreadItemReplyComposer'
+import {ThreadItemShowOtherReplies} from '#/screens/PostThread/components/ThreadItemShowOtherReplies'
+import {
+ ThreadItemTreePost,
+ ThreadItemTreePostSkeleton,
+} from '#/screens/PostThread/components/ThreadItemTreePost'
+import {atoms as a, native, platform, useBreakpoints, web} from '#/alf'
+import * as Layout from '#/components/Layout'
+import {ListFooter} from '#/components/Lists'
+
+const PARENT_CHUNK_SIZE = 5
+const CHILDREN_CHUNK_SIZE = 50
+
+export function PostThread({uri}: {uri: string}) {
+ const {gtMobile} = useBreakpoints()
+ const {hasSession} = useSession()
+ const initialNumToRender = useInitialNumToRender() // TODO
+ const {height: windowHeight} = useWindowDimensions()
+ const anchorPostSource = useUnstablePostSource(uri)
+ const feedFeedback = useFeedFeedback(anchorPostSource?.feed, hasSession)
+
+ /*
+ * One query to rule them all
+ */
+ const thread = usePostThread({anchor: uri})
+ const anchor = useMemo(() => {
+ for (const item of thread.data.items) {
+ if (item.type === 'threadPost' && item.depth === 0) {
+ return item
+ }
+ }
+ return
+ }, [thread.data.items])
+
+ const {openComposer} = useOpenComposer()
+ const optimisticOnPostReply = useCallback(
+ (payload: OnPostSuccessData) => {
+ if (payload) {
+ const {replyToUri, posts} = payload
+ if (replyToUri && posts.length) {
+ thread.actions.insertReplies(replyToUri, posts)
+ }
+ }
+ },
+ [thread],
+ )
+ const onReplyToAnchor = useCallback(() => {
+ if (anchor?.type !== 'threadPost') {
+ return
+ }
+ const post = anchor.value.post
+ openComposer({
+ replyTo: {
+ uri: anchor.uri,
+ cid: post.cid,
+ text: post.record.text,
+ author: post.author,
+ embed: post.embed,
+ moderation: anchor.moderation,
+ },
+ onPostSuccess: optimisticOnPostReply,
+ })
+
+ if (anchorPostSource) {
+ feedFeedback.sendInteraction({
+ item: post.uri,
+ event: 'app.bsky.feed.defs#interactionReply',
+ feedContext: anchorPostSource.post.feedContext,
+ reqId: anchorPostSource.post.reqId,
+ })
+ }
+ }, [
+ anchor,
+ openComposer,
+ optimisticOnPostReply,
+ anchorPostSource,
+ feedFeedback,
+ ])
+
+ const isRoot = !!anchor && anchor.value.post.record.reply === undefined
+ const canReply = !anchor?.value.post?.viewer?.replyDisabled
+ const [maxParentCount, setMaxParentCount] = useState(PARENT_CHUNK_SIZE)
+ const [maxChildrenCount, setMaxChildrenCount] = useState(CHILDREN_CHUNK_SIZE)
+ const totalParentCount = useRef(0) // recomputed below
+ const totalChildrenCount = useRef(thread.data.items.length) // recomputed below
+ const listRef = useRef(null)
+ const anchorRef = useRef(null)
+ const headerRef = useRef(null)
+
+ /*
+ * On a cold load, parents are not prepended until the anchor post has
+ * rendered as the first item in the list. This gives us a consistent
+ * reference point for which to pin the anchor post to the top of the screen.
+ *
+ * We simulate a cold load any time the user changes the view or sort params
+ * so that this handling is consistent.
+ *
+ * On native, `maintainVisibleContentPosition={{minIndexForVisible: 0}}` gives
+ * us this for free, since the anchor post is the first item in the list.
+ *
+ * On web, `onContentSizeChange` is used to get ahead of next paint and handle
+ * this scrolling.
+ */
+ const [deferParents, setDeferParents] = useState(true)
+ /**
+ * Used to flag whether we should scroll to the anchor post. On a cold load,
+ * this is always true. And when a user changes thread parameters, we also
+ * manually set this to true.
+ */
+ const shouldHandleScroll = useRef(true)
+ /**
+ * Called any time the content size of the list changes, _just_ before paint.
+ *
+ * We want this to fire every time we change params (which will reset
+ * `deferParents` via `onLayout` on the anchor post, due to the key change),
+ * or click into a new post (which will result in a fresh `deferParents`
+ * hook).
+ *
+ * The result being: any intentional change in view by the user will result
+ * in the anchor being pinned as the first item.
+ */
+ const onContentSizeChangeWebOnly = web(() => {
+ const list = listRef.current
+ const anchor = anchorRef.current as any as Element
+ const header = headerRef.current as any as Element
+
+ if (list && anchor && header && shouldHandleScroll.current) {
+ const anchorOffsetTop = anchor.getBoundingClientRect().top
+ const headerHeight = header.getBoundingClientRect().height
+
+ /*
+ * `deferParents` is `true` on a cold load, and always reset to
+ * `true` when params change via `prepareForParamsUpdate`.
+ *
+ * On a cold load or a push to a new post, on the first pass of this
+ * logic, the anchor post is the first item in the list. Therefore
+ * `anchorOffsetTop - headerHeight` will be 0.
+ *
+ * When a user changes thread params, on the first pass of this logic,
+ * the anchor post may not move (if there are no parents above it), or it
+ * may have gone off the screen above, because of the sudden lack of
+ * parents due to `deferParents === true`. This negative value (minus
+ * `headerHeight`) will result in a _negative_ `offset` value, which will
+ * scroll the anchor post _down_ to the top of the screen.
+ *
+ * However, `prepareForParamsUpdate` also resets scroll to `0`, so when a user
+ * changes params, the anchor post's offset will actually be equivalent
+ * to the `headerHeight` because of how the DOM is stacked on web.
+ * Therefore, `anchorOffsetTop - headerHeight` will once again be 0,
+ * which means the first pass in this case will result in no scroll.
+ *
+ * Then, once parents are prepended, this will fire again. Now, the
+ * `anchorOffsetTop` will be positive, which minus the header height,
+ * will give us a _positive_ offset, which will scroll the anchor post
+ * back _up_ to the top of the screen.
+ */
+ list.scrollToOffset({
+ offset: anchorOffsetTop - headerHeight,
+ })
+
+ /*
+ * After the second pass, `deferParents` will be `false`, and we need
+ * to ensure this doesn't run again until scroll handling is requested
+ * again via `shouldHandleScroll.current === true` and a params
+ * change via `prepareForParamsUpdate`.
+ *
+ * The `isRoot` here is needed because if we're looking at the anchor
+ * post, this handler will not fire after `deferParents` is set to
+ * `false`, since there are no parents to render above it. In this case,
+ * we want to make sure `shouldHandleScroll` is set to `false` so that
+ * subsequent size changes unrelated to a params change (like pagination)
+ * do not affect scroll.
+ */
+ if (!deferParents || isRoot) shouldHandleScroll.current = false
+ }
+ })
+
+ /**
+ * Ditto the above, but for native.
+ */
+ const onContentSizeChangeNativeOnly = native(() => {
+ const list = listRef.current
+ const anchor = anchorRef.current
+
+ if (list && anchor && shouldHandleScroll.current) {
+ /*
+ * `prepareForParamsUpdate` is called any time the user changes thread params like
+ * `view` or `sort`, which sets `deferParents(true)` and resets the
+ * scroll to the top of the list. However, there is a split second
+ * where the top of the list is wherever the parents _just were_. So if
+ * there were parents, the anchor is not at the top of the list just
+ * prior to this handler being called.
+ *
+ * Once this handler is called, the anchor post is the first item in
+ * the list (because of `deferParents` being `true`), and so we can
+ * synchronously scroll the list back to the top of the list (which is
+ * 0 on native, no need to handle `headerHeight`).
+ */
+ list.scrollToOffset({
+ animated: false,
+ offset: 0,
+ })
+
+ /*
+ * After this first pass, `deferParents` will be `false`, and those
+ * will render in. However, the anchor post will retain its position
+ * because of `maintainVisibleContentPosition` handling on native. So we
+ * don't need to let this handler run again, like we do on web.
+ */
+ shouldHandleScroll.current = false
+ }
+ })
+
+ /**
+ * Called any time the user changes thread params, such as `view` or `sort`.
+ * Prepares the UI for repositioning of the scroll so that the anchor post is
+ * always at the top after a params change.
+ *
+ * No need to handle max parents here, deferParents will handle that and we
+ * want it to re-render with the same items above the anchor.
+ */
+ const prepareForParamsUpdate = useCallback(() => {
+ /**
+ * Truncate list so that anchor post is the first item in the list. Manual
+ * scroll handling on web is predicated on this, and on native, this allows
+ * `maintainVisibleContentPosition` to do its thing.
+ */
+ setDeferParents(true)
+ // reset this to a lower value for faster re-render
+ setMaxChildrenCount(CHILDREN_CHUNK_SIZE)
+ // set flag
+ shouldHandleScroll.current = true
+ }, [setDeferParents, setMaxChildrenCount])
+
+ const setSortWrapped = useCallback(
+ (sort: string) => {
+ prepareForParamsUpdate()
+ thread.actions.setSort(sort)
+ },
+ [thread, prepareForParamsUpdate],
+ )
+
+ const setViewWrapped = useCallback(
+ (view: ThreadViewOption) => {
+ prepareForParamsUpdate()
+ thread.actions.setView(view)
+ },
+ [thread, prepareForParamsUpdate],
+ )
+
+ const onStartReached = () => {
+ if (thread.state.isFetching) return
+ // can be true after `prepareForParamsUpdate` is called
+ if (deferParents) return
+ // prevent any state mutations if we know we're done
+ if (maxParentCount >= totalParentCount.current) return
+ setMaxParentCount(n => n + PARENT_CHUNK_SIZE)
+ }
+
+ const onEndReached = () => {
+ if (thread.state.isFetching) return
+ // can be true after `prepareForParamsUpdate` is called
+ if (deferParents) return
+ // prevent any state mutations if we know we're done
+ if (maxChildrenCount >= totalChildrenCount.current) return
+ setMaxChildrenCount(prev => prev + CHILDREN_CHUNK_SIZE)
+ }
+
+ const slices = useMemo(() => {
+ const results: ThreadItem[] = []
+
+ if (!thread.data.items.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]
+ /*
+ * Need to check `depth`, since not found or blocked posts are not
+ * `threadPost`s, but still have `depth`.
+ */
+ const hasDepth = 'depth' in item
+
+ /*
+ * Handle anchor post.
+ */
+ if (hasDepth && item.depth === 0) {
+ results.push(item)
+
+ // Recalculate total parents current index.
+ totalParentCount.current = i
+ // Recalculate total children using (length - 1) - current index.
+ totalChildrenCount.current = thread.data.items.length - 1 - i
+
+ /*
+ * Walk up the parents, limiting by `maxParentCount`
+ */
+ if (!deferParents) {
+ const start = i - 1
+ if (start >= 0) {
+ const limit = Math.max(0, start - maxParentCount)
+ for (let pi = start; pi >= limit; pi--) {
+ results.unshift(thread.data.items[pi])
+ }
+ }
+ }
+ } else {
+ // ignore any parent items
+ if (item.type === 'readMoreUp' || (hasDepth && item.depth < 0)) continue
+ // can exit early if we've reached the max children count
+ if (childrenCount > maxChildrenCount) break
+
+ results.push(item)
+ childrenCount++
+ }
+ }
+
+ return results
+ }, [thread, deferParents, maxParentCount, maxChildrenCount])
+
+ const isTombstoneView = useMemo(() => {
+ if (slices.length > 1) return false
+ return slices.every(
+ s => s.type === 'threadPostBlocked' || s.type === 'threadPostNotFound',
+ )
+ }, [slices])
+
+ const renderItem = useCallback(
+ ({item, index}: {item: ThreadItem; index: number}) => {
+ if (item.type === 'threadPost') {
+ if (item.depth < 0) {
+ return (
+
+ )
+ } else if (item.depth === 0) {
+ return (
+ /*
+ * Keep this view wrapped so that the anchor post is always index 0
+ * in the list and `maintainVisibleContentPosition` can do its
+ * thing.
+ */
+
+ setDeferParents(false)}
+ />
+
+
+ )
+ } else {
+ if (thread.state.view === 'tree') {
+ return (
+ 0,
+ }}
+ onPostSuccess={optimisticOnPostReply}
+ />
+ )
+ } else {
+ return (
+ 0,
+ }}
+ onPostSuccess={optimisticOnPostReply}
+ />
+ )
+ }
+ }
+ } else if (item.type === 'threadPostNoUnauthenticated') {
+ if (item.depth < 0) {
+ return
+ } else if (item.depth === 0) {
+ return
+ }
+ } else if (item.type === 'readMore') {
+ return (
+
+ )
+ } else if (item.type === 'readMoreUp') {
+ return
+ } else if (item.type === 'threadPostBlocked') {
+ return
+ } else if (item.type === 'threadPostNotFound') {
+ return
+ } else if (item.type === 'replyComposer') {
+ return (
+
+ {gtMobile && (
+
+ )}
+
+ )
+ } else if (item.type === 'showOtherReplies') {
+ return
+ } else if (item.type === 'skeleton') {
+ if (item.item === 'anchor') {
+ return
+ } else if (item.item === 'reply') {
+ if (thread.state.view === 'linear') {
+ return
+ } else {
+ return
+ }
+ } else if (item.item === 'replyComposer') {
+ return
+ }
+ }
+ return null
+ },
+ [
+ thread,
+ optimisticOnPostReply,
+ onReplyToAnchor,
+ gtMobile,
+ anchorPostSource,
+ ],
+ )
+
+ return (
+ <>
+
+
+
+
+ Post
+
+
+
+
+
+
+
+ {thread.state.error ? (
+
+ ) : (
+
+ }
+ initialNumToRender={initialNumToRender}
+ windowSize={11}
+ sideBorders={false}
+ />
+ )}
+
+ {!gtMobile && canReply && hasSession && (
+
+ )}
+ >
+ )
+}
+
+function MobileComposePrompt({onPressReply}: {onPressReply: () => unknown}) {
+ const {footerHeight} = useShellLayout()
+
+ const animatedStyle = useAnimatedStyle(() => {
+ return {
+ bottom: footerHeight.get(),
+ }
+ })
+
+ return (
+
+
+
+ )
+}
+
+const keyExtractor = (item: ThreadItem) => {
+ return item.key
+}
diff --git a/src/screens/Settings/ThreadPreferences.tsx b/src/screens/Settings/ThreadPreferences.tsx
index 701d3d9e56..af3cf915f5 100644
--- a/src/screens/Settings/ThreadPreferences.tsx
+++ b/src/screens/Settings/ThreadPreferences.tsx
@@ -2,22 +2,156 @@ import {View} from 'react-native'
import {msg, Trans} from '@lingui/macro'
import {useLingui} from '@lingui/react'
-import {CommonNavigatorParams, NativeStackScreenProps} from '#/lib/routes/types'
+import {
+ type CommonNavigatorParams,
+ type NativeStackScreenProps,
+} from '#/lib/routes/types'
+import {useGate} from '#/lib/statsig/statsig'
import {
usePreferencesQuery,
useSetThreadViewPreferencesMutation,
} from '#/state/queries/preferences'
+import {
+ normalizeSort,
+ normalizeView,
+ useThreadPreferences,
+} from '#/state/queries/preferences/useThreadPreferences'
import {atoms as a, useTheme} from '#/alf'
import * as Toggle from '#/components/forms/Toggle'
import {Beaker_Stroke2_Corner2_Rounded as BeakerIcon} from '#/components/icons/Beaker'
import {Bubbles_Stroke2_Corner2_Rounded as BubblesIcon} from '#/components/icons/Bubble'
import {PersonGroup_Stroke2_Corner2_Rounded as PersonGroupIcon} from '#/components/icons/Person'
+import {Tree_Stroke2_Corner0_Rounded as TreeIcon} from '#/components/icons/Tree'
import * as Layout from '#/components/Layout'
import {Text} from '#/components/Typography'
import * as SettingsList from './components/SettingsList'
type Props = NativeStackScreenProps
export function ThreadPreferencesScreen({}: Props) {
+ const gate = useGate()
+
+ return gate('post_threads_v2_unspecced') ? (
+
+ ) : (
+
+ )
+}
+
+export function ThreadPreferencesV2() {
+ const t = useTheme()
+ const {_} = useLingui()
+ const {
+ sort,
+ setSort,
+ view,
+ setView,
+ prioritizeFollowedUsers,
+ setPrioritizeFollowedUsers,
+ } = useThreadPreferences({save: true})
+
+ return (
+
+
+
+
+
+ Thread Preferences
+
+
+
+
+
+
+
+
+
+ Sort replies
+
+
+
+ Sort replies to the same post by:
+
+ setSort(normalizeSort(values[0]))}>
+
+
+
+
+ Top replies first
+
+
+
+
+
+ Oldest replies first
+
+
+
+
+
+ Newest replies first
+
+
+
+
+
+
+
+
+
+
+ Prioritize your Follows
+
+ setPrioritizeFollowedUsers(value)}
+ style={[a.w_full, a.gap_md]}>
+
+
+ Show replies by people you follow before all other replies
+
+
+
+
+
+
+
+
+
+ Tree view
+
+
+ setView(normalizeView({treeViewEnabled: value}))
+ }
+ style={[a.w_full, a.gap_md]}>
+
+ Show post replies in a threaded tree view
+
+
+
+
+
+
+
+ )
+}
+
+export function ThreadPreferencesV1() {
const {_} = useLingui()
const t = useTheme()
diff --git a/src/screens/VideoFeed/index.tsx b/src/screens/VideoFeed/index.tsx
index 8a75751f72..495b3bc622 100644
--- a/src/screens/VideoFeed/index.tsx
+++ b/src/screens/VideoFeed/index.tsx
@@ -882,7 +882,10 @@ function Overlay({
player={player}
seekingAnimationSV={seekingAnimationSV}
scrollGesture={scrollGesture}>
-
+
diff --git a/src/state/cache/post-shadow.ts b/src/state/cache/post-shadow.ts
index 3f9644879b..90fddda2bd 100644
--- a/src/state/cache/post-shadow.ts
+++ b/src/state/cache/post-shadow.ts
@@ -14,6 +14,7 @@ import {findAllPostsInQueryData as findAllPostsInFeedQueryData} from '#/state/qu
import {findAllPostsInQueryData as findAllPostsInQuoteQueryData} from '#/state/queries/post-quotes'
import {findAllPostsInQueryData as findAllPostsInThreadQueryData} from '#/state/queries/post-thread'
import {findAllPostsInQueryData as findAllPostsInSearchQueryData} from '#/state/queries/search-posts'
+import {findAllPostsInQueryData as findAllPostsInThreadV2QueryData} from '#/state/queries/usePostThread/queryCache'
import {useProfileShadow} from './profile-shadow'
import {castAsShadow, type Shadow} from './types'
export type {Shadow} from './types'
@@ -157,6 +158,9 @@ function* findPostsInCache(
yield node.post
}
}
+ for (let post of findAllPostsInThreadV2QueryData(queryClient, uri)) {
+ yield post
+ }
for (let post of findAllPostsInSearchQueryData(queryClient, uri)) {
yield post
}
diff --git a/src/state/cache/profile-shadow.ts b/src/state/cache/profile-shadow.ts
index a1212d8a29..31bf55d132 100644
--- a/src/state/cache/profile-shadow.ts
+++ b/src/state/cache/profile-shadow.ts
@@ -21,6 +21,7 @@ import {findAllProfilesInQueryData as findAllProfilesInProfileFollowersQueryData
import {findAllProfilesInQueryData as findAllProfilesInProfileFollowsQueryData} from '#/state/queries/profile-follows'
import {findAllProfilesInQueryData as findAllProfilesInSuggestedFollowsQueryData} from '#/state/queries/suggested-follows'
import {findAllProfilesInQueryData as findAllProfilesInSuggestedUsersQueryData} from '#/state/queries/trending/useGetSuggestedUsersQuery'
+import {findAllProfilesInQueryData as findAllProfilesInPostThreadV2QueryData} from '#/state/queries/usePostThread/queryCache'
import type * as bsky from '#/types/bsky'
import {castAsShadow, type Shadow} from './types'
@@ -167,6 +168,7 @@ function* findProfilesInCache(
yield* findAllProfilesInListConvosQueryData(queryClient, did)
yield* findAllProfilesInFeedsQueryData(queryClient, did)
yield* findAllProfilesInPostThreadQueryData(queryClient, did)
+ yield* findAllProfilesInPostThreadV2QueryData(queryClient, did)
yield* findAllProfilesInKnownFollowersQueryData(queryClient, did)
yield* findAllProfilesInExploreFeedPreviewsQueryData(queryClient, did)
}
diff --git a/src/state/queries/preferences/useThreadPreferences.ts b/src/state/queries/preferences/useThreadPreferences.ts
new file mode 100644
index 0000000000..dc3122a72a
--- /dev/null
+++ b/src/state/queries/preferences/useThreadPreferences.ts
@@ -0,0 +1,179 @@
+import {useCallback, useMemo, useRef, useState} from 'react'
+import {type AppBskyUnspeccedGetPostThreadV2} from '@atproto/api'
+import debounce from 'lodash.debounce'
+
+import {OnceKey, useCallOnce} from '#/lib/hooks/useCallOnce'
+import {logger} from '#/logger'
+import {
+ usePreferencesQuery,
+ useSetThreadViewPreferencesMutation,
+} from '#/state/queries/preferences'
+import {type ThreadViewPreferences} from '#/state/queries/preferences/types'
+import {type Literal} from '#/types/utils'
+
+export type ThreadSortOption = Literal<
+ AppBskyUnspeccedGetPostThreadV2.QueryParams['sort'],
+ string
+>
+export type ThreadViewOption = 'linear' | 'tree'
+export type ThreadPreferences = {
+ isLoaded: boolean
+ isSaving: boolean
+ sort: ThreadSortOption
+ setSort: (sort: string) => void
+ view: ThreadViewOption
+ setView: (view: ThreadViewOption) => void
+ prioritizeFollowedUsers: boolean
+ setPrioritizeFollowedUsers: (prioritize: boolean) => void
+}
+
+export function useThreadPreferences({
+ save,
+}: {save?: boolean} = {}): ThreadPreferences {
+ const {data: preferences} = usePreferencesQuery()
+ const serverPrefs = preferences?.threadViewPrefs
+ const once = useCallOnce(OnceKey.PreferencesThread)
+
+ /*
+ * 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 [prioritizeFollowedUsers, setPrioritizeFollowedUsers] = useState(
+ !!serverPrefs?.prioritizeFollowedUsers,
+ )
+
+ /**
+ * If we get a server update, update local state
+ */
+ const [prevServerPrefs, setPrevServerPrefs] = useState(serverPrefs)
+ const isLoaded = !!prevServerPrefs
+ if (serverPrefs && prevServerPrefs !== serverPrefs) {
+ setPrevServerPrefs(serverPrefs)
+
+ /*
+ * Update
+ */
+ setSort(normalizeSort(serverPrefs.sort))
+ setPrioritizeFollowedUsers(serverPrefs.prioritizeFollowedUsers)
+ setView(
+ normalizeView({
+ treeViewEnabled: !!serverPrefs.lab_treeViewEnabled,
+ }),
+ )
+
+ once(() => {
+ logger.metric('thread:preferences:load', {
+ sort: serverPrefs.sort,
+ view: serverPrefs.lab_treeViewEnabled ? 'tree' : 'linear',
+ prioritizeFollowedUsers: serverPrefs.prioritizeFollowedUsers,
+ })
+ })
+ }
+
+ const userUpdatedPrefs = useRef(false)
+ const [isSaving, setIsSaving] = useState(false)
+ const {mutateAsync} = useSetThreadViewPreferencesMutation()
+ const savePrefs = useMemo(() => {
+ return debounce(async (prefs: ThreadViewPreferences) => {
+ try {
+ setIsSaving(true)
+ await mutateAsync(prefs)
+ logger.metric('thread:preferences:update', {
+ sort: prefs.sort,
+ view: prefs.lab_treeViewEnabled ? 'tree' : 'linear',
+ prioritizeFollowedUsers: prefs.prioritizeFollowedUsers,
+ })
+ } catch (e) {
+ logger.error('useThreadPreferences failed to save', {
+ safeMessage: e,
+ })
+ } finally {
+ setIsSaving(false)
+ }
+ }, 4e3)
+ }, [mutateAsync])
+
+ if (save && userUpdatedPrefs.current) {
+ savePrefs({
+ sort,
+ prioritizeFollowedUsers,
+ lab_treeViewEnabled: view === 'tree',
+ })
+ userUpdatedPrefs.current = false
+ }
+
+ const setSortWrapped = useCallback(
+ (next: string) => {
+ userUpdatedPrefs.current = true
+ setSort(normalizeSort(next))
+ },
+ [setSort],
+ )
+ const setViewWrapped = useCallback(
+ (next: ThreadViewOption) => {
+ userUpdatedPrefs.current = true
+ setView(next)
+ },
+ [setView],
+ )
+ const setPrioritizeFollowedUsersWrapped = useCallback(
+ (next: boolean) => {
+ userUpdatedPrefs.current = true
+ setPrioritizeFollowedUsers(next)
+ },
+ [setPrioritizeFollowedUsers],
+ )
+
+ return useMemo(
+ () => ({
+ isLoaded,
+ isSaving,
+ sort,
+ setSort: setSortWrapped,
+ view,
+ setView: setViewWrapped,
+ prioritizeFollowedUsers,
+ setPrioritizeFollowedUsers: setPrioritizeFollowedUsersWrapped,
+ }),
+ [
+ isLoaded,
+ isSaving,
+ sort,
+ setSortWrapped,
+ view,
+ setViewWrapped,
+ prioritizeFollowedUsers,
+ setPrioritizeFollowedUsersWrapped,
+ ],
+ )
+}
+
+/**
+ * Migrates user thread preferences from the old sort values to V2
+ */
+export function normalizeSort(sort: string): ThreadSortOption {
+ switch (sort) {
+ case 'oldest':
+ return 'oldest'
+ case 'newest':
+ return 'newest'
+ default:
+ return 'top'
+ }
+}
+
+/**
+ * Transforms existing treeViewEnabled preference into a ThreadViewOption
+ */
+export function normalizeView({
+ treeViewEnabled,
+}: {
+ treeViewEnabled: boolean
+}): ThreadViewOption {
+ return treeViewEnabled ? 'tree' : 'linear'
+}
diff --git a/src/state/queries/usePostThread/const.ts b/src/state/queries/usePostThread/const.ts
new file mode 100644
index 0000000000..9b74361307
--- /dev/null
+++ b/src/state/queries/usePostThread/const.ts
@@ -0,0 +1,27 @@
+// eslint-disable-next-line @typescript-eslint/no-unused-vars
+import {type AppBskyUnspeccedGetPostThreadV2} from '@atproto/api'
+
+/**
+ * See the `below` param on {@link AppBskyUnspeccedGetPostThreadV2.QueryParams}
+ */
+export const LINEAR_VIEW_BELOW = 10
+
+/**
+ * See the `branchingFactor` param on {@link AppBskyUnspeccedGetPostThreadV2.QueryParams}
+ */
+export const LINEAR_VIEW_BF = 1
+
+/**
+ * See the `below` param on {@link AppBskyUnspeccedGetPostThreadV2.QueryParams}
+ */
+export const TREE_VIEW_BELOW = 4
+
+/**
+ * See the `branchingFactor` param on {@link AppBskyUnspeccedGetPostThreadV2.QueryParams}
+ */
+export const TREE_VIEW_BF = undefined
+
+/**
+ * See the `below` param on {@link AppBskyUnspeccedGetPostThreadV2.QueryParams}
+ */
+export const TREE_VIEW_BELOW_DESKTOP = 6
diff --git a/src/state/queries/usePostThread/index.ts b/src/state/queries/usePostThread/index.ts
new file mode 100644
index 0000000000..782888cfbe
--- /dev/null
+++ b/src/state/queries/usePostThread/index.ts
@@ -0,0 +1,325 @@
+import {useCallback, useMemo, useState} from 'react'
+import {useQuery, useQueryClient} from '@tanstack/react-query'
+
+import {isWeb} from '#/platform/detection'
+import {useModerationOpts} from '#/state/preferences/moderation-opts'
+import {useThreadPreferences} from '#/state/queries/preferences/useThreadPreferences'
+import {
+ LINEAR_VIEW_BELOW,
+ LINEAR_VIEW_BF,
+ TREE_VIEW_BELOW,
+ TREE_VIEW_BELOW_DESKTOP,
+ TREE_VIEW_BF,
+} from '#/state/queries/usePostThread/const'
+import {
+ createCacheMutator,
+ getThreadPlaceholder,
+} from '#/state/queries/usePostThread/queryCache'
+import {
+ buildThread,
+ sortAndAnnotateThreadItems,
+} from '#/state/queries/usePostThread/traversal'
+import {
+ createPostThreadOtherQueryKey,
+ createPostThreadQueryKey,
+ type ThreadItem,
+ type UsePostThreadQueryResult,
+} from '#/state/queries/usePostThread/types'
+import {getThreadgateRecord} from '#/state/queries/usePostThread/utils'
+import * as views from '#/state/queries/usePostThread/views'
+import {useAgent, useSession} from '#/state/session'
+import {useMergeThreadgateHiddenReplies} from '#/state/threadgate-hidden-replies'
+import {useBreakpoints} from '#/alf'
+
+export * from '#/state/queries/usePostThread/types'
+
+export function usePostThread({anchor}: {anchor?: string}) {
+ const qc = useQueryClient()
+ const agent = useAgent()
+ const {hasSession} = useSession()
+ const {gtPhone} = useBreakpoints()
+ const moderationOpts = useModerationOpts()
+ const mergeThreadgateHiddenReplies = useMergeThreadgateHiddenReplies()
+ const {
+ isLoaded: isThreadPreferencesLoaded,
+ sort,
+ setSort: baseSetSort,
+ view,
+ setView: baseSetView,
+ prioritizeFollowedUsers,
+ } = useThreadPreferences()
+ const below = useMemo(() => {
+ return view === 'linear'
+ ? LINEAR_VIEW_BELOW
+ : isWeb && gtPhone
+ ? TREE_VIEW_BELOW_DESKTOP
+ : TREE_VIEW_BELOW
+ }, [view, gtPhone])
+
+ const postThreadQueryKey = createPostThreadQueryKey({
+ anchor,
+ sort,
+ view,
+ prioritizeFollowedUsers,
+ })
+ const postThreadOtherQueryKey = createPostThreadOtherQueryKey({
+ anchor,
+ prioritizeFollowedUsers,
+ })
+
+ const query = useQuery({
+ enabled: isThreadPreferencesLoaded && !!anchor && !!moderationOpts,
+ queryKey: postThreadQueryKey,
+ async queryFn(ctx) {
+ const {data} = await agent.app.bsky.unspecced.getPostThreadV2({
+ anchor: anchor!,
+ branchingFactor: view === 'linear' ? LINEAR_VIEW_BF : TREE_VIEW_BF,
+ below,
+ sort: sort,
+ prioritizeFollowedUsers: prioritizeFollowedUsers,
+ })
+
+ /*
+ * Initialize `ctx.meta` to track if we know we have additional replies
+ * we could fetch once we hit the end.
+ */
+ ctx.meta = ctx.meta || {
+ hasOtherReplies: false,
+ }
+
+ /*
+ * If we know we have additional replies, we'll set this to true.
+ */
+ if (data.hasOtherReplies) {
+ ctx.meta.hasOtherReplies = true
+ }
+
+ const result = {
+ thread: data.thread || [],
+ threadgate: data.threadgate,
+ hasOtherReplies: !!ctx.meta.hasOtherReplies,
+ }
+
+ const record = getThreadgateRecord(result.threadgate)
+ if (result.threadgate && record) {
+ result.threadgate.record = record
+ }
+
+ return result as UsePostThreadQueryResult
+ },
+ placeholderData() {
+ if (!anchor) return
+ const placeholder = getThreadPlaceholder(qc, anchor)
+ /*
+ * Always return something here, even empty data, so that
+ * `isPlaceholderData` is always true, which we'll use to insert
+ * skeletons.
+ */
+ const thread = placeholder ? [placeholder] : []
+ return {thread, threadgate: undefined, hasOtherReplies: false}
+ },
+ select(data) {
+ const record = getThreadgateRecord(data.threadgate)
+ if (data.threadgate && record) {
+ data.threadgate.record = record
+ }
+ return data
+ },
+ })
+
+ const thread = useMemo(() => query.data?.thread || [], [query.data?.thread])
+ const threadgate = useMemo(
+ () => query.data?.threadgate,
+ [query.data?.threadgate],
+ )
+ const hasOtherThreadItems = useMemo(
+ () => !!query.data?.hasOtherReplies,
+ [query.data?.hasOtherReplies],
+ )
+ const [otherItemsVisible, setOtherItemsVisible] = useState(false)
+
+ /**
+ * Creates a mutator for the post thread cache. This is used to insert
+ * replies into the thread cache after posting.
+ */
+ const mutator = useMemo(
+ () =>
+ createCacheMutator({
+ params: {view, below},
+ postThreadQueryKey,
+ postThreadOtherQueryKey,
+ queryClient: qc,
+ }),
+ [qc, view, below, postThreadQueryKey, postThreadOtherQueryKey],
+ )
+
+ /**
+ * If we have additional items available from the server and the user has
+ * chosen to view them, start loading data
+ */
+ const additionalQueryEnabled = hasOtherThreadItems && otherItemsVisible
+ const additionalItemsQuery = useQuery({
+ enabled: additionalQueryEnabled,
+ queryKey: postThreadOtherQueryKey,
+ async queryFn() {
+ const {data} = await agent.app.bsky.unspecced.getPostThreadOtherV2({
+ anchor: anchor!,
+ prioritizeFollowedUsers,
+ })
+ return data
+ },
+ })
+ const serverOtherThreadItems: ThreadItem[] = useMemo(() => {
+ if (!additionalQueryEnabled) return []
+ if (additionalItemsQuery.isLoading) {
+ return Array.from({length: 2}).map((_, i) =>
+ views.skeleton({
+ key: `other-reply-${i}`,
+ item: 'reply',
+ }),
+ )
+ } else if (additionalItemsQuery.isError) {
+ /*
+ * We could insert an special error component in here, but since these
+ * are optional additional replies, it's not critical that they're shown
+ * atm.
+ */
+ return []
+ } else if (additionalItemsQuery.data?.thread) {
+ const {threadItems} = sortAndAnnotateThreadItems(
+ additionalItemsQuery.data.thread,
+ {
+ view,
+ skipModerationHandling: true,
+ threadgateHiddenReplies: mergeThreadgateHiddenReplies(
+ threadgate?.record,
+ ),
+ moderationOpts: moderationOpts!,
+ },
+ )
+ return threadItems
+ } else {
+ return []
+ }
+ }, [
+ view,
+ additionalQueryEnabled,
+ additionalItemsQuery,
+ mergeThreadgateHiddenReplies,
+ moderationOpts,
+ threadgate?.record,
+ ])
+
+ /**
+ * Sets the sort order for the thread and resets the additional thread items
+ */
+ const setSort: typeof baseSetSort = useCallback(
+ nextSort => {
+ setOtherItemsVisible(false)
+ baseSetSort(nextSort)
+ },
+ [baseSetSort, setOtherItemsVisible],
+ )
+
+ /**
+ * Sets the view variant for the thread and resets the additional thread items
+ */
+ const setView: typeof baseSetView = useCallback(
+ nextView => {
+ setOtherItemsVisible(false)
+ baseSetView(nextView)
+ },
+ [baseSetView, setOtherItemsVisible],
+ )
+
+ /*
+ * This is the main thread response, sorted into separate buckets based on
+ * moderation, and annotated with all UI state needed for rendering.
+ */
+ const {threadItems, otherThreadItems} = useMemo(() => {
+ return sortAndAnnotateThreadItems(thread, {
+ view: view,
+ threadgateHiddenReplies: mergeThreadgateHiddenReplies(threadgate?.record),
+ moderationOpts: moderationOpts!,
+ })
+ }, [
+ thread,
+ threadgate?.record,
+ mergeThreadgateHiddenReplies,
+ moderationOpts,
+ view,
+ ])
+
+ /*
+ * Take all three sets of thread items and combine them into a single thread,
+ * along with any other thread items required for rendering e.g. "Show more
+ * replies" or the reply composer.
+ */
+ const items = useMemo(() => {
+ return buildThread({
+ threadItems,
+ otherThreadItems,
+ serverOtherThreadItems,
+ isLoading: query.isPlaceholderData,
+ hasSession,
+ hasOtherThreadItems,
+ otherItemsVisible,
+ showOtherItems: () => setOtherItemsVisible(true),
+ })
+ }, [
+ threadItems,
+ otherThreadItems,
+ serverOtherThreadItems,
+ query.isPlaceholderData,
+ hasSession,
+ hasOtherThreadItems,
+ otherItemsVisible,
+ setOtherItemsVisible,
+ ])
+
+ return useMemo(
+ () => ({
+ state: {
+ /*
+ * Copy in any query state that is useful
+ */
+ isFetching: query.isFetching,
+ isPlaceholderData: query.isPlaceholderData,
+ error: query.error,
+ /*
+ * Other state
+ */
+ sort,
+ view,
+ otherItemsVisible,
+ },
+ data: {
+ items,
+ threadgate,
+ },
+ actions: {
+ /*
+ * Copy in any query actions that are useful
+ */
+ insertReplies: mutator.insertReplies,
+ refetch: query.refetch,
+ /*
+ * Other actions
+ */
+ setSort,
+ setView,
+ },
+ }),
+ [
+ query,
+ mutator.insertReplies,
+ otherItemsVisible,
+ sort,
+ view,
+ setSort,
+ setView,
+ threadgate,
+ items,
+ ],
+ )
+}
diff --git a/src/state/queries/usePostThread/queryCache.ts b/src/state/queries/usePostThread/queryCache.ts
new file mode 100644
index 0000000000..871033395f
--- /dev/null
+++ b/src/state/queries/usePostThread/queryCache.ts
@@ -0,0 +1,300 @@
+import {
+ type $Typed,
+ type AppBskyActorDefs,
+ type AppBskyFeedDefs,
+ AppBskyUnspeccedDefs,
+ type AppBskyUnspeccedGetPostThreadOtherV2,
+ type AppBskyUnspeccedGetPostThreadV2,
+ AtUri,
+} from '@atproto/api'
+import {type QueryClient} from '@tanstack/react-query'
+
+import {findAllPostsInQueryData as findAllPostsInExploreFeedPreviewsQueryData} from '#/state/queries/explore-feed-previews'
+import {findAllPostsInQueryData as findAllPostsInNotifsQueryData} from '#/state/queries/notifications/feed'
+import {findAllPostsInQueryData as findAllPostsInFeedQueryData} from '#/state/queries/post-feed'
+import {findAllPostsInQueryData as findAllPostsInQuoteQueryData} from '#/state/queries/post-quotes'
+import {findAllPostsInQueryData as findAllPostsInSearchQueryData} from '#/state/queries/search-posts'
+import {getBranch} from '#/state/queries/usePostThread/traversal'
+import {
+ type ApiThreadItem,
+ type createPostThreadOtherQueryKey,
+ type createPostThreadQueryKey,
+ type PostThreadParams,
+ postThreadQueryKeyRoot,
+} from '#/state/queries/usePostThread/types'
+import {getRootPostAtUri} from '#/state/queries/usePostThread/utils'
+import {postViewToThreadPlaceholder} from '#/state/queries/usePostThread/views'
+import {didOrHandleUriMatches, getEmbeddedPost} from '#/state/queries/util'
+import {embedViewRecordToPostView} from '#/state/queries/util'
+
+export function createCacheMutator({
+ queryClient,
+ postThreadQueryKey,
+ postThreadOtherQueryKey,
+ params,
+}: {
+ queryClient: QueryClient
+ postThreadQueryKey: ReturnType
+ postThreadOtherQueryKey: ReturnType
+ params: Pick & {below: number}
+}) {
+ return {
+ insertReplies(
+ parentUri: string,
+ replies: AppBskyUnspeccedGetPostThreadV2.ThreadItem[],
+ ) {
+ /*
+ * Main thread query mutator.
+ */
+ queryClient.setQueryData(
+ postThreadQueryKey,
+ data => {
+ if (!data) return
+ return {
+ ...data,
+ thread: mutator([
+ ...data.thread,
+ ]),
+ }
+ },
+ )
+
+ /*
+ * Additional replies query mutator.
+ */
+ queryClient.setQueryData(
+ postThreadOtherQueryKey,
+ data => {
+ if (!data) return
+ return {
+ ...data,
+ thread: mutator([
+ ...data.thread,
+ ]),
+ }
+ },
+ )
+
+ function mutator(thread: ApiThreadItem[]): T[] {
+ for (let i = 0; i < thread.length; i++) {
+ const existingParent = thread[i]
+ if (!AppBskyUnspeccedDefs.isThreadItemPost(existingParent.value))
+ continue
+ if (existingParent.uri !== parentUri) continue
+
+ /*
+ * Update parent data
+ */
+ existingParent.value.post = {
+ ...existingParent.value.post,
+ replyCount: (existingParent.value.post.replyCount || 0) + 1,
+ }
+
+ const opDid = getRootPostAtUri(existingParent.value.post)?.host
+ const nextItem = thread.at(i + 1)
+ const isReplyToRoot = existingParent.depth === 0
+ const isEndOfReplyChain =
+ !nextItem || nextItem.depth <= existingParent.depth
+ const firstReply = replies.at(0)
+ const opIsReplier = AppBskyUnspeccedDefs.isThreadItemPost(
+ firstReply?.value,
+ )
+ ? opDid === firstReply.value.post.author.did
+ : false
+
+ /*
+ * Always insert replies if the following conditions are met.
+ */
+ const shouldAlwaysInsertReplies =
+ isReplyToRoot ||
+ params.view === 'tree' ||
+ (params.view === 'linear' && isEndOfReplyChain)
+ /*
+ * Maybe insert replies if the replier is the OP and certain conditions are met
+ */
+ const shouldReplaceWithOPReplies =
+ !isReplyToRoot && params.view === 'linear' && opIsReplier
+
+ if (shouldAlwaysInsertReplies || shouldReplaceWithOPReplies) {
+ const branch = getBranch(thread, i, existingParent.depth)
+ /*
+ * OP insertions replace other replies _in linear view_.
+ */
+ const itemsToRemove = shouldReplaceWithOPReplies ? branch.length : 0
+ const itemsToInsert = replies
+ .map((r, ri) => {
+ r.depth = existingParent.depth + 1 + ri
+ return r
+ })
+ .filter(r => {
+ // Filter out replies that are too deep for our UI
+ return r.depth <= params.below
+ })
+
+ thread.splice(i + 1, itemsToRemove, ...itemsToInsert)
+ }
+ }
+
+ return thread as T[]
+ }
+ },
+ /**
+ * Unused atm, post shadow does the trick, but it would be nice to clean up
+ * the whole sub-tree on deletes.
+ */
+ deletePost(post: AppBskyUnspeccedGetPostThreadV2.ThreadItem) {
+ queryClient.setQueryData(
+ postThreadQueryKey,
+ queryData => {
+ if (!queryData) return
+
+ const thread = [...queryData.thread]
+
+ for (let i = 0; i < thread.length; i++) {
+ const existingPost = thread[i]
+ if (!AppBskyUnspeccedDefs.isThreadItemPost(post.value)) continue
+
+ if (existingPost.uri === post.uri) {
+ const branch = getBranch(thread, i, existingPost.depth)
+ thread.splice(branch.start, branch.length)
+ break
+ }
+ }
+
+ return {
+ ...queryData,
+ thread,
+ }
+ },
+ )
+ },
+ }
+}
+
+export function getThreadPlaceholder(
+ queryClient: QueryClient,
+ uri: string,
+): $Typed | void {
+ let partial
+ for (let item of getThreadPlaceholderCandidates(queryClient, uri)) {
+ /*
+ * Currently, the backend doesn't send full post info in some cases (for
+ * example, for quoted posts). We use missing `likeCount` as a way to
+ * detect that. In the future, we should fix this on the backend, which
+ * will let us always stop on the first result.
+ *
+ * TODO can we send in feeds and quotes?
+ */
+ const hasAllInfo = item.value.post.likeCount != null
+ if (hasAllInfo) {
+ return item
+ } else {
+ // Keep searching, we might still find a full post in the cache.
+ partial = item
+ }
+ }
+ return partial
+}
+
+export function* getThreadPlaceholderCandidates(
+ queryClient: QueryClient,
+ uri: string,
+): Generator<
+ $Typed<
+ Omit & {
+ value: $Typed
+ }
+ >,
+ void
+> {
+ /*
+ * Check post thread queries first
+ */
+ for (const post of findAllPostsInQueryData(queryClient, uri)) {
+ yield postViewToThreadPlaceholder(post)
+ }
+
+ /*
+ * Check notifications first. If you have a post in notifications, it's
+ * often due to a like or a repost, and we want to prioritize a post object
+ * with >0 likes/reposts over a stale version with no metrics in order to
+ * avoid a notification->post scroll jump.
+ */
+ for (let post of findAllPostsInNotifsQueryData(queryClient, uri)) {
+ yield postViewToThreadPlaceholder(post)
+ }
+ for (let post of findAllPostsInFeedQueryData(queryClient, uri)) {
+ yield postViewToThreadPlaceholder(post)
+ }
+ for (let post of findAllPostsInQuoteQueryData(queryClient, uri)) {
+ yield postViewToThreadPlaceholder(post)
+ }
+ for (let post of findAllPostsInSearchQueryData(queryClient, uri)) {
+ yield postViewToThreadPlaceholder(post)
+ }
+ for (let post of findAllPostsInExploreFeedPreviewsQueryData(
+ queryClient,
+ uri,
+ )) {
+ yield postViewToThreadPlaceholder(post)
+ }
+}
+
+export function* findAllPostsInQueryData(
+ queryClient: QueryClient,
+ uri: string,
+): Generator {
+ const atUri = new AtUri(uri)
+ const queryDatas =
+ queryClient.getQueriesData({
+ queryKey: [postThreadQueryKeyRoot],
+ })
+
+ for (const [_queryKey, queryData] of queryDatas) {
+ if (!queryData) continue
+
+ const {thread} = queryData
+
+ for (const item of thread) {
+ if (AppBskyUnspeccedDefs.isThreadItemPost(item.value)) {
+ if (didOrHandleUriMatches(atUri, item.value.post)) {
+ yield item.value.post
+ }
+
+ const qp = getEmbeddedPost(item.value.post.embed)
+ if (qp && didOrHandleUriMatches(atUri, qp)) {
+ yield embedViewRecordToPostView(qp)
+ }
+ }
+ }
+ }
+}
+
+export function* findAllProfilesInQueryData(
+ queryClient: QueryClient,
+ did: string,
+): Generator {
+ const queryDatas =
+ queryClient.getQueriesData({
+ queryKey: [postThreadQueryKeyRoot],
+ })
+
+ for (const [_queryKey, queryData] of queryDatas) {
+ if (!queryData) continue
+
+ const {thread} = queryData
+
+ for (const item of thread) {
+ if (AppBskyUnspeccedDefs.isThreadItemPost(item.value)) {
+ if (item.value.post.author.did === did) {
+ yield item.value.post.author
+ }
+
+ const qp = getEmbeddedPost(item.value.post.embed)
+ if (qp && qp.author.did === did) {
+ yield qp.author
+ }
+ }
+ }
+ }
+}
diff --git a/src/state/queries/usePostThread/traversal.ts b/src/state/queries/usePostThread/traversal.ts
new file mode 100644
index 0000000000..fbae4ecdbf
--- /dev/null
+++ b/src/state/queries/usePostThread/traversal.ts
@@ -0,0 +1,539 @@
+/* eslint-disable no-labels */
+import {AppBskyUnspeccedDefs, type ModerationOpts} from '@atproto/api'
+
+import {
+ type ApiThreadItem,
+ type PostThreadParams,
+ type ThreadItem,
+ type TraversalMetadata,
+} from '#/state/queries/usePostThread/types'
+import {
+ getPostRecord,
+ getThreadPostNoUnauthenticatedUI,
+ getThreadPostUI,
+ getTraversalMetadata,
+ storeTraversalMetadata,
+} from '#/state/queries/usePostThread/utils'
+import * as views from '#/state/queries/usePostThread/views'
+
+export function sortAndAnnotateThreadItems(
+ thread: ApiThreadItem[],
+ {
+ threadgateHiddenReplies,
+ moderationOpts,
+ view,
+ skipModerationHandling,
+ }: {
+ threadgateHiddenReplies: Set
+ moderationOpts: ModerationOpts
+ view: PostThreadParams['view']
+ /**
+ * Set to `true` in cases where we already know the moderation state of the
+ * post e.g. when fetching additional replies from the server. This will
+ * prevent additional sorting or nested-branch truncation, and all replies,
+ * regardless of moderation state, will be included in the resulting
+ * `threadItems` array.
+ */
+ skipModerationHandling?: boolean
+ },
+) {
+ const threadItems: ThreadItem[] = []
+ const otherThreadItems: ThreadItem[] = []
+ const metadatas = new Map()
+
+ traversal: for (let i = 0; i < thread.length; i++) {
+ const item = thread[i]
+ let parentMetadata: TraversalMetadata | undefined
+ let metadata: TraversalMetadata | undefined
+
+ if (AppBskyUnspeccedDefs.isThreadItemPost(item.value)) {
+ parentMetadata = metadatas.get(
+ getPostRecord(item.value.post).reply?.parent?.uri || '',
+ )
+ metadata = getTraversalMetadata({
+ item,
+ parentMetadata,
+ prevItem: thread.at(i - 1),
+ nextItem: thread.at(i + 1),
+ })
+ storeTraversalMetadata(metadatas, metadata)
+ }
+
+ if (item.depth < 0) {
+ /*
+ * Parents are ignored until we find the anchor post, then we walk
+ * _up_ from there.
+ */
+ } else if (item.depth === 0) {
+ if (AppBskyUnspeccedDefs.isThreadItemNoUnauthenticated(item.value)) {
+ threadItems.push(views.threadPostNoUnauthenticated(item))
+ } else if (AppBskyUnspeccedDefs.isThreadItemNotFound(item.value)) {
+ threadItems.push(views.threadPostNotFound(item))
+ } else if (AppBskyUnspeccedDefs.isThreadItemBlocked(item.value)) {
+ threadItems.push(views.threadPostBlocked(item))
+ } else if (AppBskyUnspeccedDefs.isThreadItemPost(item.value)) {
+ const post = views.threadPost({
+ uri: item.uri,
+ depth: item.depth,
+ value: item.value,
+ moderationOpts,
+ threadgateHiddenReplies,
+ })
+ threadItems.push(post)
+
+ parentTraversal: for (let pi = i - 1; pi >= 0; pi--) {
+ const parent = thread[pi]
+
+ if (
+ AppBskyUnspeccedDefs.isThreadItemNoUnauthenticated(parent.value)
+ ) {
+ const post = views.threadPostNoUnauthenticated(parent)
+ post.ui = getThreadPostNoUnauthenticatedUI({
+ depth: parent.depth,
+ // ignore for now
+ // prevItemDepth: thread[pi - 1]?.depth,
+ nextItemDepth: thread[pi + 1]?.depth,
+ })
+ threadItems.unshift(post)
+ // for now, break parent traversal at first no-unauthed
+ break parentTraversal
+ } else if (AppBskyUnspeccedDefs.isThreadItemNotFound(parent.value)) {
+ threadItems.unshift(views.threadPostNotFound(parent))
+ break parentTraversal
+ } else if (AppBskyUnspeccedDefs.isThreadItemBlocked(parent.value)) {
+ threadItems.unshift(views.threadPostBlocked(parent))
+ break parentTraversal
+ } else if (AppBskyUnspeccedDefs.isThreadItemPost(parent.value)) {
+ threadItems.unshift(
+ views.threadPost({
+ uri: parent.uri,
+ depth: parent.depth,
+ value: parent.value,
+ moderationOpts,
+ threadgateHiddenReplies,
+ }),
+ )
+ }
+ }
+ }
+ } else if (item.depth > 0) {
+ /*
+ * The API does not send down any unavailable replies, so this will
+ * always be false (for now). If we ever wanted to tombstone them here,
+ * we could.
+ */
+ const shouldBreak =
+ AppBskyUnspeccedDefs.isThreadItemNoUnauthenticated(item.value) ||
+ AppBskyUnspeccedDefs.isThreadItemNotFound(item.value) ||
+ AppBskyUnspeccedDefs.isThreadItemBlocked(item.value)
+
+ if (shouldBreak) {
+ const branch = getBranch(thread, i, item.depth)
+ // could insert tombstone
+ i = branch.end
+ continue traversal
+ } else if (AppBskyUnspeccedDefs.isThreadItemPost(item.value)) {
+ if (parentMetadata) {
+ /*
+ * Set this value before incrementing the parent's repliesSeenCounter
+ */
+ metadata!.replyIndex = parentMetadata.repliesIndexCounter
+ // Increment the parent's repliesIndexCounter
+ parentMetadata.repliesIndexCounter += 1
+ }
+
+ const post = views.threadPost({
+ uri: item.uri,
+ depth: item.depth,
+ value: item.value,
+ moderationOpts,
+ threadgateHiddenReplies,
+ })
+
+ if (!post.isBlurred || skipModerationHandling) {
+ /*
+ * Not moderated, need to insert it
+ */
+ threadItems.push(post)
+
+ /*
+ * Update seen reply count of parent
+ */
+ if (parentMetadata) {
+ parentMetadata.repliesSeenCounter += 1
+ }
+ } else {
+ /*
+ * Moderated in some way, we're going to walk children
+ */
+ const parent = post
+ const parentIsTopLevelReply = parent.depth === 1
+ // get sub tree
+ const branch = getBranch(thread, i, item.depth)
+
+ if (parentIsTopLevelReply) {
+ // push branch anchor into sorted array
+ otherThreadItems.push(parent)
+ // skip branch anchor in branch traversal
+ const startIndex = branch.start + 1
+
+ for (let ci = startIndex; ci <= branch.end; ci++) {
+ const child = thread[ci]
+
+ if (AppBskyUnspeccedDefs.isThreadItemPost(child.value)) {
+ const childParentMetadata = metadatas.get(
+ getPostRecord(child.value.post).reply?.parent?.uri || '',
+ )
+ const childMetadata = getTraversalMetadata({
+ item: child,
+ prevItem: thread[ci - 1],
+ nextItem: thread[ci + 1],
+ parentMetadata: childParentMetadata,
+ })
+ storeTraversalMetadata(metadatas, childMetadata)
+ if (childParentMetadata) {
+ /*
+ * Set this value before incrementing the parent's repliesIndexCounter
+ */
+ childMetadata!.replyIndex =
+ childParentMetadata.repliesIndexCounter
+ childParentMetadata.repliesIndexCounter += 1
+ }
+
+ const childPost = views.threadPost({
+ uri: child.uri,
+ depth: child.depth,
+ value: child.value,
+ moderationOpts,
+ threadgateHiddenReplies,
+ })
+
+ /*
+ * If a child is moderated in any way, drop it an its sub-branch
+ * entirely. To reveal these, the user must navigate to the
+ * parent post directly.
+ */
+ if (childPost.isBlurred) {
+ ci = getBranch(thread, ci, child.depth).end
+ } else {
+ otherThreadItems.push(childPost)
+
+ if (childParentMetadata) {
+ childParentMetadata.repliesSeenCounter += 1
+ }
+ }
+ } else {
+ /*
+ * Drop the rest of the branch if we hit anything unexpected
+ */
+ break
+ }
+ }
+ }
+
+ /*
+ * Skip to next branch
+ */
+ i = branch.end
+ continue traversal
+ }
+ }
+ }
+ }
+
+ /*
+ * Both `threadItems` and `otherThreadItems` now need to be traversed again to fully compute
+ * UI state based on collected metadata. These arrays will be muted in situ.
+ */
+ for (const subset of [threadItems, otherThreadItems]) {
+ for (let i = 0; i < subset.length; i++) {
+ const item = subset[i]
+ const prevItem = subset.at(i - 1)
+ const nextItem = subset.at(i + 1)
+
+ if (item.type === 'threadPost') {
+ const metadata = metadatas.get(item.uri)
+
+ if (metadata) {
+ if (metadata.parentMetadata) {
+ /*
+ * Track what's before/after now that we've applied moderation
+ */
+ if (prevItem?.type === 'threadPost')
+ metadata.prevItemDepth = prevItem?.depth
+ if (nextItem?.type === 'threadPost')
+ metadata.nextItemDepth = nextItem?.depth
+
+ /*
+ * We can now officially calculate `isLastSibling` and `isLastChild`
+ * based on the actual data that we've seen.
+ */
+ metadata.isLastSibling =
+ metadata.replyIndex ===
+ metadata.parentMetadata.repliesSeenCounter - 1
+ metadata.isLastChild =
+ metadata.nextItemDepth === undefined ||
+ metadata.nextItemDepth <= metadata.depth
+
+ /*
+ * If this is the last sibling, it's implicitly part of the last
+ * branch of this sub-tree.
+ */
+ if (metadata.isLastSibling) {
+ metadata.isPartOfLastBranchFromDepth = metadata.depth
+
+ /**
+ * If the parent is part of the last branch of the sub-tree, so is the child.
+ */
+ if (metadata.parentMetadata.isPartOfLastBranchFromDepth) {
+ metadata.isPartOfLastBranchFromDepth =
+ metadata.parentMetadata.isPartOfLastBranchFromDepth
+ }
+ }
+
+ /*
+ * If this is the last sibling, and the parent has unhydrated replies,
+ * at some point down the line we will need to show a "read more".
+ */
+ if (
+ metadata.parentMetadata.repliesUnhydrated > 0 &&
+ metadata.isLastSibling
+ ) {
+ metadata.upcomingParentReadMore = metadata.parentMetadata
+ }
+
+ /*
+ * Copy in the parent's upcoming read more, if it exists. Once we
+ * reach the bottom, we'll insert a "read more"
+ */
+ if (metadata.parentMetadata.upcomingParentReadMore) {
+ metadata.upcomingParentReadMore =
+ metadata.parentMetadata.upcomingParentReadMore
+ }
+
+ /*
+ * Copy in the parent's skipped indents
+ */
+ metadata.skippedIndentIndices = new Set([
+ ...metadata.parentMetadata.skippedIndentIndices,
+ ])
+
+ /**
+ * If this is the last sibling, and the parent has no unhydrated
+ * replies, then we know we can skip an indent line.
+ */
+ if (
+ metadata.parentMetadata.repliesUnhydrated <= 0 &&
+ metadata.isLastSibling
+ ) {
+ /**
+ * Depth is 2 more than the 0-index of the indent calculation
+ * bc of how we render these. So instead of handling that in the
+ * component, we just adjust that back to 0-index here.
+ */
+ metadata.skippedIndentIndices.add(item.depth - 2)
+ }
+ }
+
+ /*
+ * If this post has unhydrated replies, and it is the last child, then
+ * it itself needs a "read more"
+ */
+ if (metadata.repliesUnhydrated > 0 && metadata.isLastChild) {
+ metadata.precedesChildReadMore = true
+ subset.splice(i + 1, 0, views.readMore(metadata))
+ i++ // skip next iteration
+ }
+
+ /*
+ * Tree-view only.
+ *
+ * If there's an upcoming parent read more, this branch is part of the
+ * last branch of the sub-tree, and the item itself is the last child,
+ * insert the parent "read more".
+ */
+ if (
+ view === 'tree' &&
+ metadata.upcomingParentReadMore &&
+ metadata.isPartOfLastBranchFromDepth ===
+ metadata.upcomingParentReadMore.depth &&
+ metadata.isLastChild
+ ) {
+ subset.splice(
+ i + 1,
+ 0,
+ views.readMore(metadata.upcomingParentReadMore),
+ )
+ i++
+ }
+
+ /**
+ * Only occurs for the first item in the thread, which may have
+ * additional parents not included in this request.
+ */
+ if (item.value.moreParents) {
+ metadata.followsReadMoreUp = true
+ subset.splice(i, 0, views.readMoreUp(metadata))
+ i++
+ }
+
+ /*
+ * Calculate the final UI state for the thread item.
+ */
+ item.ui = getThreadPostUI(metadata)
+ }
+ }
+ }
+ }
+
+ return {
+ threadItems,
+ otherThreadItems,
+ }
+}
+
+export function buildThread({
+ threadItems,
+ otherThreadItems,
+ serverOtherThreadItems,
+ isLoading,
+ hasSession,
+ otherItemsVisible,
+ hasOtherThreadItems,
+ showOtherItems,
+}: {
+ threadItems: ThreadItem[]
+ otherThreadItems: ThreadItem[]
+ serverOtherThreadItems: ThreadItem[]
+ isLoading: boolean
+ hasSession: boolean
+ otherItemsVisible: boolean
+ hasOtherThreadItems: boolean
+ showOtherItems: () => void
+}) {
+ /**
+ * `threadItems` is memoized here, so don't mutate it directly.
+ */
+ const items = [...threadItems]
+
+ if (isLoading) {
+ const anchorPost = items.at(0)
+ const hasAnchorFromCache = anchorPost && anchorPost.type === 'threadPost'
+ const skeletonReplies = hasAnchorFromCache
+ ? anchorPost.value.post.replyCount ?? 4
+ : 4
+
+ if (!items.length) {
+ items.push(
+ views.skeleton({
+ key: 'anchor-skeleton',
+ item: 'anchor',
+ }),
+ )
+ }
+
+ if (hasSession) {
+ // we might have this from cache
+ const replyDisabled =
+ hasAnchorFromCache &&
+ anchorPost.value.post.viewer?.replyDisabled === true
+
+ if (hasAnchorFromCache) {
+ if (!replyDisabled) {
+ items.push({
+ type: 'replyComposer',
+ key: 'replyComposer',
+ })
+ }
+ } else {
+ items.push(
+ views.skeleton({
+ key: 'replyComposer',
+ item: 'replyComposer',
+ }),
+ )
+ }
+ }
+
+ for (let i = 0; i < skeletonReplies; i++) {
+ items.push(
+ views.skeleton({
+ key: `anchor-skeleton-reply-${i}`,
+ item: 'reply',
+ }),
+ )
+ }
+ } else {
+ for (let i = 0; i < items.length; i++) {
+ const item = items[i]
+ if (
+ item.type === 'threadPost' &&
+ item.depth === 0 &&
+ !item.value.post.viewer?.replyDisabled &&
+ hasSession
+ ) {
+ items.splice(i + 1, 0, {
+ type: 'replyComposer',
+ key: 'replyComposer',
+ })
+ break
+ }
+ }
+
+ if (otherThreadItems.length || hasOtherThreadItems) {
+ if (otherItemsVisible) {
+ items.push(...otherThreadItems)
+ items.push(...serverOtherThreadItems)
+ } else {
+ items.push({
+ type: 'showOtherReplies',
+ key: 'showOtherReplies',
+ onPress: showOtherItems,
+ })
+ }
+ }
+ }
+
+ return items
+}
+
+/**
+ * Get the start and end index of a "branch" of the thread. A "branch" is a
+ * parent and it's children (not siblings). Returned indices are inclusive of
+ * the parent and its last child.
+ *
+ * items[] (index, depth)
+ * └─┬ anchor ──────── (0, 0)
+ * ├─── branch ───── (1, 1)
+ * ├──┬ branch ───── (2, 1) (start)
+ * │ ├──┬ leaf ──── (3, 2)
+ * │ │ └── leaf ── (4, 3)
+ * │ └─── leaf ──── (5, 2) (end)
+ * ├─── branch ───── (6, 1)
+ * └─── branch ───── (7, 1)
+ *
+ * const { start: 2, end: 5, length: 3 } = getBranch(items, 2, 1)
+ */
+export function getBranch(
+ thread: ApiThreadItem[],
+ branchStartIndex: number,
+ branchStartDepth: number,
+) {
+ let end = branchStartIndex
+
+ for (let ci = branchStartIndex + 1; ci < thread.length; ci++) {
+ const next = thread[ci]
+ if (next.depth > branchStartDepth) {
+ end = ci
+ } else {
+ end = ci - 1
+ break
+ }
+ }
+
+ return {
+ start: branchStartIndex,
+ end,
+ length: end - branchStartIndex,
+ }
+}
diff --git a/src/state/queries/usePostThread/types.ts b/src/state/queries/usePostThread/types.ts
new file mode 100644
index 0000000000..2f370b0ab7
--- /dev/null
+++ b/src/state/queries/usePostThread/types.ts
@@ -0,0 +1,227 @@
+import {
+ type AppBskyFeedDefs,
+ type AppBskyFeedPost,
+ type AppBskyFeedThreadgate,
+ type AppBskyUnspeccedDefs,
+ type AppBskyUnspeccedGetPostThreadOtherV2,
+ type AppBskyUnspeccedGetPostThreadV2,
+ type ModerationDecision,
+} from '@atproto/api'
+
+export type ApiThreadItem =
+ | AppBskyUnspeccedGetPostThreadV2.ThreadItem
+ | AppBskyUnspeccedGetPostThreadOtherV2.ThreadItem
+
+export const postThreadQueryKeyRoot = 'post-thread-v2' as const
+
+export const createPostThreadQueryKey = (props: PostThreadParams) =>
+ [postThreadQueryKeyRoot, props] as const
+
+export const createPostThreadOtherQueryKey = (
+ props: Omit & {
+ anchor?: string
+ },
+) => [postThreadQueryKeyRoot, 'other', props] as const
+
+export type PostThreadParams = Pick<
+ AppBskyUnspeccedGetPostThreadV2.QueryParams,
+ 'sort' | 'prioritizeFollowedUsers'
+> & {
+ anchor?: string
+ view: 'tree' | 'linear'
+}
+
+export type UsePostThreadQueryResult = {
+ hasOtherReplies: boolean
+ thread: AppBskyUnspeccedGetPostThreadV2.ThreadItem[]
+ threadgate?: Omit & {
+ record: AppBskyFeedThreadgate.Record
+ }
+}
+
+export type ThreadItem =
+ | {
+ type: 'threadPost'
+ key: string
+ uri: string
+ depth: number
+ value: Omit & {
+ post: Omit & {
+ record: AppBskyFeedPost.Record
+ }
+ }
+ isBlurred: boolean
+ moderation: ModerationDecision
+ ui: {
+ isAnchor: boolean
+ showParentReplyLine: boolean
+ showChildReplyLine: boolean
+ indent: number
+ isLastChild: boolean
+ skippedIndentIndices: Set
+ precedesChildReadMore: boolean
+ }
+ }
+ | {
+ type: 'threadPostNoUnauthenticated'
+ key: string
+ uri: string
+ depth: number
+ value: AppBskyUnspeccedDefs.ThreadItemNoUnauthenticated
+ ui: {
+ showParentReplyLine: boolean
+ showChildReplyLine: boolean
+ }
+ }
+ | {
+ type: 'threadPostNotFound'
+ key: string
+ uri: string
+ depth: number
+ value: AppBskyUnspeccedDefs.ThreadItemNotFound
+ }
+ | {
+ type: 'threadPostBlocked'
+ key: string
+ uri: string
+ depth: number
+ value: AppBskyUnspeccedDefs.ThreadItemBlocked
+ }
+ | {
+ type: 'replyComposer'
+ key: string
+ }
+ | {
+ type: 'showOtherReplies'
+ key: string
+ onPress: () => void
+ }
+ | {
+ /*
+ * Read more replies, downwards in the thread.
+ */
+ type: 'readMore'
+ key: string
+ depth: number
+ href: string
+ moreReplies: number
+ skippedIndentIndices: Set
+ }
+ | {
+ /*
+ * Read more parents, upwards in the thread.
+ */
+ type: 'readMoreUp'
+ key: string
+ href: string
+ }
+ | {
+ type: 'skeleton'
+ key: string
+ item: 'anchor' | 'reply' | 'replyComposer'
+ }
+
+/**
+ * Metadata collected while traversing the raw data from the thread response.
+ * Some values here can be computed immediately, while others need to be
+ * computed during a second pass over the thread after we know things like
+ * total number of replies, the reply index, etc.
+ *
+ * The idea here is that these values should be objectively true in all cases,
+ * such that we can use them later — either individually on in composite — to
+ * drive rendering behaviors.
+ */
+export type TraversalMetadata = {
+ /**
+ * The depth of the post in the reply tree, where 0 is the root post. This is
+ * calculated on the server.
+ */
+ depth: number
+ /**
+ * Indicates if this item is a "read more" link preceding this post that
+ * continues the thread upwards.
+ */
+ followsReadMoreUp: boolean
+ /**
+ * Indicates if the post is the last reply beneath its parent post.
+ */
+ isLastSibling: boolean
+ /**
+ * Indicates the post is the end-of-the-line for a given branch of replies.
+ */
+ isLastChild: boolean
+ /**
+ * Indicates if the post is the left/lower-most branch of the reply tree.
+ * Value corresponds to the depth at which this branch started.
+ */
+ isPartOfLastBranchFromDepth?: number
+ /**
+ * The depth of the slice immediately following this one, if it exists.
+ */
+ nextItemDepth?: number
+ /**
+ * This is a live reference to the parent metadata object. Mutations to this
+ * are available for later use in children.
+ */
+ parentMetadata?: TraversalMetadata
+ /**
+ * Populated during the final traversal of the thread. Denotes whether
+ * there is a "Read more" link for this item immediately following
+ * this item.
+ */
+ precedesChildReadMore: boolean
+ /**
+ * The depth of the slice immediately preceding this one, if it exists.
+ */
+ prevItemDepth?: number
+ /**
+ * Any data needed to be passed along to the "read more" items. Keep this
+ * trim for better memory usage.
+ */
+ postData: {
+ uri: string
+ authorHandle: string
+ }
+ /**
+ * The total number of replies to this post, including those not hydrated
+ * and returned by the response.
+ */
+ repliesCount: number
+ /**
+ * The number of replies to this post not hydrated and returned by the
+ * response.
+ */
+ repliesUnhydrated: number
+ /**
+ * The number of replies that have been seen so far in the traversal.
+ * Excludes replies that are moderated in some way, since those are not
+ * "seen" on first load. Use `repliesIndexCounter` for the total number of
+ * replies that were hydrated in the response.
+ *
+ * After traversal, we can use this to calculate if we actually got all the
+ * replies we expected, or if some were blocked, etc.
+ */
+ repliesSeenCounter: number
+ /**
+ * The total number of replies to this post hydrated in this response. Used
+ * for populating the `replyIndex` of the post by referencing this value on
+ * the parent.
+ */
+ repliesIndexCounter: number
+ /**
+ * The index-0-based index of this reply in the parent post's replies.
+ */
+ replyIndex: number
+ /**
+ * Each slice is responsible for rendering reply lines based on its depth.
+ * This value corresponds to any line indices that can be skipped e.g.
+ * because there are no further replies below this sub-tree to render.
+ */
+ skippedIndentIndices: Set
+ /**
+ * Indicates and stores parent data IF that parent has additional unhydrated
+ * replies. This value is passed down to children along the left/lower-most
+ * branch of the tree. When the end is reached, a "read more" is inserted.
+ */
+ upcomingParentReadMore?: TraversalMetadata
+}
diff --git a/src/state/queries/usePostThread/utils.ts b/src/state/queries/usePostThread/utils.ts
new file mode 100644
index 0000000000..b8ab340d87
--- /dev/null
+++ b/src/state/queries/usePostThread/utils.ts
@@ -0,0 +1,170 @@
+import {
+ type AppBskyFeedDefs,
+ AppBskyFeedPost,
+ AppBskyFeedThreadgate,
+ AppBskyUnspeccedDefs,
+ type AppBskyUnspeccedGetPostThreadV2,
+ AtUri,
+} from '@atproto/api'
+
+import {
+ type ApiThreadItem,
+ type ThreadItem,
+ type TraversalMetadata,
+} from '#/state/queries/usePostThread/types'
+import {isDevMode} from '#/storage/hooks/dev-mode'
+import * as bsky from '#/types/bsky'
+
+export function getThreadgateRecord(
+ view: AppBskyUnspeccedGetPostThreadV2.OutputSchema['threadgate'],
+) {
+ return bsky.dangerousIsType(
+ view?.record,
+ AppBskyFeedThreadgate.isRecord,
+ )
+ ? view?.record
+ : undefined
+}
+
+export function getRootPostAtUri(post: AppBskyFeedDefs.PostView) {
+ if (
+ bsky.dangerousIsType(
+ post.record,
+ AppBskyFeedPost.isRecord,
+ )
+ ) {
+ if (post.record.reply?.root?.uri) {
+ return new AtUri(post.record.reply.root.uri)
+ }
+ }
+}
+
+export function getPostRecord(post: AppBskyFeedDefs.PostView) {
+ return post.record as AppBskyFeedPost.Record
+}
+
+export function getTraversalMetadata({
+ item,
+ prevItem,
+ nextItem,
+ parentMetadata,
+}: {
+ item: ApiThreadItem
+ prevItem?: ApiThreadItem
+ nextItem?: ApiThreadItem
+ parentMetadata?: TraversalMetadata
+}): TraversalMetadata {
+ if (!AppBskyUnspeccedDefs.isThreadItemPost(item.value)) {
+ throw new Error(`Expected thread item to be a post`)
+ }
+ const repliesCount = item.value.post.replyCount || 0
+ const repliesUnhydrated = item.value.moreReplies || 0
+ const metadata = {
+ depth: item.depth,
+ /*
+ * Unknown until after traversal
+ */
+ isLastChild: false,
+ /*
+ * Unknown until after traversal
+ */
+ isLastSibling: false,
+ /*
+ * If it's a top level reply, bc we render each top-level branch as a
+ * separate tree, it's implicitly part of the last branch. For subsequent
+ * replies, we'll override this after traversal.
+ */
+ isPartOfLastBranchFromDepth: item.depth === 1 ? 1 : undefined,
+ nextItemDepth: nextItem?.depth,
+ parentMetadata,
+ prevItemDepth: prevItem?.depth,
+ /*
+ * Unknown until after traversal
+ */
+ precedesChildReadMore: false,
+ /*
+ * Unknown until after traversal
+ */
+ followsReadMoreUp: false,
+ postData: {
+ uri: item.uri,
+ authorHandle: item.value.post.author.handle,
+ },
+ repliesCount,
+ repliesUnhydrated,
+ repliesSeenCounter: 0,
+ repliesIndexCounter: 0,
+ replyIndex: 0,
+ skippedIndentIndices: new Set(),
+ }
+
+ if (isDevMode()) {
+ // @ts-ignore dev only for debugging
+ metadata.postData.text = getPostRecord(item.value.post).text
+ }
+
+ return metadata
+}
+
+export function storeTraversalMetadata(
+ metadatas: Map,
+ metadata: TraversalMetadata,
+) {
+ metadatas.set(metadata.postData.uri, metadata)
+
+ if (isDevMode()) {
+ // @ts-ignore dev only for debugging
+ metadatas.set(metadata.postData.text, metadata)
+ // @ts-ignore
+ window.__thread = metadatas
+ }
+}
+
+export function getThreadPostUI({
+ depth,
+ repliesCount,
+ prevItemDepth,
+ isLastChild,
+ skippedIndentIndices,
+ repliesSeenCounter,
+ repliesUnhydrated,
+ precedesChildReadMore,
+ followsReadMoreUp,
+}: TraversalMetadata): Extract['ui'] {
+ const isReplyAndHasReplies =
+ depth > 0 &&
+ repliesCount > 0 &&
+ (repliesCount - repliesUnhydrated === repliesSeenCounter ||
+ repliesSeenCounter > 0)
+ return {
+ isAnchor: depth === 0,
+ showParentReplyLine:
+ followsReadMoreUp ||
+ (!!prevItemDepth && prevItemDepth !== 0 && prevItemDepth < depth),
+ showChildReplyLine: depth < 0 || isReplyAndHasReplies,
+ indent: depth,
+ /*
+ * If there are no slices below this one, or the next slice has a depth <=
+ * than the depth of this post, it's the last child of the reply tree. It
+ * is not necessarily the last leaf in the parent branch, since it could
+ * have another sibling.
+ */
+ isLastChild,
+ skippedIndentIndices,
+ precedesChildReadMore: precedesChildReadMore ?? false,
+ }
+}
+
+export function getThreadPostNoUnauthenticatedUI({
+ depth,
+ prevItemDepth,
+}: {
+ depth: number
+ prevItemDepth?: number
+ nextItemDepth?: number
+}): Extract['ui'] {
+ return {
+ showChildReplyLine: depth < 0,
+ showParentReplyLine: Boolean(prevItemDepth && prevItemDepth < depth),
+ }
+}
diff --git a/src/state/queries/usePostThread/views.ts b/src/state/queries/usePostThread/views.ts
new file mode 100644
index 0000000000..71acfc77bc
--- /dev/null
+++ b/src/state/queries/usePostThread/views.ts
@@ -0,0 +1,183 @@
+import {
+ type $Typed,
+ type AppBskyFeedDefs,
+ type AppBskyFeedPost,
+ type AppBskyUnspeccedDefs,
+ type AppBskyUnspeccedGetPostThreadV2,
+ AtUri,
+ moderatePost,
+ type ModerationOpts,
+} from '@atproto/api'
+
+import {makeProfileLink} from '#/lib/routes/links'
+import {
+ type ApiThreadItem,
+ type ThreadItem,
+ type TraversalMetadata,
+} from '#/state/queries/usePostThread/types'
+
+export function threadPostNoUnauthenticated({
+ uri,
+ depth,
+ value,
+}: ApiThreadItem): Extract {
+ return {
+ type: 'threadPostNoUnauthenticated',
+ key: uri,
+ uri,
+ depth,
+ value: value as AppBskyUnspeccedDefs.ThreadItemNoUnauthenticated,
+ // @ts-ignore populated by the traversal
+ ui: {},
+ }
+}
+
+export function threadPostNotFound({
+ uri,
+ depth,
+ value,
+}: ApiThreadItem): Extract {
+ return {
+ type: 'threadPostNotFound',
+ key: uri,
+ uri,
+ depth,
+ value: value as AppBskyUnspeccedDefs.ThreadItemNotFound,
+ }
+}
+
+export function threadPostBlocked({
+ uri,
+ depth,
+ value,
+}: ApiThreadItem): Extract {
+ return {
+ type: 'threadPostBlocked',
+ key: uri,
+ uri,
+ depth,
+ value: value as AppBskyUnspeccedDefs.ThreadItemBlocked,
+ }
+}
+
+export function threadPost({
+ uri,
+ depth,
+ value,
+ moderationOpts,
+ threadgateHiddenReplies,
+}: {
+ uri: string
+ depth: number
+ value: $Typed
+ moderationOpts: ModerationOpts
+ threadgateHiddenReplies: Set
+}): Extract {
+ const moderation = moderatePost(value.post, moderationOpts)
+ const modui = moderation.ui('contentList')
+ const blurred = modui.blur || modui.filter
+ const muted = (modui.blurs[0] || modui.filters[0])?.type === 'muted'
+ const hiddenByThreadgate = threadgateHiddenReplies.has(uri)
+ const isBlurred = hiddenByThreadgate || blurred || muted
+ return {
+ type: 'threadPost',
+ key: uri,
+ uri,
+ depth,
+ value: {
+ ...value,
+ /*
+ * Do not spread anything here, load bearing for post shadow strict
+ * equality reference checks.
+ */
+ post: value.post as Omit & {
+ record: AppBskyFeedPost.Record
+ },
+ },
+ isBlurred,
+ moderation,
+ // @ts-ignore populated by the traversal
+ ui: {},
+ }
+}
+
+export function readMore({
+ depth,
+ repliesUnhydrated,
+ skippedIndentIndices,
+ postData,
+}: TraversalMetadata): Extract {
+ const urip = new AtUri(postData.uri)
+ const href = makeProfileLink(
+ {
+ did: urip.host,
+ handle: postData.authorHandle,
+ },
+ 'post',
+ urip.rkey,
+ )
+ return {
+ type: 'readMore' as const,
+ key: `readMore:${postData.uri}`,
+ href,
+ moreReplies: repliesUnhydrated,
+ depth,
+ skippedIndentIndices,
+ }
+}
+
+export function readMoreUp({
+ postData,
+}: TraversalMetadata): Extract {
+ const urip = new AtUri(postData.uri)
+ const href = makeProfileLink(
+ {
+ did: urip.host,
+ handle: postData.authorHandle,
+ },
+ 'post',
+ urip.rkey,
+ )
+ return {
+ type: 'readMoreUp' as const,
+ key: `readMoreUp:${postData.uri}`,
+ href,
+ }
+}
+
+export function skeleton({
+ key,
+ item,
+}: Omit, 'type'>): Extract<
+ ThreadItem,
+ {type: 'skeleton'}
+> {
+ return {
+ type: 'skeleton',
+ key,
+ item,
+ }
+}
+
+export function postViewToThreadPlaceholder(
+ post: AppBskyFeedDefs.PostView,
+): $Typed<
+ Omit & {
+ value: $Typed
+ }
+> {
+ return {
+ $type: 'app.bsky.unspecced.getPostThreadV2#threadItem',
+ uri: post.uri,
+ depth: 0, // reset to 0 for highlighted post
+ value: {
+ $type: 'app.bsky.unspecced.defs#threadItemPost',
+ post,
+ opThread: false,
+ moreParents: false,
+ moreReplies: 0,
+ hiddenByThreadgate: false,
+ mutedByViewer: false,
+ },
+ }
+}
diff --git a/src/state/shell/composer/index.tsx b/src/state/shell/composer/index.tsx
index ad07333beb..b317942480 100644
--- a/src/state/shell/composer/index.tsx
+++ b/src/state/shell/composer/index.tsx
@@ -2,6 +2,7 @@ import React from 'react'
import {
type AppBskyActorDefs,
type AppBskyFeedDefs,
+ type AppBskyUnspeccedGetPostThreadV2,
type ModerationDecision,
} from '@atproto/api'
import {msg} from '@lingui/macro'
@@ -24,9 +25,17 @@ export interface ComposerOptsPostRef {
moderation?: ModerationDecision
}
+export type OnPostSuccessData =
+ | {
+ replyToUri?: string
+ posts: AppBskyUnspeccedGetPostThreadV2.ThreadItem[]
+ }
+ | undefined
+
export interface ComposerOpts {
replyTo?: ComposerOptsPostRef
onPost?: (postUri: string | undefined) => void
+ onPostSuccess?: (data: OnPostSuccessData) => void
quote?: AppBskyFeedDefs.PostView
mention?: string // handle of user to mention
openEmojiPicker?: (pos: EmojiPickerPosition | undefined) => void
diff --git a/src/state/threadgate-hidden-replies.tsx b/src/state/threadgate-hidden-replies.tsx
index 60806f5706..9d116c7f9f 100644
--- a/src/state/threadgate-hidden-replies.tsx
+++ b/src/state/threadgate-hidden-replies.tsx
@@ -83,3 +83,17 @@ export function useMergedThreadgateHiddenReplies({
return set
}, [uris, recentlyUnhiddenUris, threadgateRecord])
}
+
+export function useMergeThreadgateHiddenReplies() {
+ const {uris, recentlyUnhiddenUris} = useThreadgateHiddenReplyUris()
+ return React.useCallback(
+ (threadgate?: AppBskyFeedThreadgate.Record) => {
+ const set = new Set([...(threadgate?.hiddenReplies || []), ...uris])
+ for (const uri of recentlyUnhiddenUris) {
+ set.delete(uri)
+ }
+ return set
+ },
+ [uris, recentlyUnhiddenUris],
+ )
+}
diff --git a/src/storage/hooks/dev-mode.ts b/src/storage/hooks/dev-mode.ts
index 49eca3bb11..331825c48f 100644
--- a/src/storage/hooks/dev-mode.ts
+++ b/src/storage/hooks/dev-mode.ts
@@ -5,3 +5,17 @@ export function useDevMode() {
return [devMode, setDevMode] as const
}
+
+let cachedIsDevMode: boolean | undefined
+/**
+ * Does not update when toggling dev mode on or off. This util simply retrieves
+ * the value and caches in memory indefinitely. So after an update, you'll need
+ * to reload the app so it can pull a fresh value from storage.
+ */
+export function isDevMode() {
+ if (__DEV__) return true
+ if (cachedIsDevMode === undefined) {
+ cachedIsDevMode = device.get(['devMode']) ?? false
+ }
+ return cachedIsDevMode
+}
diff --git a/src/types/utils.ts b/src/types/utils.ts
new file mode 100644
index 0000000000..f64922a1f1
--- /dev/null
+++ b/src/types/utils.ts
@@ -0,0 +1,5 @@
+export type Literal = T extends A
+ ? string extends T
+ ? never
+ : T
+ : never
diff --git a/src/view/com/composer/Composer.tsx b/src/view/com/composer/Composer.tsx
index 42f057803f..f5b29664ab 100644
--- a/src/view/com/composer/Composer.tsx
+++ b/src/view/com/composer/Composer.tsx
@@ -45,6 +45,7 @@ import {type ImagePickerAsset} from 'expo-image-picker'
import {
AppBskyFeedDefs,
type AppBskyFeedGetPostThread,
+ AppBskyUnspeccedDefs,
type BskyAgent,
type RichText,
} from '@atproto/api'
@@ -55,6 +56,7 @@ import {useQueryClient} from '@tanstack/react-query'
import * as apilib from '#/lib/api/index'
import {EmbeddingDisabledError} from '#/lib/api/resolve'
+import {retry} from '#/lib/async/retry'
import {until} from '#/lib/async/until'
import {
MAX_GRAPHEME_LENGTH,
@@ -87,7 +89,7 @@ import {useProfileQuery} from '#/state/queries/profile'
import {type Gif} from '#/state/queries/tenor'
import {useAgent, useSession} from '#/state/session'
import {useComposerControls} from '#/state/shell/composer'
-import {type ComposerOpts} from '#/state/shell/composer'
+import {type ComposerOpts, type OnPostSuccessData} from '#/state/shell/composer'
import {CharProgress} from '#/view/com/composer/char-progress/CharProgress'
import {ComposerReplyTo} from '#/view/com/composer/ComposerReplyTo'
import {
@@ -152,6 +154,7 @@ type Props = ComposerOpts
export const ComposePost = ({
replyTo,
onPost,
+ onPostSuccess,
quote: initQuote,
mention: initMention,
openEmojiPicker,
@@ -388,8 +391,10 @@ export const ComposePost = ({
setError('')
setIsPublishing(true)
- let postUri
+ let postUri: string | undefined
+ let postSuccessData: OnPostSuccessData
try {
+ logger.info(`composer: posting...`)
postUri = (
await apilib.post(agent, queryClient, {
thread,
@@ -398,16 +403,48 @@ export const ComposePost = ({
langs: toPostLanguages(langPrefs.postLanguage),
})
).uris[0]
+
+ /*
+ * Wait for app view to have received the post(s). If this fails, it's
+ * ok, because the post _was_ actually published above.
+ */
try {
- await whenAppViewReady(agent, postUri, res => {
- const postedThread = res?.data?.thread
- return AppBskyFeedDefs.isThreadViewPost(postedThread)
- })
+ if (postUri) {
+ logger.info(`composer: waiting for app view`)
+
+ const posts = await retry(
+ 5,
+ _e => true,
+ async () => {
+ const res = await agent.app.bsky.unspecced.getPostThreadV2({
+ anchor: postUri!,
+ above: false,
+ below: thread.posts.length - 1,
+ branchingFactor: 1,
+ })
+ if (res.data.thread.length !== thread.posts.length) {
+ throw new Error(`composer: app view is not ready`)
+ }
+ if (
+ !res.data.thread.every(p =>
+ AppBskyUnspeccedDefs.isThreadItemPost(p.value),
+ )
+ ) {
+ throw new Error(`composer: app view returned non-post items`)
+ }
+ return res.data.thread
+ },
+ 1e3,
+ )
+ postSuccessData = {
+ replyToUri: replyTo?.uri,
+ posts,
+ }
+ }
} catch (waitErr: any) {
- logger.error(waitErr, {
- message: `Waiting for app view failed`,
+ logger.info(`composer: waiting for app view failed`, {
+ safeMessage: waitErr,
})
- // Keep going because the post *was* published.
}
} catch (e: any) {
logger.error(e, {
@@ -465,12 +502,14 @@ export const ComposePost = ({
quotedThread.post.quoteCount !== initQuote.quoteCount
) {
onPost?.(postUri)
+ onPostSuccess?.(postSuccessData)
return true
}
return false
})
} else {
onPost?.(postUri)
+ onPostSuccess?.(postSuccessData)
}
onClose()
Toast.show(
@@ -489,6 +528,7 @@ export const ComposePost = ({
langPrefs.postLanguage,
onClose,
onPost,
+ onPostSuccess,
initQuote,
replyTo,
setLangPrefs,
diff --git a/src/view/com/post-thread/PostThread.tsx b/src/view/com/post-thread/PostThread.tsx
index 5bec9ced1a..94cc04f542 100644
--- a/src/view/com/post-thread/PostThread.tsx
+++ b/src/view/com/post-thread/PostThread.tsx
@@ -1,8 +1,7 @@
import React, {memo, useRef, useState} from 'react'
-import {StyleSheet, useWindowDimensions, View} from 'react-native'
-import {runOnJS} from 'react-native-reanimated'
+import {useWindowDimensions, View} from 'react-native'
+import {runOnJS, useAnimatedStyle} from 'react-native-reanimated'
import Animated from 'react-native-reanimated'
-import {useSafeAreaInsets} from 'react-native-safe-area-context'
import {
AppBskyFeedDefs,
type AppBskyFeedThreadgate,
@@ -13,11 +12,9 @@ import {useLingui} from '@lingui/react'
import {HITSLOP_10} from '#/lib/constants'
import {useInitialNumToRender} from '#/lib/hooks/useInitialNumToRender'
-import {useMinimalShellFabTransform} from '#/lib/hooks/useMinimalShellTransform'
import {useOpenComposer} from '#/lib/hooks/useOpenComposer'
import {useSetTitle} from '#/lib/hooks/useSetTitle'
import {useWebMediaQueries} from '#/lib/hooks/useWebMediaQueries'
-import {clamp} from '#/lib/numbers'
import {ScrollProvider} from '#/lib/ScrollContext'
import {sanitizeDisplayName} from '#/lib/strings/display-names'
import {cleanError} from '#/lib/strings/errors'
@@ -37,6 +34,7 @@ import {
import {useSetThreadViewPreferencesMutation} from '#/state/queries/preferences'
import {usePreferencesQuery} from '#/state/queries/preferences'
import {useSession} from '#/state/session'
+import {useShellLayout} from '#/state/shell/shell-layout'
import {useMergedThreadgateHiddenReplies} from '#/state/threadgate-hidden-replies'
import {useUnstablePostSource} from '#/state/unstable-post-source'
import {List, type ListMethods} from '#/view/com/util/List'
@@ -301,11 +299,14 @@ export function PostThread({uri}: {uri: string}) {
// maintainVisibleContentPosition and onContentSizeChange
// to "hold onto" the correct row instead of the first one.
+ /*
+ * This is basically `!!parents.length`, see notes on `isParentLoading`
+ */
if (!highlightedPost.ctx.isParentLoading && !deferParents) {
// When progressively revealing parents, rendering a placeholder
// here will cause scrolling jumps. Don't add it unless you test it.
// QT'ing this thread is a great way to test all the scrolling hacks:
- // https://bsky.app/profile/www.mozzius.dev/post/3kjqhblh6qk2o
+ // https://bsky.app/profile/samuel.bsky.team/post/3kjqhblh6qk2o
// Everything is loaded
let startIndex = Math.max(0, parents.length - maxParents)
@@ -581,6 +582,9 @@ export function PostThread({uri}: {uri: string}) {
onEndReached={onEndReached}
onEndReachedThreshold={2}
onScrollToTop={onScrollToTop}
+ /**
+ * @see https://reactnative.dev/docs/scrollview#maintainvisiblecontentposition
+ */
maintainVisibleContentPosition={
isNative && hasParents
? MAINTAIN_VISIBLE_CONTENT_POSITION
@@ -729,17 +733,16 @@ let ThreadMenu = ({
ThreadMenu = memo(ThreadMenu)
function MobileComposePrompt({onPressReply}: {onPressReply: () => unknown}) {
- const safeAreaInsets = useSafeAreaInsets()
- const fabMinimalShellTransform = useMinimalShellFabTransform()
+ const {footerHeight} = useShellLayout()
+
+ const animatedStyle = useAnimatedStyle(() => {
+ return {
+ bottom: footerHeight.get(),
+ }
+ })
+
return (
-
+
)
@@ -904,12 +907,3 @@ function hasBranchingReplies(node?: ThreadNode) {
}
return true
}
-
-const styles = StyleSheet.create({
- prompt: {
- // @ts-ignore web-only
- position: isWeb ? 'fixed' : 'absolute',
- left: 0,
- right: 0,
- },
-})
diff --git a/src/view/com/post-thread/PostThreadComposePrompt.tsx b/src/view/com/post-thread/PostThreadComposePrompt.tsx
index 40acff3765..f45b16085f 100644
--- a/src/view/com/post-thread/PostThreadComposePrompt.tsx
+++ b/src/view/com/post-thread/PostThreadComposePrompt.tsx
@@ -1,20 +1,25 @@
-import {View} from 'react-native'
+import {type StyleProp, View, type ViewStyle} from 'react-native'
+import {LinearGradient} from 'expo-linear-gradient'
import {msg, Trans} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {PressableScale} from '#/lib/custom-animations/PressableScale'
import {useHaptics} from '#/lib/haptics'
+import {useHideBottomBarBorderForScreen} from '#/lib/hooks/useHideBottomBarBorder'
import {useProfileQuery} from '#/state/queries/profile'
import {useSession} from '#/state/session'
import {UserAvatar} from '#/view/com/util/UserAvatar'
-import {atoms as a, ios, useBreakpoints, useTheme} from '#/alf'
+import {atoms as a, ios, native, useBreakpoints, useTheme} from '#/alf'
+import {transparentifyColor} from '#/alf/util/colorGeneration'
import {useInteractionState} from '#/components/hooks/useInteractionState'
import {Text} from '#/components/Typography'
export function PostThreadComposePrompt({
onPressCompose,
+ style,
}: {
onPressCompose: () => void
+ style?: StyleProp
}) {
const {currentAccount} = useSession()
const {data: profile} = useProfileQuery({did: currentAccount?.did})
@@ -28,29 +33,49 @@ export function PostThreadComposePrompt({
onOut: onHoverOut,
} = useInteractionState()
+ useHideBottomBarBorderForScreen()
+
return (
- {
- onPressCompose()
- playHaptic('Light')
- }}
- onLongPress={ios(() => {
- onPressCompose()
- playHaptic('Heavy')
- })}
- onHoverIn={onHoverIn}
- onHoverOut={onHoverOut}>
-
+ {!gtMobile && (
+
+ )}
+ {
+ onPressCompose()
+ playHaptic('Light')
+ }}
+ onLongPress={ios(() => {
+ onPressCompose()
+ playHaptic('Heavy')
+ })}
+ onHoverIn={onHoverIn}
+ onHoverOut={onHoverOut}
style={[
a.flex_row,
a.align_center,
@@ -58,6 +83,7 @@ export function PostThreadComposePrompt({
a.gap_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/view/com/post-thread/PostThreadItem.tsx b/src/view/com/post-thread/PostThreadItem.tsx
index 576b195a06..5184047cbb 100644
--- a/src/view/com/post-thread/PostThreadItem.tsx
+++ b/src/view/com/post-thread/PostThreadItem.tsx
@@ -39,6 +39,7 @@ import {FeedFeedbackProvider, useFeedFeedback} from '#/state/feed-feedback'
import {useLanguagePrefs} from '#/state/preferences'
import {type ThreadPost} from '#/state/queries/post-thread'
import {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 {PostThreadFollowBtn} from '#/view/com/post-thread/PostThreadFollowBtn'
@@ -85,6 +86,7 @@ export function PostThreadItem({
hasPrecedingItem,
overrideBlur,
onPostReply,
+ onPostSuccess,
hideTopBorder,
threadgateRecord,
anchorPostSource,
@@ -103,6 +105,7 @@ export function PostThreadItem({
hasPrecedingItem: boolean
overrideBlur: boolean
onPostReply: (postUri: string | undefined) => void
+ onPostSuccess?: (data: OnPostSuccessData) => void
hideTopBorder?: boolean
threadgateRecord?: AppBskyFeedThreadgate.Record
anchorPostSource?: PostSource
@@ -139,6 +142,7 @@ export function PostThreadItem({
hasPrecedingItem={hasPrecedingItem}
overrideBlur={overrideBlur}
onPostReply={onPostReply}
+ onPostSuccess={onPostSuccess}
hideTopBorder={hideTopBorder}
threadgateRecord={threadgateRecord}
anchorPostSource={anchorPostSource}
@@ -185,6 +189,7 @@ let PostThreadItemLoaded = ({
hasPrecedingItem,
overrideBlur,
onPostReply,
+ onPostSuccess,
hideTopBorder,
threadgateRecord,
anchorPostSource,
@@ -204,6 +209,7 @@ let PostThreadItemLoaded = ({
hasPrecedingItem: boolean
overrideBlur: boolean
onPostReply: (postUri: string | undefined) => void
+ onPostSuccess?: (data: OnPostSuccessData) => void
hideTopBorder?: boolean
threadgateRecord?: AppBskyFeedThreadgate.Record
anchorPostSource?: PostSource
@@ -298,6 +304,7 @@ let PostThreadItemLoaded = ({
moderation,
},
onPost: onPostReply,
+ onPostSuccess: onPostSuccess,
})
}
diff --git a/src/view/screens/PostThread.tsx b/src/view/screens/PostThread.tsx
index 1bad9b6cdf..cc611e0d63 100644
--- a/src/view/screens/PostThread.tsx
+++ b/src/view/screens/PostThread.tsx
@@ -1,28 +1,38 @@
-import React from 'react'
+import {useCallback} from 'react'
import {useFocusEffect} from '@react-navigation/native'
-import {CommonNavigatorParams, NativeStackScreenProps} from '#/lib/routes/types'
+import {
+ type CommonNavigatorParams,
+ type NativeStackScreenProps,
+} from '#/lib/routes/types'
+import {useGate} from '#/lib/statsig/statsig'
import {makeRecordUri} from '#/lib/strings/url-helpers'
import {useSetMinimalShellMode} from '#/state/shell'
import {PostThread as PostThreadComponent} from '#/view/com/post-thread/PostThread'
+import {PostThread} from '#/screens/PostThread'
import * as Layout from '#/components/Layout'
type Props = NativeStackScreenProps
export function PostThreadScreen({route}: Props) {
const setMinimalShellMode = useSetMinimalShellMode()
+ const gate = useGate()
const {name, rkey} = route.params
const uri = makeRecordUri(name, 'app.bsky.feed.post', rkey)
useFocusEffect(
- React.useCallback(() => {
+ useCallback(() => {
setMinimalShellMode(false)
}, [setMinimalShellMode]),
)
return (
-
+ {gate('post_threads_v2_unspecced') || __DEV__ ? (
+
+ ) : (
+
+ )}
)
}
diff --git a/src/view/shell/Composer.ios.tsx b/src/view/shell/Composer.ios.tsx
index 8b53f40416..393b8f80e6 100644
--- a/src/view/shell/Composer.ios.tsx
+++ b/src/view/shell/Composer.ios.tsx
@@ -37,6 +37,7 @@ export function Composer({}: {winHeight: number}) {
cancelRef={ref}
replyTo={state?.replyTo}
onPost={state?.onPost}
+ onPostSuccess={state?.onPostSuccess}
quote={state?.quote}
mention={state?.mention}
text={state?.text}
diff --git a/src/view/shell/Composer.tsx b/src/view/shell/Composer.tsx
index e40c3528b9..a17de6163d 100644
--- a/src/view/shell/Composer.tsx
+++ b/src/view/shell/Composer.tsx
@@ -49,6 +49,7 @@ export function Composer({winHeight}: {winHeight: number}) {
{
@@ -146,7 +148,7 @@ export function BottomBar({navigation}: BottomTabBarProps) {
style={[
styles.bottomBar,
pal.view,
- pal.border,
+ hideBorder ? {borderColor: pal.view.backgroundColor} : pal.border,
{paddingBottom: clamp(safeAreaInsets.bottom, 15, 60)},
footerMinimalShellTransform,
]}
diff --git a/src/view/shell/bottom-bar/BottomBarWeb.tsx b/src/view/shell/bottom-bar/BottomBarWeb.tsx
index 7a320cb438..8dce85cd17 100644
--- a/src/view/shell/bottom-bar/BottomBarWeb.tsx
+++ b/src/view/shell/bottom-bar/BottomBarWeb.tsx
@@ -5,16 +5,18 @@ import {msg, plural, Trans} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {useNavigationState} from '@react-navigation/native'
+import {useHideBottomBarBorder} from '#/lib/hooks/useHideBottomBarBorder'
import {useMinimalShellFooterTransform} from '#/lib/hooks/useMinimalShellTransform'
import {getCurrentRoute, isTab} from '#/lib/routes/helpers'
import {makeProfileLink} from '#/lib/routes/links'
-import {CommonNavigatorParams} from '#/lib/routes/types'
+import {type CommonNavigatorParams} from '#/lib/routes/types'
import {useGate} from '#/lib/statsig/statsig'
import {useHomeBadge} from '#/state/home-badge'
import {useUnreadMessageCount} from '#/state/queries/messages/list-conversations'
import {useUnreadNotifications} from '#/state/queries/notifications/unread'
import {useSession} from '#/state/session'
import {useLoggedOutViewControls} from '#/state/shell/logged-out'
+import {useShellLayout} from '#/state/shell/shell-layout'
import {useCloseAllActiveElements} from '#/state/util'
import {Link} from '#/view/com/util/Link'
import {Logo} from '#/view/icons/Logo'
@@ -49,6 +51,8 @@ export function BottomBarWeb() {
const footerMinimalShellTransform = useMinimalShellFooterTransform()
const {requestSwitchToAccount} = useLoggedOutViewControls()
const closeAllActiveElements = useCloseAllActiveElements()
+ const {footerHeight} = useShellLayout()
+ const hideBorder = useHideBottomBarBorder()
const iconWidth = 26
const unreadMessageCount = useUnreadMessageCount()
@@ -74,9 +78,12 @@ export function BottomBarWeb() {
styles.bottomBar,
styles.bottomBarWeb,
t.atoms.bg,
- t.atoms.border_contrast_low,
+ hideBorder
+ ? {borderColor: t.atoms.bg.backgroundColor}
+ : t.atoms.border_contrast_low,
footerMinimalShellTransform,
- ]}>
+ ]}
+ onLayout={event => footerHeight.set(event.nativeEvent.layout.height)}>
{hasSession ? (
<>