[Threads V2] Preliminary integration of unspecced V2 APIs (#8443)

* WIP

* Sorting working

* Rough handling of hidden/muted

* Better muted/hidden sorting and handling

* Clarify some naming

* Fix parents

* Handle first reply under highlighted/composer

* WIP RaW

* WIP optimistic

* Optimistic WIP

* Little cleanup, inserting dupes

* Re-org

* Add in new optimistic insert logic

* Update types

* Sorta working linear view optimistic state

* Simple working version, no pref for OP

* Working optimistic reply insertions, preference for OP

* Ensure deletes are coming through

* WIP scroll handling

* WIP scroll tweaks

* Clean up scrolling

* Clean up onPostSuccess

* Add annotations

* Fix highlighted post calc

* WIP kill me

* Update APIs

* Nvm don't kill me

* Fix optimistic insert

* Handle read more cases in tree view

* Basically working read more

* Handle linear view

* Reorg

* More reorg

* Split up thread post components

* New reply tree layout

* Fix up traversal metadata

* Tighten some spacing

* Use indent ya idiot

* Some linear mode cleanup

* Fix lines on read more items

* Vibe coding to success

* Almost there with read mores

* Update APIs

* Bump sdk

* Update import

* Checkpoint new traversal

* Checkpoint cleanup

* Checkpoint, need to fix blocked posts

* Checkpoint: think we're good, needs more cleanup

* Clean it up

* Two passes only

* Set to default params, update comment

* Fix render bug on native

* Checkpoint parent rendering, can opt for slower handling here

* Clean up parent handling, reply handling

* Fix read more extra space

* Fix read more in linear view

* Fix hidden reply handling, seen count, before/after calc

* Update naming

* Rename Slice to ThreadItem

* Add basic post and anchor skeletons

* Refactor client-side hidden

* WIP hidden fetching

* Update types

* Clean up query a bit

* Scrolling still broken

* Ok maybe fix scrolling

* Checkpoint move state into meta query

* Don't load remote hidden items unless needed

* skeleton view

* Reset hidden items when params change

* Split up traversal and avoid multiple passes

* Clean up

* Checkpoint: handling exhausted replies

* Clean up traversal functions further

* Clean up pagination

* Limit optimistic reply depth

* Handle optimistic insert in hidden replies

* Share root query key for easier cache extraction

* Make blurred posts not look like ass

* Fix double deleted item

* Make optimistic deleted state not look like crap in tree view

* Fix parents traversal 4 real

* Rename tree post

* Make optimistic deletions of linear posts not look bad

* Rename linear post components

* Handle tombstone views

* Rename read more component

* Add moreParents handling

* Align interaction states of read more

* Fix read more on FF

* Tree view skeleton

* Reply composer skele

* Remove hack for showing more replies

* Checkpoint: sort change scrolling fixed

* Checkpoint: learned new things, reset to base

* Feature gate

* Rename

* Replace show more

* Update settings screen

* Update pkg and endpoint

* Remove console

* Eureka

* Cleanup last commit

* No tests atm

* Remove scroll provider

* Clean up callbacks, better error state

* Remove todo

* Remove todo

* Remove todos

* Format

* Ok I think scrolling is solid

* Add back mobile compose input

* Ok need to compute headerHeight every time

* Update comments

* Ok button up web too

* Threads v2 tweaks (#8467)

* fix error screen collapsing

* use personx icon for blocked posts

* Remove height/width

* Revert unused Header change

* Clarify code

* Relate consts to theme values

* Remove debug code

* Typo

* Fix debounce of threads prefs

* Update metadata comments, dev mode

* Missed a spot

* Clean up todo

* Fix up no-unauthenticated posts

* Truncate parents if no-unauth

* Update getBranch docs

* Remove debug code

* Expand fetching in some cases

* Clear scroll need for root post to fix jump bug

* Fix reply composer skeleton state

* Remove uneeded initialized value

* Add profile shadow cache

* Some metrics

* prettier tweak

* eslint ignore

* Fix optimistic insertion

* Typo

* Rename, comment

* Remove wait

* Counter naming

* Replies seen counter for moderated sub-trees

* Remove borders on skeleton

* Align tombstone with optimistic deletion state

* Fix optimistic deletion for thread

* Add tree view icon

* Rename

* Cleanup

* Update settings copy

* Header menu open metric

* Bump package

* Better reply prompt (#8474)

* restyle reply prompt

* hide bottom bar border for cleaner look

* use new border hiding hook in DMs

* create `transparentifyColor` function

* adjust padding

* fix padding in immersive lpayer

* Apply suggestions from code review

Co-authored-by: surfdude29 <149612116+surfdude29@users.noreply.github.com>

* Integrate post-source

(cherry picked from commit fe053e9b38395a4fcb30a4367bc800f64ea84fe9)

---------

Co-authored-by: Samuel Newman <mozzius@protonmail.com>
Co-authored-by: surfdude29 <149612116+surfdude29@users.noreply.github.com>
This commit is contained in:
Eric Bailey
2025-06-11 14:32:14 -05:00
committed by GitHub
parent 143d5f3b81
commit 61004b887b
60 changed files with 5416 additions and 86 deletions
@@ -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<ThreadItem, {type: 'threadPost'}>
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 <ThreadItemPostDeleted item={item} overrides={overrides} />
}
return (
<ThreadItemPostInner
item={item}
postShadow={postShadow}
threadgateRecord={threadgateRecord}
overrides={overrides}
onPostSuccess={onPostSuccess}
/>
)
}
function ThreadItemPostDeleted({
item,
overrides,
}: Pick<ThreadItemPostProps, 'item' | 'overrides'>) {
const t = useTheme()
return (
<ThreadItemPostOuterWrapper item={item} overrides={overrides}>
<ThreadItemPostParentReplyLine item={item} />
<View
style={[
a.flex_row,
a.align_center,
a.py_md,
a.rounded_sm,
t.atoms.bg_contrast_25,
]}>
<View
style={[
a.flex_row,
a.align_center,
a.justify_center,
{
width: LINEAR_AVI_WIDTH,
},
]}>
<TrashIcon style={[t.atoms.text_contrast_medium]} />
</View>
<Text style={[a.text_md, a.font_bold, t.atoms.text_contrast_medium]}>
<Trans>Post has been deleted</Trans>
</Text>
</View>
<View style={[{height: 4}]} />
</ThreadItemPostOuterWrapper>
)
}
const ThreadItemPostOuterWrapper = memo(function ThreadItemPostOuterWrapper({
item,
overrides,
children,
}: Pick<ThreadItemPostProps, 'item' | 'overrides'> & {
children: ReactNode
}) {
const t = useTheme()
const showTopBorder =
!item.ui.showParentReplyLine && overrides?.topBorder !== true
return (
<View
style={[
showTopBorder && [a.border_t, t.atoms.border_contrast_low],
{
paddingHorizontal: OUTER_SPACE,
},
// If there's no next child, add a little padding to bottom
!item.ui.showChildReplyLine &&
!item.ui.precedesChildReadMore && {
paddingBottom: OUTER_SPACE / 2,
},
]}>
{children}
</View>
)
})
/**
* Provides some space between posts as well as contains the reply line
*/
const ThreadItemPostParentReplyLine = memo(
function ThreadItemPostParentReplyLine({
item,
}: Pick<ThreadItemPostProps, 'item'>) {
const t = useTheme()
return (
<View style={[a.flex_row, {height: 12}]}>
<View style={{width: LINEAR_AVI_WIDTH}}>
{item.ui.showParentReplyLine && (
<View
style={[
a.mx_auto,
a.flex_1,
a.mb_xs,
{
width: REPLY_LINE_WIDTH,
backgroundColor: t.atoms.border_contrast_low.borderColor,
},
]}
/>
)}
</View>
</View>
)
},
)
const ThreadItemPostInner = memo(function ThreadItemPostInner({
item,
postShadow,
overrides,
onPostSuccess,
threadgateRecord,
}: ThreadItemPostProps & {
postShadow: Shadow<AppBskyFeedDefs.PostView>
}) {
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 (
<SubtleHover>
<ThreadItemPostOuterWrapper item={item} overrides={overrides}>
<PostHider
testID={`postThreadItem-by-${post.author.handle}`}
href={postHref}
disabled={overrides?.moderation === true}
modui={moderation.ui('contentList')}
iconSize={LINEAR_AVI_WIDTH}
iconStyles={{marginLeft: 2, marginRight: 2}}
profile={post.author}
interpretFilterAsBlur>
<ThreadItemPostParentReplyLine item={item} />
<View style={[a.flex_row, a.gap_md]}>
<View>
<PreviewableUserAvatar
size={LINEAR_AVI_WIDTH}
profile={post.author}
moderation={moderation.ui('avatar')}
type={post.author.associated?.labeler ? 'labeler' : 'user'}
live={live}
/>
{(item.ui.showChildReplyLine ||
item.ui.precedesChildReadMore) && (
<View
style={[
a.mx_auto,
a.mt_xs,
a.flex_1,
{
width: REPLY_LINE_WIDTH,
backgroundColor: t.atoms.border_contrast_low.borderColor,
},
]}
/>
)}
</View>
<View style={[a.flex_1]}>
<PostMeta
author={post.author}
moderation={moderation}
timestamp={post.indexedAt}
postHref={postHref}
style={[a.pb_xs]}
/>
<LabelsOnMyPost post={post} style={[a.pb_xs]} />
<PostAlerts
modui={moderation.ui('contentList')}
style={[a.pb_2xs]}
additionalCauses={additionalPostAlerts}
/>
{richText?.text ? (
<RichText
enableTags
value={richText}
style={[a.flex_1, a.text_md]}
numberOfLines={limitLines ? MAX_POST_LINES : undefined}
authorHandle={post.author.handle}
shouldProxyLinks={true}
/>
) : undefined}
{limitLines ? (
<TextLink
text={_(msg`Show More`)}
style={pal.link}
onPress={onPressShowMore}
href="#"
/>
) : undefined}
{post.embed && (
<View style={[a.pb_xs]}>
<PostEmbeds
embed={post.embed}
moderation={moderation}
viewContext={PostEmbedViewContext.Feed}
/>
</View>
)}
<PostControls
post={postShadow}
record={record}
richText={richText}
onPressReply={onPressReply}
logContext="PostThreadItem"
threadgateRecord={threadgateRecord}
/>
</View>
</View>
</PostHider>
</ThreadItemPostOuterWrapper>
</SubtleHover>
)
})
function SubtleHover({children}: {children: ReactNode}) {
const {
state: hover,
onIn: onHoverIn,
onOut: onHoverOut,
} = useInteractionState()
return (
<View
onPointerEnter={onHoverIn}
onPointerLeave={onHoverOut}
style={a.pointer}>
<SubtleWebHover hover={hover} />
{children}
</View>
)
}
export function ThreadItemPostSkeleton({index}: {index: number}) {
const even = index % 2 === 0
return (
<View
style={[
{paddingHorizontal: OUTER_SPACE, paddingVertical: OUTER_SPACE / 1.5},
a.gap_md,
]}>
<Skele.Row style={[a.align_start, a.gap_md]}>
<Skele.Circle size={LINEAR_AVI_WIDTH} />
<Skele.Col style={[a.gap_xs]}>
<Skele.Row style={[a.gap_sm]}>
<Skele.Text style={[a.text_md, {width: '20%'}]} />
<Skele.Text blend style={[a.text_md, {width: '30%'}]} />
</Skele.Row>
<Skele.Col>
{even ? (
<>
<Skele.Text blend style={[a.text_md, {width: '100%'}]} />
<Skele.Text blend style={[a.text_md, {width: '60%'}]} />
</>
) : (
<Skele.Text blend style={[a.text_md, {width: '60%'}]} />
)}
</Skele.Col>
<Skele.Row style={[a.justify_between, a.pt_xs]}>
<Skele.Pill blend size={16} />
<Skele.Pill blend size={16} />
<Skele.Pill blend size={16} />
<Skele.Circle blend size={16} />
<View />
</Skele.Row>
</Skele.Col>
</Skele.Row>
</View>
)
}