mirror of https://git.lenooby09.tech/LeNooby09/social-app.git
at tooltip 713 lines 22 kB view raw
1import {memo, useCallback, useMemo} from 'react' 2import {type GestureResponderEvent, Text as RNText, View} from 'react-native' 3import { 4 AppBskyFeedDefs, 5 AppBskyFeedPost, 6 type AppBskyFeedThreadgate, 7 AtUri, 8 RichText as RichTextAPI, 9} from '@atproto/api' 10import {msg, Plural, Trans} from '@lingui/macro' 11import {useLingui} from '@lingui/react' 12 13import {useActorStatus} from '#/lib/actor-status' 14import {useOpenComposer} from '#/lib/hooks/useOpenComposer' 15import {useOpenLink} from '#/lib/hooks/useOpenLink' 16import {makeProfileLink} from '#/lib/routes/links' 17import {sanitizeDisplayName} from '#/lib/strings/display-names' 18import {sanitizeHandle} from '#/lib/strings/handles' 19import {niceDate} from '#/lib/strings/time' 20import {getTranslatorLink, isPostInLanguage} from '#/locale/helpers' 21import {logger} from '#/logger' 22import { 23 POST_TOMBSTONE, 24 type Shadow, 25 usePostShadow, 26} from '#/state/cache/post-shadow' 27import {useProfileShadow} from '#/state/cache/profile-shadow' 28import {FeedFeedbackProvider, useFeedFeedback} from '#/state/feed-feedback' 29import {useLanguagePrefs} from '#/state/preferences' 30import {type ThreadItem} from '#/state/queries/usePostThread/types' 31import {useSession} from '#/state/session' 32import {type OnPostSuccessData} from '#/state/shell/composer' 33import {useMergedThreadgateHiddenReplies} from '#/state/threadgate-hidden-replies' 34import {type PostSource} from '#/state/unstable-post-source' 35import {PostThreadFollowBtn} from '#/view/com/post-thread/PostThreadFollowBtn' 36import {formatCount} from '#/view/com/util/numeric/format' 37import {PreviewableUserAvatar} from '#/view/com/util/UserAvatar' 38import { 39 LINEAR_AVI_WIDTH, 40 OUTER_SPACE, 41 REPLY_LINE_WIDTH, 42} from '#/screens/PostThread/const' 43import {atoms as a, useTheme} from '#/alf' 44import {colors} from '#/components/Admonition' 45import {Button} from '#/components/Button' 46import {CalendarClock_Stroke2_Corner0_Rounded as CalendarClockIcon} from '#/components/icons/CalendarClock' 47import {Trash_Stroke2_Corner0_Rounded as TrashIcon} from '#/components/icons/Trash' 48import {InlineLinkText, Link} from '#/components/Link' 49import {ContentHider} from '#/components/moderation/ContentHider' 50import {LabelsOnMyPost} from '#/components/moderation/LabelsOnMe' 51import {PostAlerts} from '#/components/moderation/PostAlerts' 52import {type AppModerationCause} from '#/components/Pills' 53import {Embed, PostEmbedViewContext} from '#/components/Post/Embed' 54import {PostControls} from '#/components/PostControls' 55import {ProfileHoverCard} from '#/components/ProfileHoverCard' 56import * as Prompt from '#/components/Prompt' 57import {RichText} from '#/components/RichText' 58import * as Skele from '#/components/Skeleton' 59import {Text} from '#/components/Typography' 60import {VerificationCheckButton} from '#/components/verification/VerificationCheckButton' 61import {WhoCanReply} from '#/components/WhoCanReply' 62import * as bsky from '#/types/bsky' 63 64export function ThreadItemAnchor({ 65 item, 66 onPostSuccess, 67 threadgateRecord, 68 postSource, 69}: { 70 item: Extract<ThreadItem, {type: 'threadPost'}> 71 onPostSuccess?: (data: OnPostSuccessData) => void 72 threadgateRecord?: AppBskyFeedThreadgate.Record 73 postSource?: PostSource 74}) { 75 const postShadow = usePostShadow(item.value.post) 76 const threadRootUri = item.value.post.record.reply?.root?.uri || item.uri 77 const isRoot = threadRootUri === item.uri 78 79 if (postShadow === POST_TOMBSTONE) { 80 return <ThreadItemAnchorDeleted isRoot={isRoot} /> 81 } 82 83 return ( 84 <ThreadItemAnchorInner 85 // Safeguard from clobbering per-post state below: 86 key={postShadow.uri} 87 item={item} 88 isRoot={isRoot} 89 postShadow={postShadow} 90 onPostSuccess={onPostSuccess} 91 threadgateRecord={threadgateRecord} 92 postSource={postSource} 93 /> 94 ) 95} 96 97function ThreadItemAnchorDeleted({isRoot}: {isRoot: boolean}) { 98 const t = useTheme() 99 100 return ( 101 <> 102 <ThreadItemAnchorParentReplyLine isRoot={isRoot} /> 103 104 <View 105 style={[ 106 { 107 paddingHorizontal: OUTER_SPACE, 108 paddingBottom: OUTER_SPACE, 109 }, 110 isRoot && [a.pt_lg], 111 ]}> 112 <View 113 style={[ 114 a.flex_row, 115 a.align_center, 116 a.py_md, 117 a.rounded_sm, 118 t.atoms.bg_contrast_25, 119 ]}> 120 <View 121 style={[ 122 a.flex_row, 123 a.align_center, 124 a.justify_center, 125 { 126 width: LINEAR_AVI_WIDTH, 127 }, 128 ]}> 129 <TrashIcon style={[t.atoms.text_contrast_medium]} /> 130 </View> 131 <Text style={[a.text_md, a.font_bold, t.atoms.text_contrast_medium]}> 132 <Trans>Post has been deleted</Trans> 133 </Text> 134 </View> 135 </View> 136 </> 137 ) 138} 139 140function ThreadItemAnchorParentReplyLine({isRoot}: {isRoot: boolean}) { 141 const t = useTheme() 142 143 return !isRoot ? ( 144 <View style={[a.pl_lg, a.flex_row, a.pb_xs, {height: a.pt_lg.paddingTop}]}> 145 <View style={{width: 42}}> 146 <View 147 style={[ 148 { 149 width: REPLY_LINE_WIDTH, 150 marginLeft: 'auto', 151 marginRight: 'auto', 152 flexGrow: 1, 153 backgroundColor: t.atoms.border_contrast_low.borderColor, 154 }, 155 ]} 156 /> 157 </View> 158 </View> 159 ) : null 160} 161 162const ThreadItemAnchorInner = memo(function ThreadItemAnchorInner({ 163 item, 164 isRoot, 165 postShadow, 166 onPostSuccess, 167 threadgateRecord, 168 postSource, 169}: { 170 item: Extract<ThreadItem, {type: 'threadPost'}> 171 isRoot: boolean 172 postShadow: Shadow<AppBskyFeedDefs.PostView> 173 onPostSuccess?: (data: OnPostSuccessData) => void 174 threadgateRecord?: AppBskyFeedThreadgate.Record 175 postSource?: PostSource 176}) { 177 const t = useTheme() 178 const {_, i18n} = useLingui() 179 const {openComposer} = useOpenComposer() 180 const {currentAccount, hasSession} = useSession() 181 const feedFeedback = useFeedFeedback(postSource?.feed, hasSession) 182 183 const post = postShadow 184 const record = item.value.post.record 185 const moderation = item.moderation 186 const authorShadow = useProfileShadow(post.author) 187 const {isActive: live} = useActorStatus(post.author) 188 const richText = useMemo( 189 () => 190 new RichTextAPI({ 191 text: record.text, 192 facets: record.facets, 193 }), 194 [record], 195 ) 196 197 const threadRootUri = record.reply?.root?.uri || post.uri 198 const authorHref = makeProfileLink(post.author) 199 const isThreadAuthor = getThreadAuthor(post, record) === currentAccount?.did 200 201 const likesHref = useMemo(() => { 202 const urip = new AtUri(post.uri) 203 return makeProfileLink(post.author, 'post', urip.rkey, 'liked-by') 204 }, [post.uri, post.author]) 205 const repostsHref = useMemo(() => { 206 const urip = new AtUri(post.uri) 207 return makeProfileLink(post.author, 'post', urip.rkey, 'reposted-by') 208 }, [post.uri, post.author]) 209 const quotesHref = useMemo(() => { 210 const urip = new AtUri(post.uri) 211 return makeProfileLink(post.author, 'post', urip.rkey, 'quotes') 212 }, [post.uri, post.author]) 213 214 const threadgateHiddenReplies = useMergedThreadgateHiddenReplies({ 215 threadgateRecord, 216 }) 217 const additionalPostAlerts: AppModerationCause[] = useMemo(() => { 218 const isPostHiddenByThreadgate = threadgateHiddenReplies.has(post.uri) 219 const isControlledByViewer = 220 new AtUri(threadRootUri).host === currentAccount?.did 221 return isControlledByViewer && isPostHiddenByThreadgate 222 ? [ 223 { 224 type: 'reply-hidden', 225 source: {type: 'user', did: currentAccount?.did}, 226 priority: 6, 227 }, 228 ] 229 : [] 230 }, [post, currentAccount?.did, threadgateHiddenReplies, threadRootUri]) 231 const onlyFollowersCanReply = !!threadgateRecord?.allow?.find( 232 rule => rule.$type === 'app.bsky.feed.threadgate#followerRule', 233 ) 234 const showFollowButton = 235 currentAccount?.did !== post.author.did && !onlyFollowersCanReply 236 237 const viaRepost = useMemo(() => { 238 const reason = postSource?.post.reason 239 240 if (AppBskyFeedDefs.isReasonRepost(reason) && reason.uri && reason.cid) { 241 return { 242 uri: reason.uri, 243 cid: reason.cid, 244 } 245 } 246 }, [postSource]) 247 248 const onPressReply = useCallback(() => { 249 openComposer({ 250 replyTo: { 251 uri: post.uri, 252 cid: post.cid, 253 text: record.text, 254 author: post.author, 255 embed: post.embed, 256 moderation, 257 }, 258 onPostSuccess: onPostSuccess, 259 }) 260 261 if (postSource) { 262 feedFeedback.sendInteraction({ 263 item: post.uri, 264 event: 'app.bsky.feed.defs#interactionReply', 265 feedContext: postSource.post.feedContext, 266 reqId: postSource.post.reqId, 267 }) 268 } 269 }, [ 270 openComposer, 271 post, 272 record, 273 onPostSuccess, 274 moderation, 275 postSource, 276 feedFeedback, 277 ]) 278 279 const onOpenAuthor = () => { 280 if (postSource) { 281 feedFeedback.sendInteraction({ 282 item: post.uri, 283 event: 'app.bsky.feed.defs#clickthroughAuthor', 284 feedContext: postSource.post.feedContext, 285 reqId: postSource.post.reqId, 286 }) 287 } 288 } 289 290 const onOpenEmbed = () => { 291 if (postSource) { 292 feedFeedback.sendInteraction({ 293 item: post.uri, 294 event: 'app.bsky.feed.defs#clickthroughEmbed', 295 feedContext: postSource.post.feedContext, 296 reqId: postSource.post.reqId, 297 }) 298 } 299 } 300 301 return ( 302 <> 303 <ThreadItemAnchorParentReplyLine isRoot={isRoot} /> 304 305 <View 306 testID={`postThreadItem-by-${post.author.handle}`} 307 style={[ 308 { 309 paddingHorizontal: OUTER_SPACE, 310 }, 311 isRoot && [a.pt_lg], 312 ]}> 313 <View style={[a.flex_row, a.gap_md, a.pb_md]}> 314 <View collapsable={false}> 315 <PreviewableUserAvatar 316 size={42} 317 profile={post.author} 318 moderation={moderation.ui('avatar')} 319 type={post.author.associated?.labeler ? 'labeler' : 'user'} 320 live={live} 321 onBeforePress={onOpenAuthor} 322 /> 323 </View> 324 <Link 325 to={authorHref} 326 style={[a.flex_1]} 327 label={sanitizeDisplayName( 328 post.author.displayName || sanitizeHandle(post.author.handle), 329 moderation.ui('displayName'), 330 )} 331 onPress={onOpenAuthor}> 332 <View style={[a.flex_1, a.align_start]}> 333 <ProfileHoverCard did={post.author.did} style={[a.w_full]}> 334 <View style={[a.flex_row, a.align_center]}> 335 <Text 336 emoji 337 style={[ 338 a.flex_shrink, 339 a.text_lg, 340 a.font_bold, 341 a.leading_snug, 342 ]} 343 numberOfLines={1}> 344 {sanitizeDisplayName( 345 post.author.displayName || 346 sanitizeHandle(post.author.handle), 347 moderation.ui('displayName'), 348 )} 349 </Text> 350 351 <View style={[{paddingLeft: 3, top: -1}]}> 352 <VerificationCheckButton profile={authorShadow} size="md" /> 353 </View> 354 </View> 355 <Text 356 style={[ 357 a.text_md, 358 a.leading_snug, 359 t.atoms.text_contrast_medium, 360 ]} 361 numberOfLines={1}> 362 {sanitizeHandle(post.author.handle, '@')} 363 </Text> 364 </ProfileHoverCard> 365 </View> 366 </Link> 367 {showFollowButton && ( 368 <View collapsable={false}> 369 <PostThreadFollowBtn did={post.author.did} /> 370 </View> 371 )} 372 </View> 373 <View style={[a.pb_sm]}> 374 <LabelsOnMyPost post={post} style={[a.pb_sm]} /> 375 <ContentHider 376 modui={moderation.ui('contentView')} 377 ignoreMute 378 childContainerStyle={[a.pt_sm]}> 379 <PostAlerts 380 modui={moderation.ui('contentView')} 381 size="lg" 382 includeMute 383 style={[a.pb_sm]} 384 additionalCauses={additionalPostAlerts} 385 /> 386 {richText?.text ? ( 387 <RichText 388 enableTags 389 selectable 390 value={richText} 391 style={[a.flex_1, a.text_xl]} 392 authorHandle={post.author.handle} 393 shouldProxyLinks={true} 394 /> 395 ) : undefined} 396 {post.embed && ( 397 <View style={[a.py_xs]}> 398 <Embed 399 embed={post.embed} 400 moderation={moderation} 401 viewContext={PostEmbedViewContext.ThreadHighlighted} 402 onOpen={onOpenEmbed} 403 /> 404 </View> 405 )} 406 </ContentHider> 407 <ExpandedPostDetails 408 post={item.value.post} 409 isThreadAuthor={isThreadAuthor} 410 /> 411 {post.repostCount !== 0 || 412 post.likeCount !== 0 || 413 post.quoteCount !== 0 ? ( 414 // Show this section unless we're *sure* it has no engagement. 415 <View 416 style={[ 417 a.flex_row, 418 a.align_center, 419 a.gap_lg, 420 a.border_t, 421 a.border_b, 422 a.mt_md, 423 a.py_md, 424 t.atoms.border_contrast_low, 425 ]}> 426 {post.repostCount != null && post.repostCount !== 0 ? ( 427 <Link to={repostsHref} label={_(msg`Reposts of this post`)}> 428 <Text 429 testID="repostCount-expanded" 430 style={[a.text_md, t.atoms.text_contrast_medium]}> 431 <Text style={[a.text_md, a.font_bold, t.atoms.text]}> 432 {formatCount(i18n, post.repostCount)} 433 </Text>{' '} 434 <Plural 435 value={post.repostCount} 436 one="repost" 437 other="reposts" 438 /> 439 </Text> 440 </Link> 441 ) : null} 442 {post.quoteCount != null && 443 post.quoteCount !== 0 && 444 !post.viewer?.embeddingDisabled ? ( 445 <Link to={quotesHref} label={_(msg`Quotes of this post`)}> 446 <Text 447 testID="quoteCount-expanded" 448 style={[a.text_md, t.atoms.text_contrast_medium]}> 449 <Text style={[a.text_md, a.font_bold, t.atoms.text]}> 450 {formatCount(i18n, post.quoteCount)} 451 </Text>{' '} 452 <Plural 453 value={post.quoteCount} 454 one="quote" 455 other="quotes" 456 /> 457 </Text> 458 </Link> 459 ) : null} 460 {post.likeCount != null && post.likeCount !== 0 ? ( 461 <Link to={likesHref} label={_(msg`Likes on this post`)}> 462 <Text 463 testID="likeCount-expanded" 464 style={[a.text_md, t.atoms.text_contrast_medium]}> 465 <Text style={[a.text_md, a.font_bold, t.atoms.text]}> 466 {formatCount(i18n, post.likeCount)} 467 </Text>{' '} 468 <Plural value={post.likeCount} one="like" other="likes" /> 469 </Text> 470 </Link> 471 ) : null} 472 </View> 473 ) : null} 474 <View 475 style={[ 476 a.pt_sm, 477 a.pb_2xs, 478 { 479 marginLeft: -5, 480 }, 481 ]}> 482 <FeedFeedbackProvider value={feedFeedback}> 483 <PostControls 484 big 485 post={postShadow} 486 record={record} 487 richText={richText} 488 onPressReply={onPressReply} 489 logContext="PostThreadItem" 490 threadgateRecord={threadgateRecord} 491 feedContext={postSource?.post?.feedContext} 492 reqId={postSource?.post?.reqId} 493 viaRepost={viaRepost} 494 /> 495 </FeedFeedbackProvider> 496 </View> 497 </View> 498 </View> 499 </> 500 ) 501}) 502 503function ExpandedPostDetails({ 504 post, 505 isThreadAuthor, 506}: { 507 post: Extract<ThreadItem, {type: 'threadPost'}>['value']['post'] 508 isThreadAuthor: boolean 509}) { 510 const t = useTheme() 511 const {_, i18n} = useLingui() 512 const openLink = useOpenLink() 513 const langPrefs = useLanguagePrefs() 514 515 const translatorUrl = getTranslatorLink( 516 post.record?.text || '', 517 langPrefs.primaryLanguage, 518 ) 519 const needsTranslation = useMemo( 520 () => 521 Boolean( 522 langPrefs.primaryLanguage && 523 !isPostInLanguage(post, [langPrefs.primaryLanguage]), 524 ), 525 [post, langPrefs.primaryLanguage], 526 ) 527 528 const onTranslatePress = useCallback( 529 (e: GestureResponderEvent) => { 530 e.preventDefault() 531 openLink(translatorUrl, true) 532 533 if ( 534 bsky.dangerousIsType<AppBskyFeedPost.Record>( 535 post.record, 536 AppBskyFeedPost.isRecord, 537 ) 538 ) { 539 logger.metric('translate', { 540 sourceLanguages: post.record.langs ?? [], 541 targetLanguage: langPrefs.primaryLanguage, 542 textLength: post.record.text.length, 543 }) 544 } 545 546 return false 547 }, 548 [openLink, translatorUrl, langPrefs, post], 549 ) 550 551 return ( 552 <View style={[a.gap_md, a.pt_md, a.align_start]}> 553 <BackdatedPostIndicator post={post} /> 554 <View style={[a.flex_row, a.align_center, a.flex_wrap, a.gap_sm]}> 555 <Text style={[a.text_sm, t.atoms.text_contrast_medium]}> 556 {niceDate(i18n, post.indexedAt)} 557 </Text> 558 <WhoCanReply post={post} isThreadAuthor={isThreadAuthor} /> 559 {needsTranslation && ( 560 <> 561 <Text style={[a.text_sm, t.atoms.text_contrast_medium]}> 562 &middot; 563 </Text> 564 565 <InlineLinkText 566 to={translatorUrl} 567 label={_(msg`Translate`)} 568 style={[a.text_sm]} 569 onPress={onTranslatePress}> 570 <Trans>Translate</Trans> 571 </InlineLinkText> 572 </> 573 )} 574 </View> 575 </View> 576 ) 577} 578 579function BackdatedPostIndicator({post}: {post: AppBskyFeedDefs.PostView}) { 580 const t = useTheme() 581 const {_, i18n} = useLingui() 582 const control = Prompt.usePromptControl() 583 584 const indexedAt = new Date(post.indexedAt) 585 const createdAt = bsky.dangerousIsType<AppBskyFeedPost.Record>( 586 post.record, 587 AppBskyFeedPost.isRecord, 588 ) 589 ? new Date(post.record.createdAt) 590 : new Date(post.indexedAt) 591 592 // backdated if createdAt is 24 hours or more before indexedAt 593 const isBackdated = 594 indexedAt.getTime() - createdAt.getTime() > 24 * 60 * 60 * 1000 595 596 if (!isBackdated) return null 597 598 const orange = t.name === 'light' ? colors.warning.dark : colors.warning.light 599 600 return ( 601 <> 602 <Button 603 label={_(msg`Archived post`)} 604 accessibilityHint={_( 605 msg`Shows information about when this post was created`, 606 )} 607 onPress={e => { 608 e.preventDefault() 609 e.stopPropagation() 610 control.open() 611 }}> 612 {({hovered, pressed}) => ( 613 <View 614 style={[ 615 a.flex_row, 616 a.align_center, 617 a.rounded_full, 618 t.atoms.bg_contrast_25, 619 (hovered || pressed) && t.atoms.bg_contrast_50, 620 { 621 gap: 3, 622 paddingHorizontal: 6, 623 paddingVertical: 3, 624 }, 625 ]}> 626 <CalendarClockIcon fill={orange} size="sm" aria-hidden /> 627 <Text 628 style={[ 629 a.text_xs, 630 a.font_bold, 631 a.leading_tight, 632 t.atoms.text_contrast_medium, 633 ]}> 634 <Trans>Archived from {niceDate(i18n, createdAt)}</Trans> 635 </Text> 636 </View> 637 )} 638 </Button> 639 640 <Prompt.Outer control={control}> 641 <Prompt.TitleText> 642 <Trans>Archived post</Trans> 643 </Prompt.TitleText> 644 <Prompt.DescriptionText> 645 <Trans> 646 This post claims to have been created on{' '} 647 <RNText style={[a.font_bold]}>{niceDate(i18n, createdAt)}</RNText>, 648 but was first seen by Bluesky on{' '} 649 <RNText style={[a.font_bold]}>{niceDate(i18n, indexedAt)}</RNText>. 650 </Trans> 651 </Prompt.DescriptionText> 652 <Text 653 style={[ 654 a.text_md, 655 a.leading_snug, 656 t.atoms.text_contrast_high, 657 a.pb_xl, 658 ]}> 659 <Trans> 660 Bluesky cannot confirm the authenticity of the claimed date. 661 </Trans> 662 </Text> 663 <Prompt.Actions> 664 <Prompt.Action cta={_(msg`Okay`)} onPress={() => {}} /> 665 </Prompt.Actions> 666 </Prompt.Outer> 667 </> 668 ) 669} 670 671function getThreadAuthor( 672 post: AppBskyFeedDefs.PostView, 673 record: AppBskyFeedPost.Record, 674): string { 675 if (!record.reply) { 676 return post.author.did 677 } 678 try { 679 return new AtUri(record.reply.root.uri).host 680 } catch { 681 return '' 682 } 683} 684 685export function ThreadItemAnchorSkeleton() { 686 return ( 687 <View style={[a.p_lg, a.gap_md]}> 688 <Skele.Row style={[a.align_center, a.gap_md]}> 689 <Skele.Circle size={42} /> 690 691 <Skele.Col> 692 <Skele.Text style={[a.text_lg, {width: '20%'}]} /> 693 <Skele.Text blend style={[a.text_md, {width: '40%'}]} /> 694 </Skele.Col> 695 </Skele.Row> 696 697 <View> 698 <Skele.Text style={[a.text_xl, {width: '100%'}]} /> 699 <Skele.Text style={[a.text_xl, {width: '60%'}]} /> 700 </View> 701 702 <Skele.Text style={[a.text_sm, {width: '50%'}]} /> 703 704 <Skele.Row style={[a.justify_between]}> 705 <Skele.Pill blend size={24} /> 706 <Skele.Pill blend size={24} /> 707 <Skele.Pill blend size={24} /> 708 <Skele.Circle blend size={24} /> 709 <Skele.Circle blend size={24} /> 710 </Skele.Row> 711 </View> 712 ) 713}