mirror of https://git.lenooby09.tech/LeNooby09/social-app.git
1import {memo, useCallback, useMemo, useState} from 'react'
2import {
3 type GestureResponderEvent,
4 StyleSheet,
5 Text as RNText,
6 View,
7} from 'react-native'
8import {
9 AppBskyFeedDefs,
10 AppBskyFeedPost,
11 type AppBskyFeedThreadgate,
12 AtUri,
13 type ModerationDecision,
14 RichText as RichTextAPI,
15} from '@atproto/api'
16import {msg, Plural, Trans} from '@lingui/macro'
17import {useLingui} from '@lingui/react'
18
19import {useActorStatus} from '#/lib/actor-status'
20import {MAX_POST_LINES} from '#/lib/constants'
21import {useOpenComposer} from '#/lib/hooks/useOpenComposer'
22import {usePalette} from '#/lib/hooks/usePalette'
23import {useTranslate} from '#/lib/hooks/useTranslate'
24import {makeProfileLink} from '#/lib/routes/links'
25import {sanitizeDisplayName} from '#/lib/strings/display-names'
26import {sanitizeHandle} from '#/lib/strings/handles'
27import {countLines} from '#/lib/strings/helpers'
28import {niceDate} from '#/lib/strings/time'
29import {s} from '#/lib/styles'
30import {getTranslatorLink, isPostInLanguage} from '#/locale/helpers'
31import {logger} from '#/logger'
32import {
33 POST_TOMBSTONE,
34 type Shadow,
35 usePostShadow,
36} from '#/state/cache/post-shadow'
37import {useProfileShadow} from '#/state/cache/profile-shadow'
38import {FeedFeedbackProvider, useFeedFeedback} from '#/state/feed-feedback'
39import {useLanguagePrefs} from '#/state/preferences'
40import {type ThreadPost} from '#/state/queries/post-thread'
41import {useSession} from '#/state/session'
42import {type OnPostSuccessData} from '#/state/shell/composer'
43import {useMergedThreadgateHiddenReplies} from '#/state/threadgate-hidden-replies'
44import {type PostSource} from '#/state/unstable-post-source'
45import {PostThreadFollowBtn} from '#/view/com/post-thread/PostThreadFollowBtn'
46import {ErrorMessage} from '#/view/com/util/error/ErrorMessage'
47import {Link} from '#/view/com/util/Link'
48import {formatCount} from '#/view/com/util/numeric/format'
49import {PostMeta} from '#/view/com/util/PostMeta'
50import {PreviewableUserAvatar} from '#/view/com/util/UserAvatar'
51import {atoms as a, useTheme} from '#/alf'
52import {colors} from '#/components/Admonition'
53import {Button} from '#/components/Button'
54import {useInteractionState} from '#/components/hooks/useInteractionState'
55import {CalendarClock_Stroke2_Corner0_Rounded as CalendarClockIcon} from '#/components/icons/CalendarClock'
56import {ChevronRight_Stroke2_Corner0_Rounded as ChevronRightIcon} from '#/components/icons/Chevron'
57import {Trash_Stroke2_Corner0_Rounded as TrashIcon} from '#/components/icons/Trash'
58import {InlineLinkText} from '#/components/Link'
59import {ContentHider} from '#/components/moderation/ContentHider'
60import {LabelsOnMyPost} from '#/components/moderation/LabelsOnMe'
61import {PostAlerts} from '#/components/moderation/PostAlerts'
62import {PostHider} from '#/components/moderation/PostHider'
63import {type AppModerationCause} from '#/components/Pills'
64import {Embed, PostEmbedViewContext} from '#/components/Post/Embed'
65import {ShowMoreTextButton} from '#/components/Post/ShowMoreTextButton'
66import {PostControls} from '#/components/PostControls'
67import * as Prompt from '#/components/Prompt'
68import {RichText} from '#/components/RichText'
69import {SubtleWebHover} from '#/components/SubtleWebHover'
70import {Text} from '#/components/Typography'
71import {VerificationCheckButton} from '#/components/verification/VerificationCheckButton'
72import {WhoCanReply} from '#/components/WhoCanReply'
73import * as bsky from '#/types/bsky'
74
75export function PostThreadItem({
76 post,
77 record,
78 moderation,
79 treeView,
80 depth,
81 prevPost,
82 nextPost,
83 isHighlightedPost,
84 hasMore,
85 showChildReplyLine,
86 showParentReplyLine,
87 hasPrecedingItem,
88 overrideBlur,
89 onPostReply,
90 onPostSuccess,
91 hideTopBorder,
92 threadgateRecord,
93 anchorPostSource,
94}: {
95 post: AppBskyFeedDefs.PostView
96 record: AppBskyFeedPost.Record
97 moderation: ModerationDecision | undefined
98 treeView: boolean
99 depth: number
100 prevPost: ThreadPost | undefined
101 nextPost: ThreadPost | undefined
102 isHighlightedPost?: boolean
103 hasMore?: boolean
104 showChildReplyLine?: boolean
105 showParentReplyLine?: boolean
106 hasPrecedingItem: boolean
107 overrideBlur: boolean
108 onPostReply: (postUri: string | undefined) => void
109 onPostSuccess?: (data: OnPostSuccessData) => void
110 hideTopBorder?: boolean
111 threadgateRecord?: AppBskyFeedThreadgate.Record
112 anchorPostSource?: PostSource
113}) {
114 const postShadowed = usePostShadow(post)
115 const richText = useMemo(
116 () =>
117 new RichTextAPI({
118 text: record.text,
119 facets: record.facets,
120 }),
121 [record],
122 )
123 if (postShadowed === POST_TOMBSTONE) {
124 return <PostThreadItemDeleted hideTopBorder={hideTopBorder} />
125 }
126 if (richText && moderation) {
127 return (
128 <PostThreadItemLoaded
129 // Safeguard from clobbering per-post state below:
130 key={postShadowed.uri}
131 post={postShadowed}
132 prevPost={prevPost}
133 nextPost={nextPost}
134 record={record}
135 richText={richText}
136 moderation={moderation}
137 treeView={treeView}
138 depth={depth}
139 isHighlightedPost={isHighlightedPost}
140 hasMore={hasMore}
141 showChildReplyLine={showChildReplyLine}
142 showParentReplyLine={showParentReplyLine}
143 hasPrecedingItem={hasPrecedingItem}
144 overrideBlur={overrideBlur}
145 onPostReply={onPostReply}
146 onPostSuccess={onPostSuccess}
147 hideTopBorder={hideTopBorder}
148 threadgateRecord={threadgateRecord}
149 anchorPostSource={anchorPostSource}
150 />
151 )
152 }
153 return null
154}
155
156function PostThreadItemDeleted({hideTopBorder}: {hideTopBorder?: boolean}) {
157 const t = useTheme()
158 return (
159 <View
160 style={[
161 t.atoms.bg,
162 t.atoms.border_contrast_low,
163 a.p_xl,
164 a.pl_lg,
165 a.flex_row,
166 a.gap_md,
167 !hideTopBorder && a.border_t,
168 ]}>
169 <TrashIcon style={[t.atoms.text]} />
170 <Text style={[t.atoms.text_contrast_medium, a.mt_2xs]}>
171 <Trans>This post has been deleted.</Trans>
172 </Text>
173 </View>
174 )
175}
176
177let PostThreadItemLoaded = ({
178 post,
179 record,
180 richText,
181 moderation,
182 treeView,
183 depth,
184 prevPost,
185 nextPost,
186 isHighlightedPost,
187 hasMore,
188 showChildReplyLine,
189 showParentReplyLine,
190 hasPrecedingItem,
191 overrideBlur,
192 onPostReply,
193 onPostSuccess,
194 hideTopBorder,
195 threadgateRecord,
196 anchorPostSource,
197}: {
198 post: Shadow<AppBskyFeedDefs.PostView>
199 record: AppBskyFeedPost.Record
200 richText: RichTextAPI
201 moderation: ModerationDecision
202 treeView: boolean
203 depth: number
204 prevPost: ThreadPost | undefined
205 nextPost: ThreadPost | undefined
206 isHighlightedPost?: boolean
207 hasMore?: boolean
208 showChildReplyLine?: boolean
209 showParentReplyLine?: boolean
210 hasPrecedingItem: boolean
211 overrideBlur: boolean
212 onPostReply: (postUri: string | undefined) => void
213 onPostSuccess?: (data: OnPostSuccessData) => void
214 hideTopBorder?: boolean
215 threadgateRecord?: AppBskyFeedThreadgate.Record
216 anchorPostSource?: PostSource
217}): React.ReactNode => {
218 const {currentAccount, hasSession} = useSession()
219 const feedFeedback = useFeedFeedback(anchorPostSource?.feed, hasSession)
220
221 const t = useTheme()
222 const pal = usePalette('default')
223 const {_, i18n} = useLingui()
224 const langPrefs = useLanguagePrefs()
225 const {openComposer} = useOpenComposer()
226 const [limitLines, setLimitLines] = useState(
227 () => countLines(richText?.text) >= MAX_POST_LINES,
228 )
229 const shadowedPostAuthor = useProfileShadow(post.author)
230 const rootUri = record.reply?.root?.uri || post.uri
231 const postHref = useMemo(() => {
232 const urip = new AtUri(post.uri)
233 return makeProfileLink(post.author, 'post', urip.rkey)
234 }, [post.uri, post.author])
235 const itemTitle = _(msg`Post by ${post.author.handle}`)
236 const authorHref = makeProfileLink(post.author)
237 const authorTitle = post.author.handle
238 const isThreadAuthor = getThreadAuthor(post, record) === currentAccount?.did
239 const likesHref = useMemo(() => {
240 const urip = new AtUri(post.uri)
241 return makeProfileLink(post.author, 'post', urip.rkey, 'liked-by')
242 }, [post.uri, post.author])
243 const likesTitle = _(msg`Likes on this post`)
244 const repostsHref = useMemo(() => {
245 const urip = new AtUri(post.uri)
246 return makeProfileLink(post.author, 'post', urip.rkey, 'reposted-by')
247 }, [post.uri, post.author])
248 const repostsTitle = _(msg`Reposts of this post`)
249 const threadgateHiddenReplies = useMergedThreadgateHiddenReplies({
250 threadgateRecord,
251 })
252 const additionalPostAlerts: AppModerationCause[] = useMemo(() => {
253 const isPostHiddenByThreadgate = threadgateHiddenReplies.has(post.uri)
254 const isControlledByViewer = new AtUri(rootUri).host === currentAccount?.did
255 return isControlledByViewer && isPostHiddenByThreadgate
256 ? [
257 {
258 type: 'reply-hidden',
259 source: {type: 'user', did: currentAccount?.did},
260 priority: 6,
261 },
262 ]
263 : []
264 }, [post, currentAccount?.did, threadgateHiddenReplies, rootUri])
265 const quotesHref = useMemo(() => {
266 const urip = new AtUri(post.uri)
267 return makeProfileLink(post.author, 'post', urip.rkey, 'quotes')
268 }, [post.uri, post.author])
269 const quotesTitle = _(msg`Quotes of this post`)
270 const onlyFollowersCanReply = !!threadgateRecord?.allow?.find(
271 rule => rule.$type === 'app.bsky.feed.threadgate#followerRule',
272 )
273 const showFollowButton =
274 currentAccount?.did !== post.author.did && !onlyFollowersCanReply
275
276 const needsTranslation = useMemo(
277 () =>
278 Boolean(
279 langPrefs.primaryLanguage &&
280 !isPostInLanguage(post, [langPrefs.primaryLanguage]),
281 ),
282 [post, langPrefs.primaryLanguage],
283 )
284
285 const onPressReply = () => {
286 if (anchorPostSource && isHighlightedPost) {
287 feedFeedback.sendInteraction({
288 item: post.uri,
289 event: 'app.bsky.feed.defs#interactionReply',
290 feedContext: anchorPostSource.post.feedContext,
291 reqId: anchorPostSource.post.reqId,
292 })
293 }
294 openComposer({
295 replyTo: {
296 uri: post.uri,
297 cid: post.cid,
298 text: record.text,
299 author: post.author,
300 embed: post.embed,
301 moderation,
302 langs: record.langs,
303 },
304 onPost: onPostReply,
305 onPostSuccess: onPostSuccess,
306 })
307 }
308
309 const onOpenAuthor = () => {
310 if (anchorPostSource) {
311 feedFeedback.sendInteraction({
312 item: post.uri,
313 event: 'app.bsky.feed.defs#clickthroughAuthor',
314 feedContext: anchorPostSource.post.feedContext,
315 reqId: anchorPostSource.post.reqId,
316 })
317 }
318 }
319
320 const onOpenEmbed = () => {
321 if (anchorPostSource) {
322 feedFeedback.sendInteraction({
323 item: post.uri,
324 event: 'app.bsky.feed.defs#clickthroughEmbed',
325 feedContext: anchorPostSource.post.feedContext,
326 reqId: anchorPostSource.post.reqId,
327 })
328 }
329 }
330
331 const onPressShowMore = useCallback(() => {
332 setLimitLines(false)
333 }, [setLimitLines])
334
335 const {isActive: live} = useActorStatus(post.author)
336
337 const reason = anchorPostSource?.post.reason
338 const viaRepost = useMemo(() => {
339 if (AppBskyFeedDefs.isReasonRepost(reason) && reason.uri && reason.cid) {
340 return {
341 uri: reason.uri,
342 cid: reason.cid,
343 }
344 }
345 }, [reason])
346
347 if (!record) {
348 return <ErrorMessage message={_(msg`Invalid or unsupported post record`)} />
349 }
350
351 if (isHighlightedPost) {
352 return (
353 <>
354 {rootUri !== post.uri && (
355 <View
356 style={[
357 a.pl_lg,
358 a.flex_row,
359 a.pb_xs,
360 {height: a.pt_lg.paddingTop},
361 ]}>
362 <View style={{width: 42}}>
363 <View
364 style={[
365 styles.replyLine,
366 a.flex_grow,
367 {backgroundColor: pal.colors.replyLine},
368 ]}
369 />
370 </View>
371 </View>
372 )}
373
374 <View
375 testID={`postThreadItem-by-${post.author.handle}`}
376 style={[
377 a.px_lg,
378 t.atoms.border_contrast_low,
379 // root post styles
380 rootUri === post.uri && [a.pt_lg],
381 ]}>
382 <View style={[a.flex_row, a.gap_md, a.pb_md]}>
383 <PreviewableUserAvatar
384 size={42}
385 profile={post.author}
386 moderation={moderation.ui('avatar')}
387 type={post.author.associated?.labeler ? 'labeler' : 'user'}
388 live={live}
389 onBeforePress={onOpenAuthor}
390 />
391 <View style={[a.flex_1]}>
392 <View style={[a.flex_row, a.align_center]}>
393 <Link
394 style={[a.flex_shrink]}
395 href={authorHref}
396 title={authorTitle}
397 onBeforePress={onOpenAuthor}>
398 <Text
399 emoji
400 style={[
401 a.text_lg,
402 a.font_bold,
403 a.leading_snug,
404 a.self_start,
405 ]}
406 numberOfLines={1}>
407 {sanitizeDisplayName(
408 post.author.displayName ||
409 sanitizeHandle(post.author.handle),
410 moderation.ui('displayName'),
411 )}
412 </Text>
413 </Link>
414
415 <View style={[{paddingLeft: 3, top: -1}]}>
416 <VerificationCheckButton
417 profile={shadowedPostAuthor}
418 size="md"
419 />
420 </View>
421 </View>
422 <Link style={s.flex1} href={authorHref} title={authorTitle}>
423 <Text
424 emoji
425 style={[
426 a.text_md,
427 a.leading_snug,
428 t.atoms.text_contrast_medium,
429 ]}
430 numberOfLines={1}>
431 {sanitizeHandle(post.author.handle, '@')}
432 </Text>
433 </Link>
434 </View>
435 {showFollowButton && (
436 <View>
437 <PostThreadFollowBtn did={post.author.did} />
438 </View>
439 )}
440 </View>
441 <View style={[a.pb_sm]}>
442 <LabelsOnMyPost post={post} style={[a.pb_sm]} />
443 <ContentHider
444 modui={moderation.ui('contentView')}
445 ignoreMute
446 childContainerStyle={[a.pt_sm]}>
447 <PostAlerts
448 modui={moderation.ui('contentView')}
449 size="lg"
450 includeMute
451 style={[a.pb_sm]}
452 additionalCauses={additionalPostAlerts}
453 />
454 {richText?.text ? (
455 <RichText
456 enableTags
457 selectable
458 value={richText}
459 style={[a.flex_1, a.text_xl]}
460 authorHandle={post.author.handle}
461 shouldProxyLinks={true}
462 />
463 ) : undefined}
464 {post.embed && (
465 <View style={[a.py_xs]}>
466 <Embed
467 embed={post.embed}
468 moderation={moderation}
469 viewContext={PostEmbedViewContext.ThreadHighlighted}
470 onOpen={onOpenEmbed}
471 />
472 </View>
473 )}
474 </ContentHider>
475 <ExpandedPostDetails
476 post={post}
477 record={record}
478 isThreadAuthor={isThreadAuthor}
479 needsTranslation={needsTranslation}
480 />
481 {post.repostCount !== 0 ||
482 post.likeCount !== 0 ||
483 post.quoteCount !== 0 ? (
484 // Show this section unless we're *sure* it has no engagement.
485 <View
486 style={[
487 a.flex_row,
488 a.align_center,
489 a.gap_lg,
490 a.border_t,
491 a.border_b,
492 a.mt_md,
493 a.py_md,
494 t.atoms.border_contrast_low,
495 ]}>
496 {post.repostCount != null && post.repostCount !== 0 ? (
497 <Link href={repostsHref} title={repostsTitle}>
498 <Text
499 testID="repostCount-expanded"
500 style={[a.text_md, t.atoms.text_contrast_medium]}>
501 <Text style={[a.text_md, a.font_bold, t.atoms.text]}>
502 {formatCount(i18n, post.repostCount)}
503 </Text>{' '}
504 <Plural
505 value={post.repostCount}
506 one="repost"
507 other="reposts"
508 />
509 </Text>
510 </Link>
511 ) : null}
512 {post.quoteCount != null &&
513 post.quoteCount !== 0 &&
514 !post.viewer?.embeddingDisabled ? (
515 <Link href={quotesHref} title={quotesTitle}>
516 <Text
517 testID="quoteCount-expanded"
518 style={[a.text_md, t.atoms.text_contrast_medium]}>
519 <Text style={[a.text_md, a.font_bold, t.atoms.text]}>
520 {formatCount(i18n, post.quoteCount)}
521 </Text>{' '}
522 <Plural
523 value={post.quoteCount}
524 one="quote"
525 other="quotes"
526 />
527 </Text>
528 </Link>
529 ) : null}
530 {post.likeCount != null && post.likeCount !== 0 ? (
531 <Link href={likesHref} title={likesTitle}>
532 <Text
533 testID="likeCount-expanded"
534 style={[a.text_md, t.atoms.text_contrast_medium]}>
535 <Text style={[a.text_md, a.font_bold, t.atoms.text]}>
536 {formatCount(i18n, post.likeCount)}
537 </Text>{' '}
538 <Plural value={post.likeCount} one="like" other="likes" />
539 </Text>
540 </Link>
541 ) : null}
542 </View>
543 ) : null}
544 <View
545 style={[
546 a.pt_sm,
547 a.pb_2xs,
548 {
549 marginLeft: -5,
550 },
551 ]}>
552 <FeedFeedbackProvider value={feedFeedback}>
553 <PostControls
554 big
555 post={post}
556 record={record}
557 richText={richText}
558 onPressReply={onPressReply}
559 onPostReply={onPostReply}
560 logContext="PostThreadItem"
561 threadgateRecord={threadgateRecord}
562 feedContext={anchorPostSource?.post?.feedContext}
563 reqId={anchorPostSource?.post?.reqId}
564 viaRepost={viaRepost}
565 />
566 </FeedFeedbackProvider>
567 </View>
568 </View>
569 </View>
570 </>
571 )
572 } else {
573 const isThreadedChild = treeView && depth > 0
574 const isThreadedChildAdjacentTop =
575 isThreadedChild && prevPost?.ctx.depth === depth && depth !== 1
576 const isThreadedChildAdjacentBot =
577 isThreadedChild && nextPost?.ctx.depth === depth
578 return (
579 <PostOuterWrapper
580 post={post}
581 depth={depth}
582 showParentReplyLine={!!showParentReplyLine}
583 treeView={treeView}
584 hasPrecedingItem={hasPrecedingItem}
585 hideTopBorder={hideTopBorder}>
586 <PostHider
587 testID={`postThreadItem-by-${post.author.handle}`}
588 href={postHref}
589 disabled={overrideBlur}
590 modui={moderation.ui('contentList')}
591 iconSize={isThreadedChild ? 24 : 42}
592 iconStyles={
593 isThreadedChild ? {marginRight: 4} : {marginLeft: 2, marginRight: 2}
594 }
595 profile={post.author}
596 interpretFilterAsBlur>
597 <View
598 style={{
599 flexDirection: 'row',
600 gap: 10,
601 paddingLeft: 8,
602 height: isThreadedChildAdjacentTop ? 8 : 16,
603 }}>
604 <View style={{width: 42}}>
605 {!isThreadedChild && showParentReplyLine && (
606 <View
607 style={[
608 styles.replyLine,
609 {
610 flexGrow: 1,
611 backgroundColor: pal.colors.replyLine,
612 marginBottom: 4,
613 },
614 ]}
615 />
616 )}
617 </View>
618 </View>
619
620 <View
621 style={[
622 a.flex_row,
623 a.px_sm,
624 a.gap_md,
625 {
626 paddingBottom:
627 showChildReplyLine && !isThreadedChild
628 ? 0
629 : isThreadedChildAdjacentBot
630 ? 4
631 : 8,
632 },
633 ]}>
634 {/* If we are in threaded mode, the avatar is rendered in PostMeta */}
635 {!isThreadedChild && (
636 <View>
637 <PreviewableUserAvatar
638 size={42}
639 profile={post.author}
640 moderation={moderation.ui('avatar')}
641 type={post.author.associated?.labeler ? 'labeler' : 'user'}
642 live={live}
643 />
644
645 {showChildReplyLine && (
646 <View
647 style={[
648 styles.replyLine,
649 {
650 flexGrow: 1,
651 backgroundColor: pal.colors.replyLine,
652 marginTop: 4,
653 },
654 ]}
655 />
656 )}
657 </View>
658 )}
659
660 <View style={[a.flex_1]}>
661 <PostMeta
662 author={post.author}
663 moderation={moderation}
664 timestamp={post.indexedAt}
665 postHref={postHref}
666 showAvatar={isThreadedChild}
667 avatarSize={24}
668 style={[a.pb_xs]}
669 />
670 <LabelsOnMyPost post={post} style={[a.pb_xs]} />
671 <PostAlerts
672 modui={moderation.ui('contentList')}
673 style={[a.pb_2xs]}
674 additionalCauses={additionalPostAlerts}
675 />
676 {richText?.text ? (
677 <View style={[a.pb_2xs, a.pr_sm]}>
678 <RichText
679 enableTags
680 value={richText}
681 style={[a.flex_1, a.text_md]}
682 numberOfLines={limitLines ? MAX_POST_LINES : undefined}
683 authorHandle={post.author.handle}
684 shouldProxyLinks={true}
685 />
686 {limitLines && (
687 <ShowMoreTextButton
688 style={[a.text_md]}
689 onPress={onPressShowMore}
690 />
691 )}
692 </View>
693 ) : undefined}
694 {post.embed && (
695 <View style={[a.pb_xs]}>
696 <Embed
697 embed={post.embed}
698 moderation={moderation}
699 viewContext={PostEmbedViewContext.Feed}
700 />
701 </View>
702 )}
703 <PostControls
704 post={post}
705 record={record}
706 richText={richText}
707 onPressReply={onPressReply}
708 logContext="PostThreadItem"
709 threadgateRecord={threadgateRecord}
710 />
711 </View>
712 </View>
713 {hasMore ? (
714 <Link
715 style={[
716 styles.loadMore,
717 {
718 paddingLeft: treeView ? 8 : 70,
719 paddingTop: 0,
720 paddingBottom: treeView ? 4 : 12,
721 },
722 ]}
723 href={postHref}
724 title={itemTitle}
725 noFeedback>
726 <Text
727 style={[t.atoms.text_contrast_medium, a.font_bold, a.text_sm]}>
728 <Trans>More</Trans>
729 </Text>
730 <ChevronRightIcon
731 size="xs"
732 style={[t.atoms.text_contrast_medium]}
733 />
734 </Link>
735 ) : undefined}
736 </PostHider>
737 </PostOuterWrapper>
738 )
739 }
740}
741PostThreadItemLoaded = memo(PostThreadItemLoaded)
742
743function PostOuterWrapper({
744 post,
745 treeView,
746 depth,
747 showParentReplyLine,
748 hasPrecedingItem,
749 hideTopBorder,
750 children,
751}: React.PropsWithChildren<{
752 post: AppBskyFeedDefs.PostView
753 treeView: boolean
754 depth: number
755 showParentReplyLine: boolean
756 hasPrecedingItem: boolean
757 hideTopBorder?: boolean
758}>) {
759 const t = useTheme()
760 const {
761 state: hover,
762 onIn: onHoverIn,
763 onOut: onHoverOut,
764 } = useInteractionState()
765 if (treeView && depth > 0) {
766 return (
767 <View
768 style={[
769 a.flex_row,
770 a.px_sm,
771 a.flex_row,
772 t.atoms.border_contrast_low,
773 styles.cursor,
774 depth === 1 && a.border_t,
775 ]}
776 onPointerEnter={onHoverIn}
777 onPointerLeave={onHoverOut}>
778 {Array.from(Array(depth - 1)).map((_, n: number) => (
779 <View
780 key={`${post.uri}-padding-${n}`}
781 style={[
782 a.ml_sm,
783 t.atoms.border_contrast_low,
784 {
785 borderLeftWidth: 2,
786 paddingLeft: a.pl_sm.paddingLeft - 2, // minus border
787 },
788 ]}
789 />
790 ))}
791 <View style={a.flex_1}>
792 <SubtleWebHover
793 hover={hover}
794 style={{
795 left: (depth === 1 ? 0 : 2) - a.pl_sm.paddingLeft,
796 right: -a.pr_sm.paddingRight,
797 }}
798 />
799 {children}
800 </View>
801 </View>
802 )
803 }
804 return (
805 <View
806 onPointerEnter={onHoverIn}
807 onPointerLeave={onHoverOut}
808 style={[
809 a.border_t,
810 a.px_sm,
811 t.atoms.border_contrast_low,
812 showParentReplyLine && hasPrecedingItem && styles.noTopBorder,
813 hideTopBorder && styles.noTopBorder,
814 styles.cursor,
815 ]}>
816 <SubtleWebHover hover={hover} />
817 {children}
818 </View>
819 )
820}
821
822function ExpandedPostDetails({
823 post,
824 record,
825 isThreadAuthor,
826 needsTranslation,
827}: {
828 post: AppBskyFeedDefs.PostView
829 record: AppBskyFeedPost.Record
830 isThreadAuthor: boolean
831 needsTranslation: boolean
832}) {
833 const t = useTheme()
834 const pal = usePalette('default')
835 const {_, i18n} = useLingui()
836 const translate = useTranslate()
837 const isRootPost = !('reply' in post.record)
838 const langPrefs = useLanguagePrefs()
839
840 const onTranslatePress = useCallback(
841 (e: GestureResponderEvent) => {
842 e.preventDefault()
843 translate(record.text || '', langPrefs.primaryLanguage)
844
845 if (
846 bsky.dangerousIsType<AppBskyFeedPost.Record>(
847 post.record,
848 AppBskyFeedPost.isRecord,
849 )
850 ) {
851 logger.metric(
852 'translate',
853 {
854 sourceLanguages: post.record.langs ?? [],
855 targetLanguage: langPrefs.primaryLanguage,
856 textLength: post.record.text.length,
857 },
858 {statsig: false},
859 )
860 }
861
862 return false
863 },
864 [translate, record.text, langPrefs, post],
865 )
866
867 return (
868 <View style={[a.gap_md, a.pt_md, a.align_start]}>
869 <BackdatedPostIndicator post={post} />
870 <View style={[a.flex_row, a.align_center, a.flex_wrap, a.gap_sm]}>
871 <Text style={[a.text_sm, t.atoms.text_contrast_medium]}>
872 {niceDate(i18n, post.indexedAt)}
873 </Text>
874 {isRootPost && (
875 <WhoCanReply post={post} isThreadAuthor={isThreadAuthor} />
876 )}
877 {needsTranslation && (
878 <>
879 <Text style={[a.text_sm, t.atoms.text_contrast_medium]}>
880 ·
881 </Text>
882
883 <InlineLinkText
884 // overridden to open an intent on android, but keep
885 // as anchor tag for accessibility
886 to={getTranslatorLink(record.text, langPrefs.primaryLanguage)}
887 label={_(msg`Translate`)}
888 style={[a.text_sm, pal.link]}
889 onPress={onTranslatePress}>
890 <Trans>Translate</Trans>
891 </InlineLinkText>
892 </>
893 )}
894 </View>
895 </View>
896 )
897}
898
899function BackdatedPostIndicator({post}: {post: AppBskyFeedDefs.PostView}) {
900 const t = useTheme()
901 const {_, i18n} = useLingui()
902 const control = Prompt.usePromptControl()
903
904 const indexedAt = new Date(post.indexedAt)
905 const createdAt = bsky.dangerousIsType<AppBskyFeedPost.Record>(
906 post.record,
907 AppBskyFeedPost.isRecord,
908 )
909 ? new Date(post.record.createdAt)
910 : new Date(post.indexedAt)
911
912 // backdated if createdAt is 24 hours or more before indexedAt
913 const isBackdated =
914 indexedAt.getTime() - createdAt.getTime() > 24 * 60 * 60 * 1000
915
916 if (!isBackdated) return null
917
918 const orange = t.name === 'light' ? colors.warning.dark : colors.warning.light
919
920 return (
921 <>
922 <Button
923 label={_(msg`Archived post`)}
924 accessibilityHint={_(
925 msg`Shows information about when this post was created`,
926 )}
927 onPress={e => {
928 e.preventDefault()
929 e.stopPropagation()
930 control.open()
931 }}>
932 {({hovered, pressed}) => (
933 <View
934 style={[
935 a.flex_row,
936 a.align_center,
937 a.rounded_full,
938 t.atoms.bg_contrast_25,
939 (hovered || pressed) && t.atoms.bg_contrast_50,
940 {
941 gap: 3,
942 paddingHorizontal: 6,
943 paddingVertical: 3,
944 },
945 ]}>
946 <CalendarClockIcon fill={orange} size="sm" aria-hidden />
947 <Text
948 style={[
949 a.text_xs,
950 a.font_bold,
951 a.leading_tight,
952 t.atoms.text_contrast_medium,
953 ]}>
954 <Trans>Archived from {niceDate(i18n, createdAt)}</Trans>
955 </Text>
956 </View>
957 )}
958 </Button>
959
960 <Prompt.Outer control={control}>
961 <Prompt.TitleText>
962 <Trans>Archived post</Trans>
963 </Prompt.TitleText>
964 <Prompt.DescriptionText>
965 <Trans>
966 This post claims to have been created on{' '}
967 <RNText style={[a.font_bold]}>{niceDate(i18n, createdAt)}</RNText>,
968 but was first seen by Bluesky on{' '}
969 <RNText style={[a.font_bold]}>{niceDate(i18n, indexedAt)}</RNText>.
970 </Trans>
971 </Prompt.DescriptionText>
972 <Text
973 style={[
974 a.text_md,
975 a.leading_snug,
976 t.atoms.text_contrast_high,
977 a.pb_xl,
978 ]}>
979 <Trans>
980 Bluesky cannot confirm the authenticity of the claimed date.
981 </Trans>
982 </Text>
983 <Prompt.Actions>
984 <Prompt.Action cta={_(msg`Okay`)} onPress={() => {}} />
985 </Prompt.Actions>
986 </Prompt.Outer>
987 </>
988 )
989}
990
991function getThreadAuthor(
992 post: AppBskyFeedDefs.PostView,
993 record: AppBskyFeedPost.Record,
994): string {
995 if (!record.reply) {
996 return post.author.did
997 }
998 try {
999 return new AtUri(record.reply.root.uri).host
1000 } catch {
1001 return ''
1002 }
1003}
1004
1005const styles = StyleSheet.create({
1006 outer: {
1007 borderTopWidth: StyleSheet.hairlineWidth,
1008 paddingLeft: 8,
1009 },
1010 noTopBorder: {
1011 borderTopWidth: 0,
1012 },
1013 meta: {
1014 flexDirection: 'row',
1015 paddingVertical: 2,
1016 },
1017 metaExpandedLine1: {
1018 paddingVertical: 0,
1019 },
1020 loadMore: {
1021 flexDirection: 'row',
1022 alignItems: 'center',
1023 justifyContent: 'flex-start',
1024 gap: 4,
1025 paddingHorizontal: 20,
1026 },
1027 replyLine: {
1028 width: 2,
1029 marginLeft: 'auto',
1030 marginRight: 'auto',
1031 },
1032 cursor: {
1033 // @ts-ignore web only
1034 cursor: 'pointer',
1035 },
1036})