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