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