mirror of https://git.lenooby09.tech/LeNooby09/social-app.git
at tooltip 1037 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 {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 &middot; 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})