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

Configure Feed

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

at c906fea77adb2daad28a521f06e68d5bbc4bce4d 760 lines 25 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 {colors} from '#/components/Admonition' 42import {Button} from '#/components/Button' 43import {DebugFieldDisplay} from '#/components/DebugFieldDisplay' 44import {CalendarClock_Stroke2_Corner0_Rounded as CalendarClockIcon} from '#/components/icons/CalendarClock' 45import {Trash_Stroke2_Corner0_Rounded as TrashIcon} from '#/components/icons/Trash' 46import {InlineLinkText, Link} from '#/components/Link' 47import {ContentHider} from '#/components/moderation/ContentHider' 48import {LabelsOnMyPost} from '#/components/moderation/LabelsOnMe' 49import {PostAlerts} from '#/components/moderation/PostAlerts' 50import {type AppModerationCause} from '#/components/Pills' 51import {Embed, PostEmbedViewContext} from '#/components/Post/Embed' 52import {PostControls, PostControlsSkeleton} from '#/components/PostControls' 53import {useFormatPostStatCount} from '#/components/PostControls/util' 54import {ProfileHoverCard} from '#/components/ProfileHoverCard' 55import * as Prompt from '#/components/Prompt' 56import {RichText} from '#/components/RichText' 57import * as Skele from '#/components/Skeleton' 58import {Text} from '#/components/Typography' 59import {VerificationCheckButton} from '#/components/verification/VerificationCheckButton' 60import {WhoCanReply} from '#/components/WhoCanReply' 61import {useAnalytics} from '#/analytics' 62import {useActorStatus} from '#/features/liveNow' 63import * as bsky from '#/types/bsky' 64 65export function ThreadItemAnchor({ 66 item, 67 onPostSuccess, 68 threadgateRecord, 69 postSource, 70}: { 71 item: Extract<ThreadItem, {type: 'threadPost'}> 72 onPostSuccess?: (data: OnPostSuccessData) => void 73 threadgateRecord?: AppBskyFeedThreadgate.Record 74 postSource?: PostSource 75}) { 76 const postShadow = usePostShadow(item.value.post) 77 const threadRootUri = item.value.post.record.reply?.root?.uri || item.uri 78 const isRoot = threadRootUri === item.uri 79 80 if (postShadow === POST_TOMBSTONE) { 81 return <ThreadItemAnchorDeleted isRoot={isRoot} /> 82 } 83 84 return ( 85 <ThreadItemAnchorInner 86 // Safeguard from clobbering per-post state below: 87 key={postShadow.uri} 88 item={item} 89 isRoot={isRoot} 90 postShadow={postShadow} 91 onPostSuccess={onPostSuccess} 92 threadgateRecord={threadgateRecord} 93 postSource={postSource} 94 /> 95 ) 96} 97 98function ThreadItemAnchorDeleted({isRoot}: {isRoot: boolean}) { 99 const t = useTheme() 100 101 return ( 102 <> 103 <ThreadItemAnchorParentReplyLine isRoot={isRoot} /> 104 105 <View 106 style={[ 107 { 108 paddingHorizontal: OUTER_SPACE, 109 paddingBottom: OUTER_SPACE, 110 }, 111 isRoot && [a.pt_lg], 112 ]}> 113 <View 114 style={[ 115 a.flex_row, 116 a.align_center, 117 a.py_md, 118 a.rounded_sm, 119 t.atoms.bg_contrast_25, 120 ]}> 121 <View 122 style={[ 123 a.flex_row, 124 a.align_center, 125 a.justify_center, 126 { 127 width: LINEAR_AVI_WIDTH, 128 }, 129 ]}> 130 <TrashIcon style={[t.atoms.text_contrast_medium]} /> 131 </View> 132 <Text 133 style={[a.text_md, a.font_semi_bold, t.atoms.text_contrast_medium]}> 134 <Trans>Post has been deleted</Trans> 135 </Text> 136 </View> 137 </View> 138 </> 139 ) 140} 141 142function ThreadItemAnchorParentReplyLine({isRoot}: {isRoot: boolean}) { 143 const t = useTheme() 144 145 return !isRoot ? ( 146 <View style={[a.pl_lg, a.flex_row, a.pb_xs, {height: a.pt_lg.paddingTop}]}> 147 <View style={{width: 42}}> 148 <View 149 style={[ 150 { 151 width: REPLY_LINE_WIDTH, 152 marginLeft: 'auto', 153 marginRight: 'auto', 154 flexGrow: 1, 155 backgroundColor: t.atoms.border_contrast_low.borderColor, 156 }, 157 ]} 158 /> 159 </View> 160 </View> 161 ) : null 162} 163 164const ThreadItemAnchorInner = memo(function ThreadItemAnchorInner({ 165 item, 166 isRoot, 167 postShadow, 168 onPostSuccess, 169 threadgateRecord, 170 postSource, 171}: { 172 item: Extract<ThreadItem, {type: 'threadPost'}> 173 isRoot: boolean 174 postShadow: Shadow<AppBskyFeedDefs.PostView> 175 onPostSuccess?: (data: OnPostSuccessData) => void 176 threadgateRecord?: AppBskyFeedThreadgate.Record 177 postSource?: PostSource 178}) { 179 const t = useTheme() 180 const ax = useAnalytics() 181 const {_} = useLingui() 182 const {openComposer} = useOpenComposer() 183 const {currentAccount, hasSession} = useSession() 184 const feedFeedback = useFeedFeedback(postSource?.feedSourceInfo, hasSession) 185 const formatPostStatCount = useFormatPostStatCount() 186 187 const post = postShadow 188 const record = item.value.post.record 189 const moderation = item.moderation 190 const authorShadow = useProfileShadow(post.author) 191 const {isActive: live} = useActorStatus(post.author) 192 const richText = useMemo( 193 () => 194 new RichTextAPI({ 195 text: record.text, 196 facets: record.facets, 197 }), 198 [record], 199 ) 200 201 const threadRootUri = record.reply?.root?.uri || post.uri 202 const authorHref = makeProfileLink(post.author) 203 const isThreadAuthor = getThreadAuthor(post, record) === currentAccount?.did 204 205 const likesHref = useMemo(() => { 206 const urip = new AtUri(post.uri) 207 return makeProfileLink(post.author, 'post', urip.rkey, 'liked-by') 208 }, [post.uri, post.author]) 209 const repostsHref = useMemo(() => { 210 const urip = new AtUri(post.uri) 211 return makeProfileLink(post.author, 'post', urip.rkey, 'reposted-by') 212 }, [post.uri, post.author]) 213 const quotesHref = useMemo(() => { 214 const urip = new AtUri(post.uri) 215 return makeProfileLink(post.author, 'post', urip.rkey, 'quotes') 216 }, [post.uri, post.author]) 217 218 const threadgateHiddenReplies = useMergedThreadgateHiddenReplies({ 219 threadgateRecord, 220 }) 221 const additionalPostAlerts: AppModerationCause[] = useMemo(() => { 222 const isPostHiddenByThreadgate = threadgateHiddenReplies.has(post.uri) 223 const isControlledByViewer = 224 new AtUri(threadRootUri).host === currentAccount?.did 225 return isControlledByViewer && isPostHiddenByThreadgate 226 ? [ 227 { 228 type: 'reply-hidden', 229 source: {type: 'user', did: currentAccount?.did}, 230 priority: 6, 231 }, 232 ] 233 : [] 234 }, [post, currentAccount?.did, threadgateHiddenReplies, threadRootUri]) 235 const onlyFollowersCanReply = !!threadgateRecord?.allow?.find( 236 rule => rule.$type === 'app.bsky.feed.threadgate#followerRule', 237 ) 238 const showFollowButton = 239 currentAccount?.did !== post.author.did && !onlyFollowersCanReply 240 241 const viaRepost = useMemo(() => { 242 const reason = postSource?.post.reason 243 244 if (AppBskyFeedDefs.isReasonRepost(reason) && reason.uri && reason.cid) { 245 return { 246 uri: reason.uri, 247 cid: reason.cid, 248 } 249 } 250 }, [postSource]) 251 252 const onPressReply = useCallback(() => { 253 openComposer({ 254 replyTo: { 255 uri: post.uri, 256 cid: post.cid, 257 text: record.text, 258 author: post.author, 259 embed: post.embed, 260 moderation, 261 langs: record.langs, 262 }, 263 onPostSuccess: onPostSuccess, 264 logContext: 'PostReply', 265 }) 266 267 if (postSource) { 268 feedFeedback.sendInteraction({ 269 item: post.uri, 270 event: 'app.bsky.feed.defs#interactionReply', 271 feedContext: postSource.post.feedContext, 272 reqId: postSource.post.reqId, 273 }) 274 } 275 }, [ 276 openComposer, 277 post, 278 record, 279 onPostSuccess, 280 moderation, 281 postSource, 282 feedFeedback, 283 ]) 284 285 const onOpenAuthor = () => { 286 ax.metric('post:clickthroughAuthor', { 287 uri: post.uri, 288 authorDid: post.author.did, 289 logContext: 'PostThreadItem', 290 feedDescriptor: feedFeedback.feedDescriptor, 291 }) 292 if (postSource) { 293 feedFeedback.sendInteraction({ 294 item: post.uri, 295 event: 'app.bsky.feed.defs#clickthroughAuthor', 296 feedContext: postSource.post.feedContext, 297 reqId: postSource.post.reqId, 298 }) 299 } 300 } 301 302 const onOpenEmbed = () => { 303 ax.metric('post:clickthroughEmbed', { 304 uri: post.uri, 305 authorDid: post.author.did, 306 logContext: 'PostThreadItem', 307 feedDescriptor: feedFeedback.feedDescriptor, 308 }) 309 if (postSource) { 310 feedFeedback.sendInteraction({ 311 item: post.uri, 312 event: 'app.bsky.feed.defs#clickthroughEmbed', 313 feedContext: postSource.post.feedContext, 314 reqId: postSource.post.reqId, 315 }) 316 } 317 } 318 319 return ( 320 <> 321 <ThreadItemAnchorParentReplyLine isRoot={isRoot} /> 322 323 <View 324 testID={`postThreadItem-by-${post.author.handle}`} 325 style={[ 326 { 327 paddingHorizontal: OUTER_SPACE, 328 }, 329 isRoot && [a.pt_lg], 330 ]}> 331 <View style={[a.flex_row, a.gap_md, a.pb_md]}> 332 <View collapsable={false}> 333 <PreviewableUserAvatar 334 size={42} 335 profile={post.author} 336 moderation={moderation.ui('avatar')} 337 type={post.author.associated?.labeler ? 'labeler' : 'user'} 338 live={live} 339 onBeforePress={onOpenAuthor} 340 /> 341 </View> 342 <Link 343 to={authorHref} 344 style={[a.flex_1]} 345 label={sanitizeDisplayName( 346 post.author.displayName || sanitizeHandle(post.author.handle), 347 moderation.ui('displayName'), 348 )} 349 onPress={onOpenAuthor}> 350 <View style={[a.flex_1, a.align_start]}> 351 <ProfileHoverCard did={post.author.did} style={[a.w_full]}> 352 <View style={[a.flex_row, a.align_center]}> 353 <Text 354 emoji 355 style={[ 356 a.flex_shrink, 357 a.text_lg, 358 a.font_semi_bold, 359 a.leading_snug, 360 ]} 361 numberOfLines={1}> 362 {sanitizeDisplayName( 363 post.author.displayName || 364 sanitizeHandle(post.author.handle), 365 moderation.ui('displayName'), 366 )} 367 </Text> 368 369 <View style={[a.pl_xs]}> 370 <VerificationCheckButton profile={authorShadow} size="md" /> 371 </View> 372 </View> 373 <Text 374 style={[ 375 a.text_md, 376 a.leading_snug, 377 t.atoms.text_contrast_medium, 378 ]} 379 numberOfLines={1}> 380 {sanitizeHandle(post.author.handle, '@')} 381 </Text> 382 </ProfileHoverCard> 383 </View> 384 </Link> 385 <View collapsable={false} style={[a.self_center]}> 386 <ThreadItemAnchorFollowButton 387 did={post.author.did} 388 enabled={showFollowButton} 389 /> 390 </View> 391 </View> 392 <View style={[a.pb_sm]}> 393 <LabelsOnMyPost post={post} style={[a.pb_sm]} /> 394 <ContentHider 395 modui={moderation.ui('contentView')} 396 ignoreMute 397 childContainerStyle={[a.pt_sm]}> 398 <PostAlerts 399 modui={moderation.ui('contentView')} 400 size="lg" 401 includeMute 402 style={[a.pb_sm]} 403 additionalCauses={additionalPostAlerts} 404 /> 405 {richText?.text ? ( 406 <RichText 407 enableTags 408 selectable 409 value={richText} 410 style={[a.flex_1, a.text_lg]} 411 authorHandle={post.author.handle} 412 shouldProxyLinks={true} 413 /> 414 ) : undefined} 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={_(msg`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={_(msg`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={_(msg`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 ax = useAnalytics() 559 const {_, i18n} = useLingui() 560 const translate = useTranslate() 561 const isRootPost = !('reply' in post.record) 562 const langPrefs = useLanguagePrefs() 563 564 const needsTranslation = useMemo( 565 () => 566 Boolean( 567 langPrefs.primaryLanguage && 568 !isPostInLanguage(post, [langPrefs.primaryLanguage]), 569 ), 570 [post, langPrefs.primaryLanguage], 571 ) 572 573 const onTranslatePress = useCallback( 574 (e: GestureResponderEvent) => { 575 e.preventDefault() 576 translate(post.record.text || '', langPrefs.primaryLanguage) 577 578 if ( 579 bsky.dangerousIsType<AppBskyFeedPost.Record>( 580 post.record, 581 AppBskyFeedPost.isRecord, 582 ) 583 ) { 584 ax.metric('translate', { 585 sourceLanguages: post.record.langs ?? [], 586 targetLanguage: langPrefs.primaryLanguage, 587 textLength: post.record.text.length, 588 }) 589 } 590 591 return false 592 }, 593 [ax, translate, langPrefs, post], 594 ) 595 596 return ( 597 <View style={[a.gap_md, a.pt_md, a.align_start]}> 598 <BackdatedPostIndicator post={post} /> 599 <View style={[a.flex_row, a.align_center, a.flex_wrap, a.gap_sm]}> 600 <Text style={[a.text_sm, t.atoms.text_contrast_medium]}> 601 {niceDate(i18n, post.indexedAt, 'dot separated')} 602 </Text> 603 {isRootPost && ( 604 <WhoCanReply post={post} isThreadAuthor={isThreadAuthor} /> 605 )} 606 {needsTranslation && ( 607 <> 608 <Text style={[a.text_sm, t.atoms.text_contrast_medium]}> 609 &middot; 610 </Text> 611 612 <InlineLinkText 613 // overridden to open an intent on android, but keep 614 // as anchor tag for accessibility 615 to={getTranslatorLink( 616 post.record.text, 617 langPrefs.primaryLanguage, 618 )} 619 label={_(msg`Translate`)} 620 style={[a.text_sm]} 621 onPress={onTranslatePress}> 622 <Trans>Translate</Trans> 623 </InlineLinkText> 624 </> 625 )} 626 </View> 627 </View> 628 ) 629} 630 631function BackdatedPostIndicator({post}: {post: AppBskyFeedDefs.PostView}) { 632 const t = useTheme() 633 const {_, i18n} = useLingui() 634 const control = Prompt.usePromptControl() 635 636 const indexedAt = new Date(post.indexedAt) 637 const createdAt = bsky.dangerousIsType<AppBskyFeedPost.Record>( 638 post.record, 639 AppBskyFeedPost.isRecord, 640 ) 641 ? new Date(post.record.createdAt) 642 : new Date(post.indexedAt) 643 644 // backdated if createdAt is 24 hours or more before indexedAt 645 const isBackdated = 646 indexedAt.getTime() - createdAt.getTime() > 24 * 60 * 60 * 1000 647 648 if (!isBackdated) return null 649 650 const orange = colors.warning 651 652 return ( 653 <> 654 <Button 655 label={_(msg`Archived post`)} 656 accessibilityHint={_( 657 msg`Shows information about when this post was created`, 658 )} 659 onPress={e => { 660 e.preventDefault() 661 e.stopPropagation() 662 control.open() 663 }}> 664 {({hovered, pressed}) => ( 665 <View 666 style={[ 667 a.flex_row, 668 a.align_center, 669 a.rounded_full, 670 t.atoms.bg_contrast_25, 671 (hovered || pressed) && t.atoms.bg_contrast_50, 672 { 673 gap: 3, 674 paddingHorizontal: 6, 675 paddingVertical: 3, 676 }, 677 ]}> 678 <CalendarClockIcon fill={orange} size="sm" aria-hidden /> 679 <Text 680 style={[ 681 a.text_xs, 682 a.font_semi_bold, 683 a.leading_tight, 684 t.atoms.text_contrast_medium, 685 ]}> 686 <Trans>Archived from {niceDate(i18n, createdAt, 'medium')}</Trans> 687 </Text> 688 </View> 689 )} 690 </Button> 691 692 <Prompt.Outer control={control}> 693 <Prompt.Content> 694 <Prompt.TitleText> 695 <Trans>Archived post</Trans> 696 </Prompt.TitleText> 697 <Prompt.DescriptionText> 698 <Trans> 699 This post claims to have been created on{' '} 700 <RNText style={[a.font_semi_bold]}> 701 {niceDate(i18n, createdAt)} 702 </RNText> 703 , but was first seen by Bluesky on{' '} 704 <RNText style={[a.font_semi_bold]}> 705 {niceDate(i18n, indexedAt)} 706 </RNText> 707 . 708 </Trans> 709 </Prompt.DescriptionText> 710 <Prompt.DescriptionText> 711 <Trans> 712 Bluesky cannot confirm the authenticity of the claimed date. 713 </Trans> 714 </Prompt.DescriptionText> 715 </Prompt.Content> 716 <Prompt.Actions> 717 <Prompt.Action cta={_(msg`Okay`)} onPress={() => {}} /> 718 </Prompt.Actions> 719 </Prompt.Outer> 720 </> 721 ) 722} 723 724function getThreadAuthor( 725 post: AppBskyFeedDefs.PostView, 726 record: AppBskyFeedPost.Record, 727): string { 728 if (!record.reply) { 729 return post.author.did 730 } 731 try { 732 return new AtUri(record.reply.root.uri).host 733 } catch { 734 return '' 735 } 736} 737 738export function ThreadItemAnchorSkeleton() { 739 return ( 740 <View style={[a.p_lg, a.gap_md]}> 741 <Skele.Row style={[a.align_center, a.gap_md]}> 742 <Skele.Circle size={42} /> 743 744 <Skele.Col> 745 <Skele.Text style={[a.text_lg, {width: '20%'}]} /> 746 <Skele.Text blend style={[a.text_md, {width: '40%'}]} /> 747 </Skele.Col> 748 </Skele.Row> 749 750 <View> 751 <Skele.Text style={[a.text_xl, {width: '100%'}]} /> 752 <Skele.Text style={[a.text_xl, {width: '60%'}]} /> 753 </View> 754 755 <Skele.Text style={[a.text_sm, {width: '50%'}]} /> 756 757 <PostControlsSkeleton big /> 758 </View> 759 ) 760}