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 {useOpenLink} from '#/lib/hooks/useOpenLink'
23import {usePalette} from '#/lib/hooks/usePalette'
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 translatorUrl = getTranslatorLink(
277 record?.text || '',
278 langPrefs.primaryLanguage,
279 )
280 const needsTranslation = useMemo(
281 () =>
282 Boolean(
283 langPrefs.primaryLanguage &&
284 !isPostInLanguage(post, [langPrefs.primaryLanguage]),
285 ),
286 [post, langPrefs.primaryLanguage],
287 )
288
289 const onPressReply = () => {
290 if (anchorPostSource && isHighlightedPost) {
291 feedFeedback.sendInteraction({
292 item: post.uri,
293 event: 'app.bsky.feed.defs#interactionReply',
294 feedContext: anchorPostSource.post.feedContext,
295 reqId: anchorPostSource.post.reqId,
296 })
297 }
298 openComposer({
299 replyTo: {
300 uri: post.uri,
301 cid: post.cid,
302 text: record.text,
303 author: post.author,
304 embed: post.embed,
305 moderation,
306 },
307 onPost: onPostReply,
308 onPostSuccess: onPostSuccess,
309 })
310 }
311
312 const onOpenAuthor = () => {
313 if (anchorPostSource) {
314 feedFeedback.sendInteraction({
315 item: post.uri,
316 event: 'app.bsky.feed.defs#clickthroughAuthor',
317 feedContext: anchorPostSource.post.feedContext,
318 reqId: anchorPostSource.post.reqId,
319 })
320 }
321 }
322
323 const onOpenEmbed = () => {
324 if (anchorPostSource) {
325 feedFeedback.sendInteraction({
326 item: post.uri,
327 event: 'app.bsky.feed.defs#clickthroughEmbed',
328 feedContext: anchorPostSource.post.feedContext,
329 reqId: anchorPostSource.post.reqId,
330 })
331 }
332 }
333
334 const onPressShowMore = useCallback(() => {
335 setLimitLines(false)
336 }, [setLimitLines])
337
338 const {isActive: live} = useActorStatus(post.author)
339
340 const reason = anchorPostSource?.post.reason
341 const viaRepost = useMemo(() => {
342 if (AppBskyFeedDefs.isReasonRepost(reason) && reason.uri && reason.cid) {
343 return {
344 uri: reason.uri,
345 cid: reason.cid,
346 }
347 }
348 }, [reason])
349
350 if (!record) {
351 return <ErrorMessage message={_(msg`Invalid or unsupported post record`)} />
352 }
353
354 if (isHighlightedPost) {
355 return (
356 <>
357 {rootUri !== post.uri && (
358 <View
359 style={[
360 a.pl_lg,
361 a.flex_row,
362 a.pb_xs,
363 {height: a.pt_lg.paddingTop},
364 ]}>
365 <View style={{width: 42}}>
366 <View
367 style={[
368 styles.replyLine,
369 a.flex_grow,
370 {backgroundColor: pal.colors.replyLine},
371 ]}
372 />
373 </View>
374 </View>
375 )}
376
377 <View
378 testID={`postThreadItem-by-${post.author.handle}`}
379 style={[
380 a.px_lg,
381 t.atoms.border_contrast_low,
382 // root post styles
383 rootUri === post.uri && [a.pt_lg],
384 ]}>
385 <View style={[a.flex_row, a.gap_md, a.pb_md]}>
386 <PreviewableUserAvatar
387 size={42}
388 profile={post.author}
389 moderation={moderation.ui('avatar')}
390 type={post.author.associated?.labeler ? 'labeler' : 'user'}
391 live={live}
392 onBeforePress={onOpenAuthor}
393 />
394 <View style={[a.flex_1]}>
395 <View style={[a.flex_row, a.align_center]}>
396 <Link
397 style={[a.flex_shrink]}
398 href={authorHref}
399 title={authorTitle}
400 onBeforePress={onOpenAuthor}>
401 <Text
402 emoji
403 style={[
404 a.text_lg,
405 a.font_bold,
406 a.leading_snug,
407 a.self_start,
408 ]}
409 numberOfLines={1}>
410 {sanitizeDisplayName(
411 post.author.displayName ||
412 sanitizeHandle(post.author.handle),
413 moderation.ui('displayName'),
414 )}
415 </Text>
416 </Link>
417
418 <View style={[{paddingLeft: 3, top: -1}]}>
419 <VerificationCheckButton
420 profile={shadowedPostAuthor}
421 size="md"
422 />
423 </View>
424 </View>
425 <Link style={s.flex1} href={authorHref} title={authorTitle}>
426 <Text
427 emoji
428 style={[
429 a.text_md,
430 a.leading_snug,
431 t.atoms.text_contrast_medium,
432 ]}
433 numberOfLines={1}>
434 {sanitizeHandle(post.author.handle, '@')}
435 </Text>
436 </Link>
437 </View>
438 {showFollowButton && (
439 <View>
440 <PostThreadFollowBtn did={post.author.did} />
441 </View>
442 )}
443 </View>
444 <View style={[a.pb_sm]}>
445 <LabelsOnMyPost post={post} style={[a.pb_sm]} />
446 <ContentHider
447 modui={moderation.ui('contentView')}
448 ignoreMute
449 childContainerStyle={[a.pt_sm]}>
450 <PostAlerts
451 modui={moderation.ui('contentView')}
452 size="lg"
453 includeMute
454 style={[a.pb_sm]}
455 additionalCauses={additionalPostAlerts}
456 />
457 {richText?.text ? (
458 <RichText
459 enableTags
460 selectable
461 value={richText}
462 style={[a.flex_1, a.text_xl]}
463 authorHandle={post.author.handle}
464 shouldProxyLinks={true}
465 />
466 ) : undefined}
467 {post.embed && (
468 <View style={[a.py_xs]}>
469 <Embed
470 embed={post.embed}
471 moderation={moderation}
472 viewContext={PostEmbedViewContext.ThreadHighlighted}
473 onOpen={onOpenEmbed}
474 />
475 </View>
476 )}
477 </ContentHider>
478 <ExpandedPostDetails
479 post={post}
480 isThreadAuthor={isThreadAuthor}
481 translatorUrl={translatorUrl}
482 needsTranslation={needsTranslation}
483 />
484 {post.repostCount !== 0 ||
485 post.likeCount !== 0 ||
486 post.quoteCount !== 0 ? (
487 // Show this section unless we're *sure* it has no engagement.
488 <View
489 style={[
490 a.flex_row,
491 a.align_center,
492 a.gap_lg,
493 a.border_t,
494 a.border_b,
495 a.mt_md,
496 a.py_md,
497 t.atoms.border_contrast_low,
498 ]}>
499 {post.repostCount != null && post.repostCount !== 0 ? (
500 <Link href={repostsHref} title={repostsTitle}>
501 <Text
502 testID="repostCount-expanded"
503 style={[a.text_md, t.atoms.text_contrast_medium]}>
504 <Text style={[a.text_md, a.font_bold, t.atoms.text]}>
505 {formatCount(i18n, post.repostCount)}
506 </Text>{' '}
507 <Plural
508 value={post.repostCount}
509 one="repost"
510 other="reposts"
511 />
512 </Text>
513 </Link>
514 ) : null}
515 {post.quoteCount != null &&
516 post.quoteCount !== 0 &&
517 !post.viewer?.embeddingDisabled ? (
518 <Link href={quotesHref} title={quotesTitle}>
519 <Text
520 testID="quoteCount-expanded"
521 style={[a.text_md, t.atoms.text_contrast_medium]}>
522 <Text style={[a.text_md, a.font_bold, t.atoms.text]}>
523 {formatCount(i18n, post.quoteCount)}
524 </Text>{' '}
525 <Plural
526 value={post.quoteCount}
527 one="quote"
528 other="quotes"
529 />
530 </Text>
531 </Link>
532 ) : null}
533 {post.likeCount != null && post.likeCount !== 0 ? (
534 <Link href={likesHref} title={likesTitle}>
535 <Text
536 testID="likeCount-expanded"
537 style={[a.text_md, t.atoms.text_contrast_medium]}>
538 <Text style={[a.text_md, a.font_bold, t.atoms.text]}>
539 {formatCount(i18n, post.likeCount)}
540 </Text>{' '}
541 <Plural value={post.likeCount} one="like" other="likes" />
542 </Text>
543 </Link>
544 ) : null}
545 </View>
546 ) : null}
547 <View
548 style={[
549 a.pt_sm,
550 a.pb_2xs,
551 {
552 marginLeft: -5,
553 },
554 ]}>
555 <FeedFeedbackProvider value={feedFeedback}>
556 <PostControls
557 big
558 post={post}
559 record={record}
560 richText={richText}
561 onPressReply={onPressReply}
562 onPostReply={onPostReply}
563 logContext="PostThreadItem"
564 threadgateRecord={threadgateRecord}
565 feedContext={anchorPostSource?.post?.feedContext}
566 reqId={anchorPostSource?.post?.reqId}
567 viaRepost={viaRepost}
568 />
569 </FeedFeedbackProvider>
570 </View>
571 </View>
572 </View>
573 </>
574 )
575 } else {
576 const isThreadedChild = treeView && depth > 0
577 const isThreadedChildAdjacentTop =
578 isThreadedChild && prevPost?.ctx.depth === depth && depth !== 1
579 const isThreadedChildAdjacentBot =
580 isThreadedChild && nextPost?.ctx.depth === depth
581 return (
582 <PostOuterWrapper
583 post={post}
584 depth={depth}
585 showParentReplyLine={!!showParentReplyLine}
586 treeView={treeView}
587 hasPrecedingItem={hasPrecedingItem}
588 hideTopBorder={hideTopBorder}>
589 <PostHider
590 testID={`postThreadItem-by-${post.author.handle}`}
591 href={postHref}
592 disabled={overrideBlur}
593 modui={moderation.ui('contentList')}
594 iconSize={isThreadedChild ? 24 : 42}
595 iconStyles={
596 isThreadedChild ? {marginRight: 4} : {marginLeft: 2, marginRight: 2}
597 }
598 profile={post.author}
599 interpretFilterAsBlur>
600 <View
601 style={{
602 flexDirection: 'row',
603 gap: 10,
604 paddingLeft: 8,
605 height: isThreadedChildAdjacentTop ? 8 : 16,
606 }}>
607 <View style={{width: 42}}>
608 {!isThreadedChild && showParentReplyLine && (
609 <View
610 style={[
611 styles.replyLine,
612 {
613 flexGrow: 1,
614 backgroundColor: pal.colors.replyLine,
615 marginBottom: 4,
616 },
617 ]}
618 />
619 )}
620 </View>
621 </View>
622
623 <View
624 style={[
625 a.flex_row,
626 a.px_sm,
627 a.gap_md,
628 {
629 paddingBottom:
630 showChildReplyLine && !isThreadedChild
631 ? 0
632 : isThreadedChildAdjacentBot
633 ? 4
634 : 8,
635 },
636 ]}>
637 {/* If we are in threaded mode, the avatar is rendered in PostMeta */}
638 {!isThreadedChild && (
639 <View>
640 <PreviewableUserAvatar
641 size={42}
642 profile={post.author}
643 moderation={moderation.ui('avatar')}
644 type={post.author.associated?.labeler ? 'labeler' : 'user'}
645 live={live}
646 />
647
648 {showChildReplyLine && (
649 <View
650 style={[
651 styles.replyLine,
652 {
653 flexGrow: 1,
654 backgroundColor: pal.colors.replyLine,
655 marginTop: 4,
656 },
657 ]}
658 />
659 )}
660 </View>
661 )}
662
663 <View style={[a.flex_1]}>
664 <PostMeta
665 author={post.author}
666 moderation={moderation}
667 timestamp={post.indexedAt}
668 postHref={postHref}
669 showAvatar={isThreadedChild}
670 avatarSize={24}
671 style={[a.pb_xs]}
672 />
673 <LabelsOnMyPost post={post} style={[a.pb_xs]} />
674 <PostAlerts
675 modui={moderation.ui('contentList')}
676 style={[a.pb_2xs]}
677 additionalCauses={additionalPostAlerts}
678 />
679 {richText?.text ? (
680 <View style={[a.pb_2xs, a.pr_sm]}>
681 <RichText
682 enableTags
683 value={richText}
684 style={[a.flex_1, a.text_md]}
685 numberOfLines={limitLines ? MAX_POST_LINES : undefined}
686 authorHandle={post.author.handle}
687 shouldProxyLinks={true}
688 />
689 {limitLines && (
690 <ShowMoreTextButton
691 style={[a.text_md]}
692 onPress={onPressShowMore}
693 />
694 )}
695 </View>
696 ) : undefined}
697 {post.embed && (
698 <View style={[a.pb_xs]}>
699 <Embed
700 embed={post.embed}
701 moderation={moderation}
702 viewContext={PostEmbedViewContext.Feed}
703 />
704 </View>
705 )}
706 <PostControls
707 post={post}
708 record={record}
709 richText={richText}
710 onPressReply={onPressReply}
711 logContext="PostThreadItem"
712 threadgateRecord={threadgateRecord}
713 />
714 </View>
715 </View>
716 {hasMore ? (
717 <Link
718 style={[
719 styles.loadMore,
720 {
721 paddingLeft: treeView ? 8 : 70,
722 paddingTop: 0,
723 paddingBottom: treeView ? 4 : 12,
724 },
725 ]}
726 href={postHref}
727 title={itemTitle}
728 noFeedback>
729 <Text
730 style={[t.atoms.text_contrast_medium, a.font_bold, a.text_sm]}>
731 <Trans>More</Trans>
732 </Text>
733 <ChevronRightIcon
734 size="xs"
735 style={[t.atoms.text_contrast_medium]}
736 />
737 </Link>
738 ) : undefined}
739 </PostHider>
740 </PostOuterWrapper>
741 )
742 }
743}
744PostThreadItemLoaded = memo(PostThreadItemLoaded)
745
746function PostOuterWrapper({
747 post,
748 treeView,
749 depth,
750 showParentReplyLine,
751 hasPrecedingItem,
752 hideTopBorder,
753 children,
754}: React.PropsWithChildren<{
755 post: AppBskyFeedDefs.PostView
756 treeView: boolean
757 depth: number
758 showParentReplyLine: boolean
759 hasPrecedingItem: boolean
760 hideTopBorder?: boolean
761}>) {
762 const t = useTheme()
763 const {
764 state: hover,
765 onIn: onHoverIn,
766 onOut: onHoverOut,
767 } = useInteractionState()
768 if (treeView && depth > 0) {
769 return (
770 <View
771 style={[
772 a.flex_row,
773 a.px_sm,
774 a.flex_row,
775 t.atoms.border_contrast_low,
776 styles.cursor,
777 depth === 1 && a.border_t,
778 ]}
779 onPointerEnter={onHoverIn}
780 onPointerLeave={onHoverOut}>
781 {Array.from(Array(depth - 1)).map((_, n: number) => (
782 <View
783 key={`${post.uri}-padding-${n}`}
784 style={[
785 a.ml_sm,
786 t.atoms.border_contrast_low,
787 {
788 borderLeftWidth: 2,
789 paddingLeft: a.pl_sm.paddingLeft - 2, // minus border
790 },
791 ]}
792 />
793 ))}
794 <View style={a.flex_1}>
795 <SubtleWebHover
796 hover={hover}
797 style={{
798 left: (depth === 1 ? 0 : 2) - a.pl_sm.paddingLeft,
799 right: -a.pr_sm.paddingRight,
800 }}
801 />
802 {children}
803 </View>
804 </View>
805 )
806 }
807 return (
808 <View
809 onPointerEnter={onHoverIn}
810 onPointerLeave={onHoverOut}
811 style={[
812 a.border_t,
813 a.px_sm,
814 t.atoms.border_contrast_low,
815 showParentReplyLine && hasPrecedingItem && styles.noTopBorder,
816 hideTopBorder && styles.noTopBorder,
817 styles.cursor,
818 ]}>
819 <SubtleWebHover hover={hover} />
820 {children}
821 </View>
822 )
823}
824
825function ExpandedPostDetails({
826 post,
827 isThreadAuthor,
828 needsTranslation,
829 translatorUrl,
830}: {
831 post: AppBskyFeedDefs.PostView
832 isThreadAuthor: boolean
833 needsTranslation: boolean
834 translatorUrl: string
835}) {
836 const t = useTheme()
837 const pal = usePalette('default')
838 const {_, i18n} = useLingui()
839 const openLink = useOpenLink()
840 const isRootPost = !('reply' in post.record)
841 const langPrefs = useLanguagePrefs()
842
843 const onTranslatePress = useCallback(
844 (e: GestureResponderEvent) => {
845 e.preventDefault()
846 openLink(translatorUrl, true)
847
848 if (
849 bsky.dangerousIsType<AppBskyFeedPost.Record>(
850 post.record,
851 AppBskyFeedPost.isRecord,
852 )
853 ) {
854 logger.metric(
855 'translate',
856 {
857 sourceLanguages: post.record.langs ?? [],
858 targetLanguage: langPrefs.primaryLanguage,
859 textLength: post.record.text.length,
860 },
861 {statsig: false},
862 )
863 }
864
865 return false
866 },
867 [openLink, translatorUrl, langPrefs, post],
868 )
869
870 return (
871 <View style={[a.gap_md, a.pt_md, a.align_start]}>
872 <BackdatedPostIndicator post={post} />
873 <View style={[a.flex_row, a.align_center, a.flex_wrap, a.gap_sm]}>
874 <Text style={[a.text_sm, t.atoms.text_contrast_medium]}>
875 {niceDate(i18n, post.indexedAt)}
876 </Text>
877 {isRootPost && (
878 <WhoCanReply post={post} isThreadAuthor={isThreadAuthor} />
879 )}
880 {needsTranslation && (
881 <>
882 <Text style={[a.text_sm, t.atoms.text_contrast_medium]}>
883 ·
884 </Text>
885
886 <InlineLinkText
887 to={translatorUrl}
888 label={_(msg`Translate`)}
889 style={[a.text_sm, pal.link]}
890 onPress={onTranslatePress}>
891 <Trans>Translate</Trans>
892 </InlineLinkText>
893 </>
894 )}
895 </View>
896 </View>
897 )
898}
899
900function BackdatedPostIndicator({post}: {post: AppBskyFeedDefs.PostView}) {
901 const t = useTheme()
902 const {_, i18n} = useLingui()
903 const control = Prompt.usePromptControl()
904
905 const indexedAt = new Date(post.indexedAt)
906 const createdAt = bsky.dangerousIsType<AppBskyFeedPost.Record>(
907 post.record,
908 AppBskyFeedPost.isRecord,
909 )
910 ? new Date(post.record.createdAt)
911 : new Date(post.indexedAt)
912
913 // backdated if createdAt is 24 hours or more before indexedAt
914 const isBackdated =
915 indexedAt.getTime() - createdAt.getTime() > 24 * 60 * 60 * 1000
916
917 if (!isBackdated) return null
918
919 const orange = t.name === 'light' ? colors.warning.dark : colors.warning.light
920
921 return (
922 <>
923 <Button
924 label={_(msg`Archived post`)}
925 accessibilityHint={_(
926 msg`Shows information about when this post was created`,
927 )}
928 onPress={e => {
929 e.preventDefault()
930 e.stopPropagation()
931 control.open()
932 }}>
933 {({hovered, pressed}) => (
934 <View
935 style={[
936 a.flex_row,
937 a.align_center,
938 a.rounded_full,
939 t.atoms.bg_contrast_25,
940 (hovered || pressed) && t.atoms.bg_contrast_50,
941 {
942 gap: 3,
943 paddingHorizontal: 6,
944 paddingVertical: 3,
945 },
946 ]}>
947 <CalendarClockIcon fill={orange} size="sm" aria-hidden />
948 <Text
949 style={[
950 a.text_xs,
951 a.font_bold,
952 a.leading_tight,
953 t.atoms.text_contrast_medium,
954 ]}>
955 <Trans>Archived from {niceDate(i18n, createdAt)}</Trans>
956 </Text>
957 </View>
958 )}
959 </Button>
960
961 <Prompt.Outer control={control}>
962 <Prompt.TitleText>
963 <Trans>Archived post</Trans>
964 </Prompt.TitleText>
965 <Prompt.DescriptionText>
966 <Trans>
967 This post claims to have been created on{' '}
968 <RNText style={[a.font_bold]}>{niceDate(i18n, createdAt)}</RNText>,
969 but was first seen by Bluesky on{' '}
970 <RNText style={[a.font_bold]}>{niceDate(i18n, indexedAt)}</RNText>.
971 </Trans>
972 </Prompt.DescriptionText>
973 <Text
974 style={[
975 a.text_md,
976 a.leading_snug,
977 t.atoms.text_contrast_high,
978 a.pb_xl,
979 ]}>
980 <Trans>
981 Bluesky cannot confirm the authenticity of the claimed date.
982 </Trans>
983 </Text>
984 <Prompt.Actions>
985 <Prompt.Action cta={_(msg`Okay`)} onPress={() => {}} />
986 </Prompt.Actions>
987 </Prompt.Outer>
988 </>
989 )
990}
991
992function getThreadAuthor(
993 post: AppBskyFeedDefs.PostView,
994 record: AppBskyFeedPost.Record,
995): string {
996 if (!record.reply) {
997 return post.author.did
998 }
999 try {
1000 return new AtUri(record.reply.root.uri).host
1001 } catch {
1002 return ''
1003 }
1004}
1005
1006const styles = StyleSheet.create({
1007 outer: {
1008 borderTopWidth: StyleSheet.hairlineWidth,
1009 paddingLeft: 8,
1010 },
1011 noTopBorder: {
1012 borderTopWidth: 0,
1013 },
1014 meta: {
1015 flexDirection: 'row',
1016 paddingVertical: 2,
1017 },
1018 metaExpandedLine1: {
1019 paddingVertical: 0,
1020 },
1021 loadMore: {
1022 flexDirection: 'row',
1023 alignItems: 'center',
1024 justifyContent: 'flex-start',
1025 gap: 4,
1026 paddingHorizontal: 20,
1027 },
1028 replyLine: {
1029 width: 2,
1030 marginLeft: 'auto',
1031 marginRight: 'auto',
1032 },
1033 cursor: {
1034 // @ts-ignore web only
1035 cursor: 'pointer',
1036 },
1037})