[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,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 (
<Menu.Root>
<Menu.Trigger label={_(msg`Thread options`)}>
{({props: {onPress, ...props}}) => (
<Button
label={_(msg`Thread options`)}
size="small"
variant="ghost"
color="secondary"
shape="round"
hitSlop={HITSLOP_10}
onPress={() => {
logger.metric('thread:click:headerMenuOpen', {})
onPress()
}}
{...props}>
<ButtonIcon icon={SettingsSlider} size="md" />
</Button>
)}
</Menu.Trigger>
<Menu.Outer>
<Menu.LabelText>
<Trans>Show replies as</Trans>
</Menu.LabelText>
<Menu.Group>
<Menu.Item
label={_(msg`Linear`)}
onPress={() => {
setView('linear')
}}>
<Menu.ItemText>
<Trans>Linear</Trans>
</Menu.ItemText>
<Menu.ItemRadio selected={view === 'linear'} />
</Menu.Item>
<Menu.Item
label={_(msg`Threaded`)}
onPress={() => {
setView('tree')
}}>
<Menu.ItemText>
<Trans>Threaded</Trans>
</Menu.ItemText>
<Menu.ItemRadio selected={view === 'tree'} />
</Menu.Item>
</Menu.Group>
<Menu.Divider />
<Menu.LabelText>
<Trans>Reply sorting</Trans>
</Menu.LabelText>
<Menu.Group>
<Menu.Item
label={_(msg`Top replies first`)}
onPress={() => {
setSort('top')
}}>
<Menu.ItemText>
<Trans>Top replies first</Trans>
</Menu.ItemText>
<Menu.ItemRadio selected={sort === 'top'} />
</Menu.Item>
<Menu.Item
label={_(msg`Oldest replies first`)}
onPress={() => {
setSort('oldest')
}}>
<Menu.ItemText>
<Trans>Oldest replies first</Trans>
</Menu.ItemText>
<Menu.ItemRadio selected={sort === 'oldest'} />
</Menu.Item>
<Menu.Item
label={_(msg`Newest replies first`)}
onPress={() => {
setSort('newest')
}}>
<Menu.ItemText>
<Trans>Newest replies first</Trans>
</Menu.ItemText>
<Menu.ItemRadio selected={sort === 'newest'} />
</Menu.Item>
</Menu.Group>
</Menu.Outer>
</Menu.Root>
)
}
@@ -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 (
<Layout.Center>
<View
style={[
a.w_full,
a.align_center,
{
padding: OUTER_SPACE,
paddingTop: OUTER_SPACE * 2,
},
]}>
<View
style={[
a.w_full,
a.align_center,
a.gap_xl,
{
maxWidth: 260,
},
]}>
<View style={[a.gap_xs]}>
<Text
style={[a.text_center, a.text_lg, a.font_bold, a.leading_snug]}>
{title}
</Text>
<Text
style={[
a.text_center,
a.text_sm,
a.leading_snug,
t.atoms.text_contrast_medium,
]}>
{message}
</Text>
</View>
<Button
label={_(msg`Retry`)}
size="small"
variant="solid"
color="secondary_inverted"
onPress={onRetry}>
<ButtonText>
<Trans>Retry</Trans>
</ButtonText>
<ButtonIcon icon={RetryIcon} position="right" />
</Button>
</View>
</View>
</Layout.Center>
)
}
@@ -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<ThreadItem, {type: 'threadPost'}>
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 <ThreadItemAnchorDeleted isRoot={isRoot} />
}
return (
<ThreadItemAnchorInner
// Safeguard from clobbering per-post state below:
key={postShadow.uri}
item={item}
isRoot={isRoot}
postShadow={postShadow}
onPostSuccess={onPostSuccess}
threadgateRecord={threadgateRecord}
postSource={postSource}
/>
)
}
function ThreadItemAnchorDeleted({isRoot}: {isRoot: boolean}) {
const t = useTheme()
return (
<>
<ThreadItemAnchorParentReplyLine isRoot={isRoot} />
<View
style={[
{
paddingHorizontal: OUTER_SPACE,
paddingBottom: OUTER_SPACE,
},
isRoot && [a.pt_lg],
]}>
<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>
</>
)
}
function ThreadItemAnchorParentReplyLine({isRoot}: {isRoot: boolean}) {
const t = useTheme()
return !isRoot ? (
<View style={[a.pl_lg, a.flex_row, a.pb_xs, {height: a.pt_lg.paddingTop}]}>
<View style={{width: 42}}>
<View
style={[
{
width: REPLY_LINE_WIDTH,
marginLeft: 'auto',
marginRight: 'auto',
flexGrow: 1,
backgroundColor: t.atoms.border_contrast_low.borderColor,
},
]}
/>
</View>
</View>
) : null
}
const ThreadItemAnchorInner = memo(function ThreadItemAnchorInner({
item,
isRoot,
postShadow,
onPostSuccess,
threadgateRecord,
postSource,
}: {
item: Extract<ThreadItem, {type: 'threadPost'}>
isRoot: boolean
postShadow: Shadow<AppBskyFeedDefs.PostView>
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 (
<>
<ThreadItemAnchorParentReplyLine isRoot={isRoot} />
<View
testID={`postThreadItem-by-${post.author.handle}`}
style={[
{
paddingHorizontal: OUTER_SPACE,
},
isRoot && [a.pt_lg],
]}>
<View style={[a.flex_row, a.gap_md, a.pb_md]}>
<PreviewableUserAvatar
size={42}
profile={post.author}
moderation={moderation.ui('avatar')}
type={post.author.associated?.labeler ? 'labeler' : 'user'}
live={live}
onBeforePress={onOpenAuthor}
/>
<View style={[a.flex_1]}>
<View style={[a.flex_row, a.align_center]}>
<Link
style={[a.flex_shrink]}
href={authorHref}
title={authorTitle}
onBeforePress={onOpenAuthor}>
<Text
emoji
style={[a.text_lg, a.font_bold, a.leading_snug, a.self_start]}
numberOfLines={1}>
{sanitizeDisplayName(
post.author.displayName ||
sanitizeHandle(post.author.handle),
moderation.ui('displayName'),
)}
</Text>
</Link>
<View style={[{paddingLeft: 3, top: -1}]}>
<VerificationCheckButton profile={authorShadow} size="md" />
</View>
</View>
<Link style={s.flex1} href={authorHref} title={authorTitle}>
<Text
emoji
style={[
a.text_md,
a.leading_snug,
t.atoms.text_contrast_medium,
]}
numberOfLines={1}>
{sanitizeHandle(post.author.handle, '@')}
</Text>
</Link>
</View>
{showFollowButton && (
<View>
<PostThreadFollowBtn did={post.author.did} />
</View>
)}
</View>
<View style={[a.pb_sm]}>
<LabelsOnMyPost post={post} style={[a.pb_sm]} />
<ContentHider
modui={moderation.ui('contentView')}
ignoreMute
childContainerStyle={[a.pt_sm]}>
<PostAlerts
modui={moderation.ui('contentView')}
size="lg"
includeMute
style={[a.pb_sm]}
additionalCauses={additionalPostAlerts}
/>
{richText?.text ? (
<RichText
enableTags
selectable
value={richText}
style={[a.flex_1, a.text_xl]}
authorHandle={post.author.handle}
shouldProxyLinks={true}
/>
) : undefined}
{post.embed && (
<View style={[a.py_xs]}>
<PostEmbeds
embed={post.embed}
moderation={moderation}
viewContext={PostEmbedViewContext.ThreadHighlighted}
onOpen={onOpenEmbed}
/>
</View>
)}
</ContentHider>
<ExpandedPostDetails
post={item.value.post}
isThreadAuthor={isThreadAuthor}
/>
{post.repostCount !== 0 ||
post.likeCount !== 0 ||
post.quoteCount !== 0 ? (
// Show this section unless we're *sure* it has no engagement.
<View
style={[
a.flex_row,
a.align_center,
a.gap_lg,
a.border_t,
a.border_b,
a.mt_md,
a.py_md,
t.atoms.border_contrast_low,
]}>
{post.repostCount != null && post.repostCount !== 0 ? (
<Link href={repostsHref} title={_(msg`Reposts of this post`)}>
<Text
testID="repostCount-expanded"
style={[a.text_md, t.atoms.text_contrast_medium]}>
<Text style={[a.text_md, a.font_bold, t.atoms.text]}>
{formatCount(i18n, post.repostCount)}
</Text>{' '}
<Plural
value={post.repostCount}
one="repost"
other="reposts"
/>
</Text>
</Link>
) : null}
{post.quoteCount != null &&
post.quoteCount !== 0 &&
!post.viewer?.embeddingDisabled ? (
<Link href={quotesHref} title={_(msg`Quotes of this post`)}>
<Text
testID="quoteCount-expanded"
style={[a.text_md, t.atoms.text_contrast_medium]}>
<Text style={[a.text_md, a.font_bold, t.atoms.text]}>
{formatCount(i18n, post.quoteCount)}
</Text>{' '}
<Plural
value={post.quoteCount}
one="quote"
other="quotes"
/>
</Text>
</Link>
) : null}
{post.likeCount != null && post.likeCount !== 0 ? (
<Link href={likesHref} title={_(msg`Likes on this post`)}>
<Text
testID="likeCount-expanded"
style={[a.text_md, t.atoms.text_contrast_medium]}>
<Text style={[a.text_md, a.font_bold, t.atoms.text]}>
{formatCount(i18n, post.likeCount)}
</Text>{' '}
<Plural value={post.likeCount} one="like" other="likes" />
</Text>
</Link>
) : null}
</View>
) : null}
<View
style={[
a.pt_sm,
a.pb_2xs,
{
marginLeft: -5,
},
]}>
<FeedFeedbackProvider value={feedFeedback}>
<PostControls
big
post={postShadow}
record={record}
richText={richText}
onPressReply={onPressReply}
logContext="PostThreadItem"
threadgateRecord={threadgateRecord}
feedContext={postSource?.post?.feedContext}
reqId={postSource?.post?.reqId}
viaRepost={viaRepost}
/>
</FeedFeedbackProvider>
</View>
</View>
</View>
</>
)
})
function ExpandedPostDetails({
post,
isThreadAuthor,
}: {
post: Extract<ThreadItem, {type: 'threadPost'}>['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<AppBskyFeedPost.Record>(
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 (
<View style={[a.gap_md, a.pt_md, a.align_start]}>
<BackdatedPostIndicator post={post} />
<View style={[a.flex_row, a.align_center, a.flex_wrap, a.gap_sm]}>
<Text style={[a.text_sm, t.atoms.text_contrast_medium]}>
{niceDate(i18n, post.indexedAt)}
</Text>
<WhoCanReply post={post} isThreadAuthor={isThreadAuthor} />
{needsTranslation && (
<>
<Text style={[a.text_sm, t.atoms.text_contrast_medium]}>
&middot;
</Text>
<InlineLinkText
to={translatorUrl}
label={_(msg`Translate`)}
style={[a.text_sm]}
onPress={onTranslatePress}>
<Trans>Translate</Trans>
</InlineLinkText>
</>
)}
</View>
</View>
)
}
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<AppBskyFeedPost.Record>(
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 (
<>
<Button
label={_(msg`Archived post`)}
accessibilityHint={_(
msg`Shows information about when this post was created`,
)}
onPress={e => {
e.preventDefault()
e.stopPropagation()
control.open()
}}>
{({hovered, pressed}) => (
<View
style={[
a.flex_row,
a.align_center,
a.rounded_full,
t.atoms.bg_contrast_25,
(hovered || pressed) && t.atoms.bg_contrast_50,
{
gap: 3,
paddingHorizontal: 6,
paddingVertical: 3,
},
]}>
<CalendarClockIcon fill={orange} size="sm" aria-hidden />
<Text
style={[
a.text_xs,
a.font_bold,
a.leading_tight,
t.atoms.text_contrast_medium,
]}>
<Trans>Archived from {niceDate(i18n, createdAt)}</Trans>
</Text>
</View>
)}
</Button>
<Prompt.Outer control={control}>
<Prompt.TitleText>
<Trans>Archived post</Trans>
</Prompt.TitleText>
<Prompt.DescriptionText>
<Trans>
This post claims to have been created on{' '}
<RNText style={[a.font_bold]}>{niceDate(i18n, createdAt)}</RNText>,
but was first seen by Bluesky on{' '}
<RNText style={[a.font_bold]}>{niceDate(i18n, indexedAt)}</RNText>.
</Trans>
</Prompt.DescriptionText>
<Text
style={[
a.text_md,
a.leading_snug,
t.atoms.text_contrast_high,
a.pb_xl,
]}>
<Trans>
Bluesky cannot confirm the authenticity of the claimed date.
</Trans>
</Text>
<Prompt.Actions>
<Prompt.Action cta={_(msg`Okay`)} onPress={() => {}} />
</Prompt.Actions>
</Prompt.Outer>
</>
)
}
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 (
<View style={[a.p_lg, a.gap_md]}>
<Skele.Row style={[a.align_center, a.gap_md]}>
<Skele.Circle size={42} />
<Skele.Col>
<Skele.Text style={[a.text_lg, {width: '20%'}]} />
<Skele.Text blend style={[a.text_md, {width: '40%'}]} />
</Skele.Col>
</Skele.Row>
<View>
<Skele.Text style={[a.text_xl, {width: '100%'}]} />
<Skele.Text style={[a.text_xl, {width: '60%'}]} />
</View>
<Skele.Text style={[a.text_sm, {width: '50%'}]} />
<Skele.Row style={[a.justify_between]}>
<Skele.Pill blend size={24} />
<Skele.Pill blend size={24} />
<Skele.Pill blend size={24} />
<Skele.Circle blend size={24} />
<Skele.Circle blend size={24} />
</Skele.Row>
</View>
)
}
@@ -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 (
<View style={[a.p_lg, a.gap_md]}>
<Skele.Row style={[a.align_center, a.gap_md]}>
<Skele.Circle size={42}>
<LockIcon size="md" fill={t.atoms.text_contrast_medium.color} />
</Skele.Circle>
<Skele.Col>
<Skele.Text style={[a.text_lg, {width: '20%'}]} />
<Skele.Text blend style={[a.text_md, {width: '40%'}]} />
</Skele.Col>
</Skele.Row>
<View style={[a.py_sm]}>
<Text style={[a.text_xl, a.italic, t.atoms.text_contrast_medium]}>
<Trans>You must sign in to view this post.</Trans>
</Text>
</View>
</View>
)
}
@@ -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>
)
}
@@ -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<ThreadItem, {type: 'threadPostNoUnauthenticated'}>
}) {
const t = useTheme()
return (
<View style={[{paddingHorizontal: OUTER_SPACE}]}>
<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>
<Skele.Row style={[a.align_center, a.gap_md]}>
<Skele.Circle size={LINEAR_AVI_WIDTH}>
<LockIcon size="md" fill={t.atoms.text_contrast_medium.color} />
</Skele.Circle>
<Text style={[a.text_md, a.italic, t.atoms.text_contrast_medium]}>
<Trans>You must sign in to view this post.</Trans>
</Text>
</Skele.Row>
<View
style={[
a.flex_row,
a.justify_center,
{
height: OUTER_SPACE / 1.5,
width: LINEAR_AVI_WIDTH,
},
]}>
{item.ui.showChildReplyLine && (
<View
style={[
a.mt_xs,
a.h_full,
{
width: REPLY_LINE_WIDTH,
backgroundColor: t.atoms.border_contrast_low.borderColor,
},
]}
/>
)}
</View>
</View>
)
}
@@ -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 (
<View
style={[
a.mb_xs,
{
paddingHorizontal: OUTER_SPACE,
paddingTop: OUTER_SPACE / 1.2,
},
]}>
<View
style={[
a.flex_row,
a.align_center,
a.rounded_sm,
t.atoms.bg_contrast_25,
{paddingVertical: OUTER_SPACE / 1.2},
]}>
<View style={[a.flex_row, a.justify_center, {width: LINEAR_AVI_WIDTH}]}>
<Icon style={[t.atoms.text_contrast_medium]} />
</View>
<Text style={[a.text_md, a.font_bold, t.atoms.text_contrast_medium]}>
{copy}
</Text>
</View>
</View>
)
}
@@ -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<ThreadItem, {type: 'readMore'}>
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 (
<View
key={`${item.key}-padding-${n}`}
style={[
t.atoms.border_contrast_low,
{
borderRightWidth: isSkipped ? 0 : REPLY_LINE_WIDTH,
width: TREE_INDENT + TREE_AVI_WIDTH / 2,
left: 1,
},
]}
/>
)
})
: null
return (
<View style={[a.flex_row]}>
{spacers}
<View
style={[
t.atoms.border_contrast_low,
{
marginLeft: isTreeView
? TREE_INDENT + TREE_AVI_WIDTH / 2 - 1
: (LINEAR_AVI_WIDTH - REPLY_LINE_WIDTH) / 2 + 16,
borderLeftWidth: 2,
borderBottomWidth: 2,
borderBottomLeftRadius: a.rounded_sm.borderRadius,
height: 18, // magic, Link below is 38px tall
width: isTreeView ? TREE_INDENT : LINEAR_AVI_WIDTH / 2 + 10,
},
]}
/>
<Link
label={_(msg`Read more replies`)}
to={item.href}
style={[a.pt_sm, a.pb_md, a.gap_xs]}>
{({hovered, pressed}) => {
const interacted = hovered || pressed
return (
<>
<CirclePlus
fill={
interacted
? t.atoms.text_contrast_high.color
: t.atoms.text_contrast_low.color
}
width={18}
/>
<Text
style={[
a.text_sm,
t.atoms.text_contrast_medium,
interacted && a.underline,
]}>
<Trans>
Read {item.moreReplies} more{' '}
<Plural
one="reply"
other="replies"
value={item.moreReplies}
/>
</Trans>
</Text>
</>
)
}}
</Link>
</View>
)
})
@@ -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<ThreadItem, {type: 'readMoreUp'}>
}) {
const t = useTheme()
const {_} = useLingui()
return (
<Link
label={_(msg`Continue thread`)}
to={item.href}
style={[
a.gap_xs,
{
paddingTop: OUTER_SPACE,
paddingHorizontal: OUTER_SPACE,
},
]}>
{({hovered, pressed}) => {
const interacted = hovered || pressed
return (
<View>
<View style={[a.flex_row, a.align_center, a.gap_md]}>
<View
style={[
a.align_center,
{
width: LINEAR_AVI_WIDTH,
},
]}>
<UpIcon
fill={
interacted
? t.atoms.text_contrast_high.color
: t.atoms.text_contrast_low.color
}
width={24}
/>
</View>
<Text
style={[
a.text_sm,
t.atoms.text_contrast_medium,
interacted && [a.underline],
]}>
<Trans>Continue thread...</Trans>
</Text>
</View>
<View
style={[
a.align_center,
{
width: LINEAR_AVI_WIDTH,
},
]}>
<View
style={[
a.mt_xs,
{
height: OUTER_SPACE / 2,
width: REPLY_LINE_WIDTH,
backgroundColor: t.atoms.border_contrast_low.borderColor,
},
]}
/>
</View>
</View>
)
}}
</Link>
)
})
@@ -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 (
<View
style={[
a.border_t,
t.atoms.border_contrast_low,
gtMobile ? a.py_xs : {paddingTop: 8, paddingBottom: 11},
{
paddingHorizontal: OUTER_SPACE,
},
]}>
<View style={[a.flex_row, a.align_center, a.gap_xs, a.py_sm]}>
<Skele.Circle size={gtMobile ? 24 : 22} />
<Skele.Text style={[a.text_md]} />
</View>
</View>
)
}
@@ -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 (
<Button
onPress={() => {
onPress()
logger.metric('thread:click:showOtherReplies', {})
}}
label={label}>
{({hovered, pressed}) => (
<View
style={[
a.flex_1,
a.flex_row,
a.align_center,
a.gap_sm,
a.py_lg,
a.px_xl,
a.border_t,
t.atoms.border_contrast_low,
hovered || pressed ? t.atoms.bg_contrast_25 : t.atoms.bg,
]}>
<View
style={[
t.atoms.bg_contrast_25,
a.align_center,
a.justify_center,
{
width: 26,
height: 26,
borderRadius: 13,
marginRight: 4,
},
]}>
<EyeSlash size="sm" fill={t.atoms.text_contrast_medium.color} />
</View>
<Text
style={[t.atoms.text_contrast_medium, a.flex_1, a.leading_snug]}
numberOfLines={1}>
{label}
</Text>
</View>
)}
</Button>
)
}
@@ -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<ThreadItem, {type: 'threadPost'}>
overrides?: {
moderation?: boolean
topBorder?: boolean
}
onPostSuccess?: (data: OnPostSuccessData) => void
threadgateRecord?: AppBskyFeedThreadgate.Record
}) {
const postShadow = usePostShadow(item.value.post)
if (postShadow === POST_TOMBSTONE) {
return <ThreadItemTreePostDeleted item={item} />
}
return (
<ThreadItemTreePostInner
// Safeguard from clobbering per-post state below:
key={postShadow.uri}
item={item}
postShadow={postShadow}
threadgateRecord={threadgateRecord}
overrides={overrides}
onPostSuccess={onPostSuccess}
/>
)
}
function ThreadItemTreePostDeleted({
item,
}: {
item: Extract<ThreadItem, {type: 'threadPost'}>
}) {
const t = useTheme()
return (
<ThreadItemTreePostOuterWrapper item={item}>
<ThreadItemTreePostInnerWrapper item={item}>
<View
style={[
a.flex_row,
a.align_center,
a.rounded_sm,
t.atoms.bg_contrast_25,
{
gap: 6,
paddingHorizontal: OUTER_SPACE / 2,
height: TREE_AVI_WIDTH,
},
]}>
<TrashIcon style={[t.atoms.text]} width={14} />
<Text style={[t.atoms.text_contrast_medium, a.mt_2xs]}>
<Trans>Post has been deleted</Trans>
</Text>
</View>
{item.ui.isLastChild && !item.ui.precedesChildReadMore && (
<View style={{height: OUTER_SPACE / 2}} />
)}
</ThreadItemTreePostInnerWrapper>
</ThreadItemTreePostOuterWrapper>
)
}
const ThreadItemTreePostOuterWrapper = memo(
function ThreadItemTreePostOuterWrapper({
item,
children,
}: {
item: Extract<ThreadItem, {type: 'threadPost'}>
children: React.ReactNode
}) {
const t = useTheme()
const indents = Math.max(0, item.ui.indent - 1)
return (
<View
style={[
a.flex_row,
item.ui.indent === 1 &&
!item.ui.showParentReplyLine && [
a.border_t,
t.atoms.border_contrast_low,
],
]}>
{Array.from(Array(indents)).map((_, n: number) => {
const isSkipped = item.ui.skippedIndentIndices.has(n)
return (
<View
key={`${item.value.post.uri}-padding-${n}`}
style={[
t.atoms.border_contrast_low,
{
borderRightWidth: isSkipped ? 0 : REPLY_LINE_WIDTH,
width: TREE_INDENT + TREE_AVI_WIDTH / 2,
left: 1,
},
]}
/>
)
})}
{children}
</View>
)
},
)
const ThreadItemTreePostInnerWrapper = memo(
function ThreadItemTreePostInnerWrapper({
item,
children,
}: {
item: Extract<ThreadItem, {type: 'threadPost'}>
children: React.ReactNode
}) {
const t = useTheme()
return (
<View
style={[
a.flex_1, // TODO check on ios
{
paddingHorizontal: OUTER_SPACE,
paddingTop: OUTER_SPACE / 2,
},
item.ui.indent === 1 && [
!item.ui.showParentReplyLine && a.pt_lg,
!item.ui.showChildReplyLine && a.pb_sm,
],
item.ui.isLastChild &&
!item.ui.precedesChildReadMore && [
{
paddingBottom: OUTER_SPACE / 2,
},
],
]}>
{item.ui.indent > 1 && (
<View
style={[
a.absolute,
t.atoms.border_contrast_low,
{
left: -1,
top: 0,
height:
TREE_AVI_WIDTH / 2 + REPLY_LINE_WIDTH / 2 + OUTER_SPACE / 2,
width: OUTER_SPACE,
borderLeftWidth: REPLY_LINE_WIDTH,
borderBottomWidth: REPLY_LINE_WIDTH,
borderBottomLeftRadius: a.rounded_sm.borderRadius,
},
]}
/>
)}
{children}
</View>
)
},
)
const ThreadItemTreeReplyChildReplyLine = memo(
function ThreadItemTreeReplyChildReplyLine({
item,
}: {
item: Extract<ThreadItem, {type: 'threadPost'}>
}) {
const t = useTheme()
return (
<View style={[a.relative, {width: TREE_AVI_PLUS_SPACE}]}>
{item.ui.showChildReplyLine && (
<View
style={[
a.flex_1,
t.atoms.border_contrast_low,
{
borderRightWidth: 2,
width: '50%',
left: -1,
},
]}
/>
)}
</View>
)
},
)
const ThreadItemTreePostInner = memo(function ThreadItemTreePostInner({
item,
postShadow,
overrides,
onPostSuccess,
threadgateRecord,
}: {
item: Extract<ThreadItem, {type: 'threadPost'}>
postShadow: Shadow<AppBskyFeedDefs.PostView>
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 (
<ThreadItemTreePostOuterWrapper item={item}>
<SubtleHover>
<PostHider
testID={`postThreadItem-by-${post.author.handle}`}
href={postHref}
disabled={overrides?.moderation === true}
modui={moderation.ui('contentList')}
iconSize={42}
iconStyles={{marginLeft: 2, marginRight: 2}}
profile={post.author}
interpretFilterAsBlur>
<ThreadItemTreePostInnerWrapper item={item}>
<View style={[a.flex_1]}>
<PostMeta
author={post.author}
moderation={moderation}
timestamp={post.indexedAt}
postHref={postHref}
avatarSize={TREE_AVI_WIDTH}
style={[a.pb_2xs]}
showAvatar
/>
<View style={[a.flex_row]}>
<ThreadItemTreeReplyChildReplyLine item={item} />
<View style={[a.flex_1]}>
<LabelsOnMyPost post={post} style={[a.pb_2xs]} />
<PostAlerts
modui={moderation.ui('contentList')}
style={[a.pb_2xs]}
additionalCauses={additionalPostAlerts}
/>
{richText?.text ? (
<View>
<RichText
enableTags
value={richText}
style={[a.flex_1, a.text_md]}
numberOfLines={limitLines ? MAX_POST_LINES : undefined}
authorHandle={post.author.handle}
shouldProxyLinks={true}
/>
</View>
) : 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>
</View>
</ThreadItemTreePostInnerWrapper>
</PostHider>
</SubtleHover>
</ThreadItemTreePostOuterWrapper>
)
})
function SubtleHover({children}: {children: React.ReactNode}) {
const {
state: hover,
onIn: onHoverIn,
onOut: onHoverOut,
} = useInteractionState()
return (
<View
onPointerEnter={onHoverIn}
onPointerLeave={onHoverOut}
style={[a.flex_1, a.pointer]}>
<SubtleWebHover hover={hover} />
{children}
</View>
)
}
export function ThreadItemTreePostSkeleton({index}: {index: number}) {
const t = useTheme()
const even = index % 2 === 0
return (
<View
style={[
{paddingHorizontal: OUTER_SPACE, paddingVertical: OUTER_SPACE / 1.5},
a.gap_md,
a.border_t,
t.atoms.border_contrast_low,
]}>
<Skele.Row style={[a.align_start, a.gap_md]}>
<Skele.Circle size={TREE_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>
)
}