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