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