this repo has no description
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

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