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