Bluesky app fork with some witchin' additions 馃挮
0
fork

Configure Feed

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

at cb7e2ab976d9e2e8e2d13356b79bba7e6870a9fd 757 lines 24 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 {useOpenComposer} from '#/lib/hooks/useOpenComposer' 14import {useTranslate} from '#/lib/hooks/useTranslate' 15import {makeProfileLink} from '#/lib/routes/links' 16import {sanitizeDisplayName} from '#/lib/strings/display-names' 17import {sanitizeHandle} from '#/lib/strings/handles' 18import {niceDate} from '#/lib/strings/time' 19import {getTranslatorLink, isPostInLanguage} from '#/locale/helpers' 20import { 21 POST_TOMBSTONE, 22 type Shadow, 23 usePostShadow, 24} from '#/state/cache/post-shadow' 25import {useProfileShadow} from '#/state/cache/profile-shadow' 26import {FeedFeedbackProvider, useFeedFeedback} from '#/state/feed-feedback' 27import {useLanguagePrefs} from '#/state/preferences' 28import {type ThreadItem} from '#/state/queries/usePostThread/types' 29import {useSession} from '#/state/session' 30import {type OnPostSuccessData} from '#/state/shell/composer' 31import {useMergedThreadgateHiddenReplies} from '#/state/threadgate-hidden-replies' 32import {type PostSource} from '#/state/unstable-post-source' 33import {PreviewableUserAvatar} from '#/view/com/util/UserAvatar' 34import {ThreadItemAnchorFollowButton} from '#/screens/PostThread/components/ThreadItemAnchorFollowButton' 35import { 36 LINEAR_AVI_WIDTH, 37 OUTER_SPACE, 38 REPLY_LINE_WIDTH, 39} from '#/screens/PostThread/const' 40import {atoms as a, useTheme} from '#/alf' 41import {Button} from '#/components/Button' 42import {DebugFieldDisplay} from '#/components/DebugFieldDisplay' 43import {CalendarClock_Stroke2_Corner0_Rounded as CalendarClockIcon} from '#/components/icons/CalendarClock' 44import {Trash_Stroke2_Corner0_Rounded as TrashIcon} from '#/components/icons/Trash' 45import {InlineLinkText, Link} from '#/components/Link' 46import {ContentHider} from '#/components/moderation/ContentHider' 47import {LabelsOnMyPost} from '#/components/moderation/LabelsOnMe' 48import {PostAlerts} from '#/components/moderation/PostAlerts' 49import {type AppModerationCause} from '#/components/Pills' 50import {Embed, PostEmbedViewContext} from '#/components/Post/Embed' 51import {PostControls, PostControlsSkeleton} from '#/components/PostControls' 52import {useFormatPostStatCount} from '#/components/PostControls/util' 53import {ProfileHoverCard} from '#/components/ProfileHoverCard' 54import * as Prompt from '#/components/Prompt' 55import {RichText} from '#/components/RichText' 56import * as Skele from '#/components/Skeleton' 57import {Text} from '#/components/Typography' 58import {VerificationCheckButton} from '#/components/verification/VerificationCheckButton' 59import {WhoCanReply} from '#/components/WhoCanReply' 60import {useAnalytics} from '#/analytics' 61import {useActorStatus} from '#/features/liveNow' 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 132 style={[a.text_md, a.font_semi_bold, t.atoms.text_contrast_medium]}> 133 <Trans>Post has been deleted</Trans> 134 </Text> 135 </View> 136 </View> 137 </> 138 ) 139} 140 141function ThreadItemAnchorParentReplyLine({isRoot}: {isRoot: boolean}) { 142 const t = useTheme() 143 144 return !isRoot ? ( 145 <View style={[a.pl_lg, a.flex_row, a.pb_xs, {height: a.pt_lg.paddingTop}]}> 146 <View style={{width: 42}}> 147 <View 148 style={[ 149 { 150 width: REPLY_LINE_WIDTH, 151 marginLeft: 'auto', 152 marginRight: 'auto', 153 flexGrow: 1, 154 backgroundColor: t.atoms.border_contrast_low.borderColor, 155 }, 156 ]} 157 /> 158 </View> 159 </View> 160 ) : null 161} 162 163const ThreadItemAnchorInner = memo(function ThreadItemAnchorInner({ 164 item, 165 isRoot, 166 postShadow, 167 onPostSuccess, 168 threadgateRecord, 169 postSource, 170}: { 171 item: Extract<ThreadItem, {type: 'threadPost'}> 172 isRoot: boolean 173 postShadow: Shadow<AppBskyFeedDefs.PostView> 174 onPostSuccess?: (data: OnPostSuccessData) => void 175 threadgateRecord?: AppBskyFeedThreadgate.Record 176 postSource?: PostSource 177}) { 178 const t = useTheme() 179 const ax = useAnalytics() 180 const {_} = useLingui() 181 const {openComposer} = useOpenComposer() 182 const {currentAccount, hasSession} = useSession() 183 const feedFeedback = useFeedFeedback(postSource?.feedSourceInfo, hasSession) 184 const formatPostStatCount = useFormatPostStatCount() 185 186 const post = postShadow 187 const record = item.value.post.record 188 const moderation = item.moderation 189 const authorShadow = useProfileShadow(post.author) 190 const {isActive: live} = useActorStatus(post.author) 191 const richText = useMemo( 192 () => 193 new RichTextAPI({ 194 text: record.text, 195 facets: record.facets, 196 }), 197 [record], 198 ) 199 200 const threadRootUri = record.reply?.root?.uri || post.uri 201 const authorHref = makeProfileLink(post.author) 202 const isThreadAuthor = getThreadAuthor(post, record) === currentAccount?.did 203 204 const likesHref = useMemo(() => { 205 const urip = new AtUri(post.uri) 206 return makeProfileLink(post.author, 'post', urip.rkey, 'liked-by') 207 }, [post.uri, post.author]) 208 const repostsHref = useMemo(() => { 209 const urip = new AtUri(post.uri) 210 return makeProfileLink(post.author, 'post', urip.rkey, 'reposted-by') 211 }, [post.uri, post.author]) 212 const quotesHref = useMemo(() => { 213 const urip = new AtUri(post.uri) 214 return makeProfileLink(post.author, 'post', urip.rkey, 'quotes') 215 }, [post.uri, post.author]) 216 217 const threadgateHiddenReplies = useMergedThreadgateHiddenReplies({ 218 threadgateRecord, 219 }) 220 const additionalPostAlerts: AppModerationCause[] = useMemo(() => { 221 const isPostHiddenByThreadgate = threadgateHiddenReplies.has(post.uri) 222 const isControlledByViewer = 223 new AtUri(threadRootUri).host === currentAccount?.did 224 return isControlledByViewer && isPostHiddenByThreadgate 225 ? [ 226 { 227 type: 'reply-hidden', 228 source: {type: 'user', did: currentAccount?.did}, 229 priority: 6, 230 }, 231 ] 232 : [] 233 }, [post, currentAccount?.did, threadgateHiddenReplies, threadRootUri]) 234 const onlyFollowersCanReply = !!threadgateRecord?.allow?.find( 235 rule => rule.$type === 'app.bsky.feed.threadgate#followerRule', 236 ) 237 const showFollowButton = 238 currentAccount?.did !== post.author.did && !onlyFollowersCanReply 239 240 const viaRepost = useMemo(() => { 241 const reason = postSource?.post.reason 242 243 if (AppBskyFeedDefs.isReasonRepost(reason) && reason.uri && reason.cid) { 244 return { 245 uri: reason.uri, 246 cid: reason.cid, 247 } 248 } 249 }, [postSource]) 250 251 const onPressReply = useCallback(() => { 252 openComposer({ 253 replyTo: { 254 uri: post.uri, 255 cid: post.cid, 256 text: record.text, 257 author: post.author, 258 embed: post.embed, 259 moderation, 260 langs: record.langs, 261 }, 262 onPostSuccess: onPostSuccess, 263 logContext: 'PostReply', 264 }) 265 266 if (postSource) { 267 feedFeedback.sendInteraction({ 268 item: post.uri, 269 event: 'app.bsky.feed.defs#interactionReply', 270 feedContext: postSource.post.feedContext, 271 reqId: postSource.post.reqId, 272 }) 273 } 274 }, [ 275 openComposer, 276 post, 277 record, 278 onPostSuccess, 279 moderation, 280 postSource, 281 feedFeedback, 282 ]) 283 284 const onOpenAuthor = () => { 285 ax.metric('post:clickthroughAuthor', { 286 uri: post.uri, 287 authorDid: post.author.did, 288 logContext: 'PostThreadItem', 289 feedDescriptor: feedFeedback.feedDescriptor, 290 }) 291 if (postSource) { 292 feedFeedback.sendInteraction({ 293 item: post.uri, 294 event: 'app.bsky.feed.defs#clickthroughAuthor', 295 feedContext: postSource.post.feedContext, 296 reqId: postSource.post.reqId, 297 }) 298 } 299 } 300 301 const onOpenEmbed = () => { 302 ax.metric('post:clickthroughEmbed', { 303 uri: post.uri, 304 authorDid: post.author.did, 305 logContext: 'PostThreadItem', 306 feedDescriptor: feedFeedback.feedDescriptor, 307 }) 308 if (postSource) { 309 feedFeedback.sendInteraction({ 310 item: post.uri, 311 event: 'app.bsky.feed.defs#clickthroughEmbed', 312 feedContext: postSource.post.feedContext, 313 reqId: postSource.post.reqId, 314 }) 315 } 316 } 317 318 return ( 319 <> 320 <ThreadItemAnchorParentReplyLine isRoot={isRoot} /> 321 322 <View 323 testID={`postThreadItem-by-${post.author.handle}`} 324 style={[ 325 { 326 paddingHorizontal: OUTER_SPACE, 327 }, 328 isRoot && [a.pt_lg], 329 ]}> 330 <View style={[a.flex_row, a.gap_md, a.pb_md]}> 331 <View collapsable={false}> 332 <PreviewableUserAvatar 333 size={42} 334 profile={post.author} 335 moderation={moderation.ui('avatar')} 336 type={post.author.associated?.labeler ? 'labeler' : 'user'} 337 live={live} 338 onBeforePress={onOpenAuthor} 339 /> 340 </View> 341 <Link 342 to={authorHref} 343 style={[a.flex_1]} 344 label={sanitizeDisplayName( 345 post.author.displayName || sanitizeHandle(post.author.handle), 346 moderation.ui('displayName'), 347 )} 348 onPress={onOpenAuthor}> 349 <View style={[a.flex_1, a.align_start]}> 350 <ProfileHoverCard did={post.author.did} style={[a.w_full]}> 351 <View style={[a.flex_row, a.align_center]}> 352 <Text 353 emoji 354 style={[ 355 a.flex_shrink, 356 a.text_lg, 357 a.font_semi_bold, 358 a.leading_snug, 359 ]} 360 numberOfLines={1}> 361 {sanitizeDisplayName( 362 post.author.displayName || 363 sanitizeHandle(post.author.handle), 364 moderation.ui('displayName'), 365 )} 366 </Text> 367 368 <View style={[a.pl_xs]}> 369 <VerificationCheckButton profile={authorShadow} size="md" /> 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 {post.embed && ( 415 <View style={[a.py_xs]}> 416 <Embed 417 embed={post.embed} 418 moderation={moderation} 419 viewContext={PostEmbedViewContext.ThreadHighlighted} 420 onOpen={onOpenEmbed} 421 /> 422 </View> 423 )} 424 </ContentHider> 425 <ExpandedPostDetails 426 post={item.value.post} 427 isThreadAuthor={isThreadAuthor} 428 /> 429 {post.repostCount !== 0 || 430 post.likeCount !== 0 || 431 post.quoteCount !== 0 || 432 post.bookmarkCount !== 0 ? ( 433 // Show this section unless we're *sure* it has no engagement. 434 <View 435 style={[ 436 a.flex_row, 437 a.flex_wrap, 438 a.align_center, 439 { 440 rowGap: a.gap_sm.gap, 441 columnGap: a.gap_lg.gap, 442 }, 443 a.border_t, 444 a.border_b, 445 a.mt_md, 446 a.py_md, 447 t.atoms.border_contrast_low, 448 ]}> 449 {post.repostCount != null && post.repostCount !== 0 ? ( 450 <Link to={repostsHref} label={_(msg`Reposts of this post`)}> 451 <Text 452 testID="repostCount-expanded" 453 style={[a.text_md, t.atoms.text_contrast_medium]}> 454 <Trans comment="Repost count display, the <0> tags enclose the number of reposts in bold (will never be 0)"> 455 <Text style={[a.text_md, a.font_semi_bold, t.atoms.text]}> 456 {formatPostStatCount(post.repostCount)} 457 </Text>{' '} 458 <Plural 459 value={post.repostCount} 460 one="repost" 461 other="reposts" 462 /> 463 </Trans> 464 </Text> 465 </Link> 466 ) : null} 467 {post.quoteCount != null && 468 post.quoteCount !== 0 && 469 !post.viewer?.embeddingDisabled ? ( 470 <Link to={quotesHref} label={_(msg`Quotes of this post`)}> 471 <Text 472 testID="quoteCount-expanded" 473 style={[a.text_md, t.atoms.text_contrast_medium]}> 474 <Trans comment="Quote count display, the <0> tags enclose the number of quotes in bold (will never be 0)"> 475 <Text style={[a.text_md, a.font_semi_bold, t.atoms.text]}> 476 {formatPostStatCount(post.quoteCount)} 477 </Text>{' '} 478 <Plural 479 value={post.quoteCount} 480 one="quote" 481 other="quotes" 482 /> 483 </Trans> 484 </Text> 485 </Link> 486 ) : null} 487 {post.likeCount != null && post.likeCount !== 0 ? ( 488 <Link to={likesHref} label={_(msg`Likes on this post`)}> 489 <Text 490 testID="likeCount-expanded" 491 style={[a.text_md, t.atoms.text_contrast_medium]}> 492 <Trans comment="Like count display, the <0> tags enclose the number of likes in bold (will never be 0)"> 493 <Text style={[a.text_md, a.font_semi_bold, t.atoms.text]}> 494 {formatPostStatCount(post.likeCount)} 495 </Text>{' '} 496 <Plural value={post.likeCount} one="like" other="likes" /> 497 </Trans> 498 </Text> 499 </Link> 500 ) : null} 501 {post.bookmarkCount != null && post.bookmarkCount !== 0 ? ( 502 <Text 503 testID="bookmarkCount-expanded" 504 style={[a.text_md, t.atoms.text_contrast_medium]}> 505 <Trans comment="Save count display, the <0> tags enclose the number of saves in bold (will never be 0)"> 506 <Text style={[a.text_md, a.font_semi_bold, t.atoms.text]}> 507 {formatPostStatCount(post.bookmarkCount)} 508 </Text>{' '} 509 <Plural 510 value={post.bookmarkCount} 511 one="save" 512 other="saves" 513 /> 514 </Trans> 515 </Text> 516 ) : null} 517 </View> 518 ) : null} 519 <View 520 style={[ 521 a.pt_sm, 522 a.pb_2xs, 523 { 524 marginLeft: -5, 525 }, 526 ]}> 527 <FeedFeedbackProvider value={feedFeedback}> 528 <PostControls 529 big 530 post={postShadow} 531 record={record} 532 richText={richText} 533 onPressReply={onPressReply} 534 logContext="PostThreadItem" 535 threadgateRecord={threadgateRecord} 536 feedContext={postSource?.post?.feedContext} 537 reqId={postSource?.post?.reqId} 538 viaRepost={viaRepost} 539 /> 540 </FeedFeedbackProvider> 541 </View> 542 <DebugFieldDisplay subject={post} /> 543 </View> 544 </View> 545 </> 546 ) 547}) 548 549function ExpandedPostDetails({ 550 post, 551 isThreadAuthor, 552}: { 553 post: Extract<ThreadItem, {type: 'threadPost'}>['value']['post'] 554 isThreadAuthor: boolean 555}) { 556 const t = useTheme() 557 const ax = useAnalytics() 558 const {_, i18n} = useLingui() 559 const translate = useTranslate() 560 const isRootPost = !('reply' in post.record) 561 const langPrefs = useLanguagePrefs() 562 563 const needsTranslation = useMemo( 564 () => 565 Boolean( 566 langPrefs.primaryLanguage && 567 !isPostInLanguage(post, [langPrefs.primaryLanguage]), 568 ), 569 [post, langPrefs.primaryLanguage], 570 ) 571 572 const onTranslatePress = useCallback( 573 (e: GestureResponderEvent) => { 574 e.preventDefault() 575 translate(post.record.text || '', langPrefs.primaryLanguage) 576 577 if ( 578 bsky.dangerousIsType<AppBskyFeedPost.Record>( 579 post.record, 580 AppBskyFeedPost.isRecord, 581 ) 582 ) { 583 ax.metric('translate', { 584 sourceLanguages: post.record.langs ?? [], 585 targetLanguage: langPrefs.primaryLanguage, 586 textLength: post.record.text.length, 587 }) 588 } 589 590 return false 591 }, 592 [ax, translate, langPrefs, post], 593 ) 594 595 return ( 596 <View style={[a.gap_md, a.pt_md, a.align_start]}> 597 <BackdatedPostIndicator post={post} /> 598 <View style={[a.flex_row, a.align_center, a.flex_wrap, a.gap_sm]}> 599 <Text style={[a.text_sm, t.atoms.text_contrast_medium]}> 600 {niceDate(i18n, post.indexedAt, 'dot separated')} 601 </Text> 602 {isRootPost && ( 603 <WhoCanReply post={post} isThreadAuthor={isThreadAuthor} /> 604 )} 605 {needsTranslation && ( 606 <> 607 <Text style={[a.text_sm, t.atoms.text_contrast_medium]}> 608 &middot; 609 </Text> 610 611 <InlineLinkText 612 // overridden to open an intent on android, but keep 613 // as anchor tag for accessibility 614 to={getTranslatorLink( 615 post.record.text, 616 langPrefs.primaryLanguage, 617 )} 618 label={_(msg`Translate`)} 619 style={[a.text_sm]} 620 onPress={onTranslatePress}> 621 <Trans>Translate</Trans> 622 </InlineLinkText> 623 </> 624 )} 625 </View> 626 </View> 627 ) 628} 629 630function BackdatedPostIndicator({post}: {post: AppBskyFeedDefs.PostView}) { 631 const t = useTheme() 632 const {_, i18n} = useLingui() 633 const control = Prompt.usePromptControl() 634 635 const indexedAt = new Date(post.indexedAt) 636 const createdAt = bsky.dangerousIsType<AppBskyFeedPost.Record>( 637 post.record, 638 AppBskyFeedPost.isRecord, 639 ) 640 ? new Date(post.record.createdAt) 641 : new Date(post.indexedAt) 642 643 // backdated if createdAt is 24 hours or more before indexedAt 644 const isBackdated = 645 indexedAt.getTime() - createdAt.getTime() > 24 * 60 * 60 * 1000 646 647 if (!isBackdated) return null 648 649 return ( 650 <> 651 <Button 652 label={_(msg`Archived post`)} 653 accessibilityHint={_( 654 msg`Shows information about when this post was created`, 655 )} 656 onPress={e => { 657 e.preventDefault() 658 e.stopPropagation() 659 control.open() 660 }}> 661 {({hovered, pressed}) => ( 662 <View 663 style={[ 664 a.flex_row, 665 a.align_center, 666 a.rounded_full, 667 t.atoms.bg_contrast_25, 668 (hovered || pressed) && t.atoms.bg_contrast_50, 669 { 670 gap: 3, 671 paddingHorizontal: 6, 672 paddingVertical: 3, 673 }, 674 ]}> 675 <CalendarClockIcon fill={t.palette.yellow} size="sm" aria-hidden /> 676 <Text 677 style={[ 678 a.text_xs, 679 a.font_semi_bold, 680 a.leading_tight, 681 t.atoms.text_contrast_medium, 682 ]}> 683 <Trans>Archived from {niceDate(i18n, createdAt, 'medium')}</Trans> 684 </Text> 685 </View> 686 )} 687 </Button> 688 689 <Prompt.Outer control={control}> 690 <Prompt.Content> 691 <Prompt.TitleText> 692 <Trans>Archived post</Trans> 693 </Prompt.TitleText> 694 <Prompt.DescriptionText> 695 <Trans> 696 This post claims to have been created on{' '} 697 <RNText style={[a.font_semi_bold]}> 698 {niceDate(i18n, createdAt)} 699 </RNText> 700 , but was first seen by Bluesky on{' '} 701 <RNText style={[a.font_semi_bold]}> 702 {niceDate(i18n, indexedAt)} 703 </RNText> 704 . 705 </Trans> 706 </Prompt.DescriptionText> 707 <Prompt.DescriptionText> 708 <Trans> 709 Bluesky cannot confirm the authenticity of the claimed date. 710 </Trans> 711 </Prompt.DescriptionText> 712 </Prompt.Content> 713 <Prompt.Actions> 714 <Prompt.Action cta={_(msg`Okay`)} onPress={() => {}} /> 715 </Prompt.Actions> 716 </Prompt.Outer> 717 </> 718 ) 719} 720 721function getThreadAuthor( 722 post: AppBskyFeedDefs.PostView, 723 record: AppBskyFeedPost.Record, 724): string { 725 if (!record.reply) { 726 return post.author.did 727 } 728 try { 729 return new AtUri(record.reply.root.uri).host 730 } catch { 731 return '' 732 } 733} 734 735export function ThreadItemAnchorSkeleton() { 736 return ( 737 <View style={[a.p_lg, a.gap_md]}> 738 <Skele.Row style={[a.align_center, a.gap_md]}> 739 <Skele.Circle size={42} /> 740 741 <Skele.Col> 742 <Skele.Text style={[a.text_lg, {width: '20%'}]} /> 743 <Skele.Text blend style={[a.text_md, {width: '40%'}]} /> 744 </Skele.Col> 745 </Skele.Row> 746 747 <View> 748 <Skele.Text style={[a.text_xl, {width: '100%'}]} /> 749 <Skele.Text style={[a.text_xl, {width: '60%'}]} /> 750 </View> 751 752 <Skele.Text style={[a.text_sm, {width: '50%'}]} /> 753 754 <PostControlsSkeleton big /> 755 </View> 756 ) 757}