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