mirror of https://git.lenooby09.tech/LeNooby09/social-app.git
at vouch-impl 917 lines 28 kB view raw
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 &middot; 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})