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