this repo has no description
1import {memo, useCallback, useMemo} from 'react'
2import {Text as RNText, View} from 'react-native'
3import {
4 AppBskyFeedDefs,
5 AppBskyFeedPost,
6 type AppBskyFeedThreadgate,
7 AtUri,
8 RichText as RichTextAPI,
9} from '@atproto/api'
10import {Plural, Trans, useLingui} from '@lingui/react/macro'
11
12import {useOpenComposer} from '#/lib/hooks/useOpenComposer'
13import {makeProfileLink} from '#/lib/routes/links'
14import {sanitizeDisplayName} from '#/lib/strings/display-names'
15import {sanitizeHandle} from '#/lib/strings/handles'
16import {niceDate} from '#/lib/strings/time'
17import {
18 POST_TOMBSTONE,
19 type Shadow,
20 usePostShadow,
21} from '#/state/cache/post-shadow'
22import {useProfileShadow} from '#/state/cache/profile-shadow'
23import {FeedFeedbackProvider, useFeedFeedback} from '#/state/feed-feedback'
24import {type ThreadItem} from '#/state/queries/usePostThread/types'
25import {useSession} from '#/state/session'
26import {type OnPostSuccessData} from '#/state/shell/composer'
27import {useMergedThreadgateHiddenReplies} from '#/state/threadgate-hidden-replies'
28import {type PostSource} from '#/state/unstable-post-source'
29import {PreviewableUserAvatar} from '#/view/com/util/UserAvatar'
30import {ThreadItemAnchorFollowButton} from '#/screens/PostThread/components/ThreadItemAnchorFollowButton'
31import {
32 LINEAR_AVI_WIDTH,
33 OUTER_SPACE,
34 REPLY_LINE_WIDTH,
35} from '#/screens/PostThread/const'
36import {atoms as a, useTheme} from '#/alf'
37import {Button} from '#/components/Button'
38import {DebugFieldDisplay} from '#/components/DebugFieldDisplay'
39import {CalendarClock_Stroke2_Corner0_Rounded as CalendarClockIcon} from '#/components/icons/CalendarClock'
40import {Trash_Stroke2_Corner0_Rounded as TrashIcon} from '#/components/icons/Trash'
41import {Link} from '#/components/Link'
42import {ContentHider} from '#/components/moderation/ContentHider'
43import {LabelsOnMyPost} from '#/components/moderation/LabelsOnMe'
44import {PostAlerts} from '#/components/moderation/PostAlerts'
45import {type AppModerationCause} from '#/components/Pills'
46import {Embed, PostEmbedViewContext} from '#/components/Post/Embed'
47import {TranslatedPost} from '#/components/Post/Translated'
48import {PostControls, PostControlsSkeleton} from '#/components/PostControls'
49import {useFormatPostStatCount} from '#/components/PostControls/util'
50import {ProfileBadges} from '#/components/ProfileBadges'
51import {ProfileHoverCard} from '#/components/ProfileHoverCard'
52import * as Prompt from '#/components/Prompt'
53import {RichText} from '#/components/RichText'
54import * as Skele from '#/components/Skeleton'
55import {Text} from '#/components/Typography'
56import {WhoCanReply} from '#/components/WhoCanReply'
57import {useAnalytics} from '#/analytics'
58import {useActorStatus} from '#/features/liveNow'
59import * as bsky from '#/types/bsky'
60
61export function ThreadItemAnchor({
62 item,
63 onPostSuccess,
64 threadgateRecord,
65 postSource,
66}: {
67 item: Extract<ThreadItem, {type: 'threadPost'}>
68 onPostSuccess?: (data: OnPostSuccessData) => void
69 threadgateRecord?: AppBskyFeedThreadgate.Record
70 postSource?: PostSource
71}) {
72 const postShadow = usePostShadow(item.value.post)
73 const threadRootUri = item.value.post.record.reply?.root?.uri || item.uri
74 const isRoot = threadRootUri === item.uri
75
76 if (postShadow === POST_TOMBSTONE) {
77 return <ThreadItemAnchorDeleted isRoot={isRoot} />
78 }
79
80 return (
81 <ThreadItemAnchorInner
82 // Safeguard from clobbering per-post state below:
83 key={postShadow.uri}
84 item={item}
85 isRoot={isRoot}
86 postShadow={postShadow}
87 onPostSuccess={onPostSuccess}
88 threadgateRecord={threadgateRecord}
89 postSource={postSource}
90 />
91 )
92}
93
94function ThreadItemAnchorDeleted({isRoot}: {isRoot: boolean}) {
95 const t = useTheme()
96
97 return (
98 <>
99 <ThreadItemAnchorParentReplyLine isRoot={isRoot} />
100
101 <View
102 style={[
103 {
104 paddingHorizontal: OUTER_SPACE,
105 paddingBottom: OUTER_SPACE,
106 },
107 isRoot && [a.pt_lg],
108 ]}>
109 <View
110 style={[
111 a.flex_row,
112 a.align_center,
113 a.py_md,
114 a.rounded_sm,
115 t.atoms.bg_contrast_25,
116 ]}>
117 <View
118 style={[
119 a.flex_row,
120 a.align_center,
121 a.justify_center,
122 {
123 width: LINEAR_AVI_WIDTH,
124 },
125 ]}>
126 <TrashIcon style={[t.atoms.text_contrast_medium]} />
127 </View>
128 <Text
129 style={[a.text_md, a.font_semi_bold, t.atoms.text_contrast_medium]}>
130 <Trans>Post has been deleted</Trans>
131 </Text>
132 </View>
133 </View>
134 </>
135 )
136}
137
138function ThreadItemAnchorParentReplyLine({isRoot}: {isRoot: boolean}) {
139 const t = useTheme()
140
141 return !isRoot ? (
142 <View style={[a.pl_lg, a.flex_row, a.pb_xs, {height: a.pt_lg.paddingTop}]}>
143 <View style={{width: 42}}>
144 <View
145 style={[
146 {
147 width: REPLY_LINE_WIDTH,
148 marginLeft: 'auto',
149 marginRight: 'auto',
150 flexGrow: 1,
151 backgroundColor: t.atoms.border_contrast_low.borderColor,
152 },
153 ]}
154 />
155 </View>
156 </View>
157 ) : null
158}
159
160const ThreadItemAnchorInner = memo(function ThreadItemAnchorInner({
161 item,
162 isRoot,
163 postShadow,
164 onPostSuccess,
165 threadgateRecord,
166 postSource,
167}: {
168 item: Extract<ThreadItem, {type: 'threadPost'}>
169 isRoot: boolean
170 postShadow: Shadow<AppBskyFeedDefs.PostView>
171 onPostSuccess?: (data: OnPostSuccessData) => void
172 threadgateRecord?: AppBskyFeedThreadgate.Record
173 postSource?: PostSource
174}) {
175 const t = useTheme()
176 const ax = useAnalytics()
177 const {t: l} = useLingui()
178 const {openComposer} = useOpenComposer()
179 const {currentAccount, hasSession} = useSession()
180 const feedFeedback = useFeedFeedback(postSource?.feedSourceInfo, hasSession)
181 const formatPostStatCount = useFormatPostStatCount()
182
183 const post = postShadow
184 const record = item.value.post.record
185 const moderation = item.moderation
186 const authorShadow = useProfileShadow(post.author)
187 const {isActive: live} = useActorStatus(post.author)
188 const richText = useMemo(
189 () =>
190 new RichTextAPI({
191 text: record.text,
192 facets: record.facets,
193 }),
194 [record],
195 )
196
197 const threadRootUri = record.reply?.root?.uri || post.uri
198 const authorHref = makeProfileLink(post.author)
199 const isThreadAuthor = getThreadAuthor(post, record) === currentAccount?.did
200
201 const likesHref = useMemo(() => {
202 const urip = new AtUri(post.uri)
203 return makeProfileLink(post.author, 'post', urip.rkey, 'liked-by')
204 }, [post.uri, post.author])
205 const repostsHref = useMemo(() => {
206 const urip = new AtUri(post.uri)
207 return makeProfileLink(post.author, 'post', urip.rkey, 'reposted-by')
208 }, [post.uri, post.author])
209 const quotesHref = useMemo(() => {
210 const urip = new AtUri(post.uri)
211 return makeProfileLink(post.author, 'post', urip.rkey, 'quotes')
212 }, [post.uri, post.author])
213
214 const threadgateHiddenReplies = useMergedThreadgateHiddenReplies({
215 threadgateRecord,
216 })
217 const additionalPostAlerts: AppModerationCause[] = useMemo(() => {
218 const isPostHiddenByThreadgate = threadgateHiddenReplies.has(post.uri)
219 const isControlledByViewer =
220 new AtUri(threadRootUri).host === currentAccount?.did
221 return isControlledByViewer && isPostHiddenByThreadgate
222 ? [
223 {
224 type: 'reply-hidden',
225 source: {type: 'user', did: currentAccount?.did},
226 priority: 6,
227 },
228 ]
229 : []
230 }, [post, currentAccount?.did, threadgateHiddenReplies, threadRootUri])
231 const onlyFollowersCanReply = !!threadgateRecord?.allow?.find(
232 rule => rule.$type === 'app.bsky.feed.threadgate#followerRule',
233 )
234 const showFollowButton =
235 currentAccount?.did !== post.author.did && !onlyFollowersCanReply
236
237 const viaRepost = useMemo(() => {
238 const reason = postSource?.post.reason
239
240 if (AppBskyFeedDefs.isReasonRepost(reason) && reason.uri && reason.cid) {
241 return {
242 uri: reason.uri,
243 cid: reason.cid,
244 }
245 }
246 }, [postSource])
247
248 const onPressReply = useCallback(() => {
249 openComposer({
250 replyTo: {
251 uri: post.uri,
252 cid: post.cid,
253 text: record.text,
254 author: post.author,
255 embed: post.embed,
256 moderation,
257 langs: record.langs,
258 },
259 onPostSuccess: onPostSuccess,
260 logContext: 'PostReply',
261 })
262
263 if (postSource) {
264 feedFeedback.sendInteraction({
265 item: post.uri,
266 event: 'app.bsky.feed.defs#interactionReply',
267 feedContext: postSource.post.feedContext,
268 reqId: postSource.post.reqId,
269 })
270 }
271 }, [
272 openComposer,
273 post,
274 record,
275 onPostSuccess,
276 moderation,
277 postSource,
278 feedFeedback,
279 ])
280
281 const onOpenAuthor = () => {
282 ax.metric('post:clickthroughAuthor', {
283 uri: post.uri,
284 authorDid: post.author.did,
285 logContext: 'PostThreadItem',
286 feedDescriptor: feedFeedback.feedDescriptor,
287 })
288 if (postSource) {
289 feedFeedback.sendInteraction({
290 item: post.uri,
291 event: 'app.bsky.feed.defs#clickthroughAuthor',
292 feedContext: postSource.post.feedContext,
293 reqId: postSource.post.reqId,
294 })
295 }
296 }
297
298 const onOpenEmbed = () => {
299 ax.metric('post:clickthroughEmbed', {
300 uri: post.uri,
301 authorDid: post.author.did,
302 logContext: 'PostThreadItem',
303 feedDescriptor: feedFeedback.feedDescriptor,
304 })
305 if (postSource) {
306 feedFeedback.sendInteraction({
307 item: post.uri,
308 event: 'app.bsky.feed.defs#clickthroughEmbed',
309 feedContext: postSource.post.feedContext,
310 reqId: postSource.post.reqId,
311 })
312 }
313 }
314
315 return (
316 <>
317 <ThreadItemAnchorParentReplyLine isRoot={isRoot} />
318 <View
319 testID={`postThreadItem-by-${post.author.handle}`}
320 style={[
321 {
322 paddingHorizontal: OUTER_SPACE,
323 },
324 isRoot && [a.pt_lg],
325 ]}>
326 <View style={[a.flex_row, a.gap_md, a.pb_md]}>
327 <View collapsable={false}>
328 <PreviewableUserAvatar
329 size={42}
330 profile={post.author}
331 moderation={moderation.ui('avatar')}
332 type={post.author.associated?.labeler ? 'labeler' : 'user'}
333 live={live}
334 onBeforePress={onOpenAuthor}
335 />
336 </View>
337 <Link
338 to={authorHref}
339 style={[a.flex_1]}
340 label={sanitizeDisplayName(
341 post.author.displayName || sanitizeHandle(post.author.handle),
342 moderation.ui('displayName'),
343 )}
344 onPress={onOpenAuthor}>
345 <View style={[a.flex_1, a.align_start]}>
346 <ProfileHoverCard did={post.author.did} style={[a.w_full]}>
347 <View style={[a.flex_row, a.align_center]}>
348 <Text
349 emoji
350 style={[
351 a.flex_shrink,
352 a.text_lg,
353 a.font_semi_bold,
354 a.leading_snug,
355 ]}
356 numberOfLines={1}>
357 {sanitizeDisplayName(
358 post.author.displayName ||
359 sanitizeHandle(post.author.handle),
360 moderation.ui('displayName'),
361 )}
362 </Text>
363
364 <View style={[a.pl_xs]}>
365 <ProfileBadges
366 profile={authorShadow}
367 size="md"
368 interactive
369 />
370 </View>
371 </View>
372 <Text
373 style={[
374 a.text_md,
375 a.leading_snug,
376 t.atoms.text_contrast_medium,
377 ]}
378 numberOfLines={1}>
379 {sanitizeHandle(post.author.handle, '@')}
380 </Text>
381 </ProfileHoverCard>
382 </View>
383 </Link>
384 <View collapsable={false} style={[a.self_center]}>
385 <ThreadItemAnchorFollowButton
386 did={post.author.did}
387 enabled={showFollowButton}
388 />
389 </View>
390 </View>
391 <View style={[a.pb_sm]}>
392 <LabelsOnMyPost post={post} style={[a.pb_sm]} />
393 <ContentHider
394 modui={moderation.ui('contentView')}
395 ignoreMute
396 childContainerStyle={[a.pt_sm]}>
397 <PostAlerts
398 modui={moderation.ui('contentView')}
399 size="lg"
400 includeMute
401 style={[a.pb_sm]}
402 additionalCauses={additionalPostAlerts}
403 />
404 {richText?.text ? (
405 <RichText
406 enableTags
407 selectable
408 value={richText}
409 style={[a.flex_1, a.text_lg]}
410 authorHandle={post.author.handle}
411 shouldProxyLinks={true}
412 />
413 ) : undefined}
414 <TranslatedPost post={post} postTextStyle={[a.text_lg]} />
415 {post.embed && (
416 <View style={[a.py_xs]}>
417 <Embed
418 embed={post.embed}
419 moderation={moderation}
420 viewContext={PostEmbedViewContext.ThreadHighlighted}
421 onOpen={onOpenEmbed}
422 />
423 </View>
424 )}
425 </ContentHider>
426 <ExpandedPostDetails
427 post={item.value.post}
428 isThreadAuthor={isThreadAuthor}
429 />
430 {post.repostCount !== 0 ||
431 post.likeCount !== 0 ||
432 post.quoteCount !== 0 ||
433 post.bookmarkCount !== 0 ? (
434 // Show this section unless we're *sure* it has no engagement.
435 <View
436 style={[
437 a.flex_row,
438 a.flex_wrap,
439 a.align_center,
440 {
441 rowGap: a.gap_sm.gap,
442 columnGap: a.gap_lg.gap,
443 },
444 a.border_t,
445 a.border_b,
446 a.mt_md,
447 a.py_md,
448 t.atoms.border_contrast_low,
449 ]}>
450 {post.repostCount != null && post.repostCount !== 0 ? (
451 <Link to={repostsHref} label={l`Reposts of this post`}>
452 <Text
453 testID="repostCount-expanded"
454 style={[a.text_md, t.atoms.text_contrast_medium]}>
455 <Trans comment="Repost count display, the <0> tags enclose the number of reposts in bold (will never be 0)">
456 <Text style={[a.text_md, a.font_semi_bold, t.atoms.text]}>
457 {formatPostStatCount(post.repostCount)}
458 </Text>{' '}
459 <Plural
460 value={post.repostCount}
461 one="repost"
462 other="reposts"
463 />
464 </Trans>
465 </Text>
466 </Link>
467 ) : null}
468 {post.quoteCount != null &&
469 post.quoteCount !== 0 &&
470 !post.viewer?.embeddingDisabled ? (
471 <Link to={quotesHref} label={l`Quotes of this post`}>
472 <Text
473 testID="quoteCount-expanded"
474 style={[a.text_md, t.atoms.text_contrast_medium]}>
475 <Trans comment="Quote count display, the <0> tags enclose the number of quotes in bold (will never be 0)">
476 <Text style={[a.text_md, a.font_semi_bold, t.atoms.text]}>
477 {formatPostStatCount(post.quoteCount)}
478 </Text>{' '}
479 <Plural
480 value={post.quoteCount}
481 one="quote"
482 other="quotes"
483 />
484 </Trans>
485 </Text>
486 </Link>
487 ) : null}
488 {post.likeCount != null && post.likeCount !== 0 ? (
489 <Link to={likesHref} label={l`Likes on this post`}>
490 <Text
491 testID="likeCount-expanded"
492 style={[a.text_md, t.atoms.text_contrast_medium]}>
493 <Trans comment="Like count display, the <0> tags enclose the number of likes in bold (will never be 0)">
494 <Text style={[a.text_md, a.font_semi_bold, t.atoms.text]}>
495 {formatPostStatCount(post.likeCount)}
496 </Text>{' '}
497 <Plural value={post.likeCount} one="like" other="likes" />
498 </Trans>
499 </Text>
500 </Link>
501 ) : null}
502 {post.bookmarkCount != null && post.bookmarkCount !== 0 ? (
503 <Text
504 testID="bookmarkCount-expanded"
505 style={[a.text_md, t.atoms.text_contrast_medium]}>
506 <Trans comment="Save count display, the <0> tags enclose the number of saves in bold (will never be 0)">
507 <Text style={[a.text_md, a.font_semi_bold, t.atoms.text]}>
508 {formatPostStatCount(post.bookmarkCount)}
509 </Text>{' '}
510 <Plural
511 value={post.bookmarkCount}
512 one="save"
513 other="saves"
514 />
515 </Trans>
516 </Text>
517 ) : null}
518 </View>
519 ) : null}
520 <View
521 style={[
522 a.pt_sm,
523 a.pb_2xs,
524 {
525 marginLeft: -5,
526 },
527 ]}>
528 <FeedFeedbackProvider value={feedFeedback}>
529 <PostControls
530 big
531 post={postShadow}
532 record={record}
533 richText={richText}
534 onPressReply={onPressReply}
535 logContext="PostThreadItem"
536 threadgateRecord={threadgateRecord}
537 feedContext={postSource?.post?.feedContext}
538 reqId={postSource?.post?.reqId}
539 viaRepost={viaRepost}
540 />
541 </FeedFeedbackProvider>
542 </View>
543 <DebugFieldDisplay subject={post} />
544 </View>
545 </View>
546 </>
547 )
548})
549
550function ExpandedPostDetails({
551 post,
552 isThreadAuthor,
553}: {
554 post: Extract<ThreadItem, {type: 'threadPost'}>['value']['post']
555 isThreadAuthor: boolean
556}) {
557 const t = useTheme()
558 const {i18n} = useLingui()
559 const isRootPost = !('reply' in post.record)
560
561 return (
562 <View style={[a.gap_md, a.pt_md, a.align_start]}>
563 <BackdatedPostIndicator post={post} />
564 <View style={[a.flex_row, a.align_center, a.flex_wrap, a.gap_sm]}>
565 <Text style={[a.text_sm, t.atoms.text_contrast_medium]}>
566 {niceDate(i18n, post.indexedAt, 'dot separated')}
567 </Text>
568 {isRootPost && (
569 <WhoCanReply post={post} isThreadAuthor={isThreadAuthor} />
570 )}
571 </View>
572 </View>
573 )
574}
575
576function BackdatedPostIndicator({post}: {post: AppBskyFeedDefs.PostView}) {
577 const t = useTheme()
578 const {t: l, i18n} = useLingui()
579 const control = Prompt.usePromptControl()
580
581 const indexedAt = new Date(post.indexedAt)
582 const createdAt = bsky.dangerousIsType<AppBskyFeedPost.Record>(
583 post.record,
584 AppBskyFeedPost.isRecord,
585 )
586 ? new Date(post.record.createdAt)
587 : new Date(post.indexedAt)
588
589 // backdated if createdAt is 24 hours or more before indexedAt
590 const isBackdated =
591 indexedAt.getTime() - createdAt.getTime() > 24 * 60 * 60 * 1000
592
593 if (!isBackdated) return null
594
595 return (
596 <>
597 <Button
598 label={l`Archived post`}
599 accessibilityHint={l`Shows information about when this post was created`}
600 onPress={e => {
601 e.preventDefault()
602 e.stopPropagation()
603 control.open()
604 }}>
605 {({hovered, pressed}) => (
606 <View
607 style={[
608 a.flex_row,
609 a.align_center,
610 a.rounded_full,
611 t.atoms.bg_contrast_25,
612 (hovered || pressed) && t.atoms.bg_contrast_50,
613 {
614 gap: 3,
615 paddingHorizontal: 6,
616 paddingVertical: 3,
617 },
618 ]}>
619 <CalendarClockIcon fill={t.palette.yellow} size="sm" aria-hidden />
620 <Text
621 style={[
622 a.text_xs,
623 a.font_semi_bold,
624 a.leading_tight,
625 t.atoms.text_contrast_medium,
626 ]}>
627 <Trans>Archived from {niceDate(i18n, createdAt, 'medium')}</Trans>
628 </Text>
629 </View>
630 )}
631 </Button>
632
633 <Prompt.Outer control={control}>
634 <Prompt.Content>
635 <Prompt.TitleText>
636 <Trans>Archived post</Trans>
637 </Prompt.TitleText>
638 <Prompt.DescriptionText>
639 <Trans>
640 This post claims to have been created on{' '}
641 <RNText style={[a.font_semi_bold]}>
642 {niceDate(i18n, createdAt)}
643 </RNText>
644 , but was first seen by Bluesky on{' '}
645 <RNText style={[a.font_semi_bold]}>
646 {niceDate(i18n, indexedAt)}
647 </RNText>
648 .
649 </Trans>
650 </Prompt.DescriptionText>
651 <Prompt.DescriptionText>
652 <Trans>
653 Bluesky cannot confirm the authenticity of the claimed date.
654 </Trans>
655 </Prompt.DescriptionText>
656 </Prompt.Content>
657 <Prompt.Actions>
658 <Prompt.Action cta={l`Okay`} onPress={() => {}} />
659 </Prompt.Actions>
660 </Prompt.Outer>
661 </>
662 )
663}
664
665function getThreadAuthor(
666 post: AppBskyFeedDefs.PostView,
667 record: AppBskyFeedPost.Record,
668): string {
669 if (!record.reply) {
670 return post.author.did
671 }
672 try {
673 return new AtUri(record.reply.root.uri).host
674 } catch {
675 return ''
676 }
677}
678
679export function ThreadItemAnchorSkeleton() {
680 return (
681 <View style={[a.p_lg, a.gap_md]}>
682 <Skele.Row style={[a.align_center, a.gap_md]}>
683 <Skele.Circle size={42} />
684
685 <Skele.Col>
686 <Skele.Text style={[a.text_lg, {width: '20%'}]} />
687 <Skele.Text blend style={[a.text_md, {width: '40%'}]} />
688 </Skele.Col>
689 </Skele.Row>
690
691 <View>
692 <Skele.Text style={[a.text_xl, {width: '100%'}]} />
693 <Skele.Text style={[a.text_xl, {width: '60%'}]} />
694 </View>
695
696 <Skele.Text style={[a.text_sm, {width: '50%'}]} />
697
698 <PostControlsSkeleton big />
699 </View>
700 )
701}