An ATproto social media client -- with an independent Appview.
6
fork

Configure Feed

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

at main 738 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 {useActorStatus} from '#/lib/actor-status' 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 {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 {PreviewableUserAvatar} from '#/view/com/util/UserAvatar' 36import {ThreadItemAnchorFollowButton} from '#/screens/PostThread/components/ThreadItemAnchorFollowButton' 37import { 38 LINEAR_AVI_WIDTH, 39 OUTER_SPACE, 40 REPLY_LINE_WIDTH, 41} from '#/screens/PostThread/const' 42import {atoms as a, useBreakpoints, useTheme} from '#/alf' 43import {colors} from '#/components/Admonition' 44import {Button} from '#/components/Button' 45import {CalendarClock_Stroke2_Corner0_Rounded as CalendarClockIcon} from '#/components/icons/CalendarClock' 46import {Trash_Stroke2_Corner0_Rounded as TrashIcon} from '#/components/icons/Trash' 47import {InlineLinkText, Link} from '#/components/Link' 48import {LoggedOutCTA} from '#/components/LoggedOutCTA' 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 {useFormatPostStatCount} from '#/components/PostControls/util' 56import {ProfileHoverCard} from '#/components/ProfileHoverCard' 57import * as Prompt from '#/components/Prompt' 58import {RichText} from '#/components/RichText' 59import * as Skele from '#/components/Skeleton' 60import {Text} from '#/components/Typography' 61import {VerificationCheckButton} from '#/components/verification/VerificationCheckButton' 62import {WhoCanReply} from '#/components/WhoCanReply' 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 style={[a.text_md, a.font_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 {_} = useLingui() 180 const {openComposer} = useOpenComposer() 181 const {currentAccount, hasSession} = useSession() 182 const {gtTablet} = useBreakpoints() 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 }) 264 265 if (postSource) { 266 feedFeedback.sendInteraction({ 267 item: post.uri, 268 event: 'app.bsky.feed.defs#interactionReply', 269 feedContext: postSource.post.feedContext, 270 reqId: postSource.post.reqId, 271 }) 272 } 273 }, [ 274 openComposer, 275 post, 276 record, 277 onPostSuccess, 278 moderation, 279 postSource, 280 feedFeedback, 281 ]) 282 283 const onOpenAuthor = () => { 284 if (postSource) { 285 feedFeedback.sendInteraction({ 286 item: post.uri, 287 event: 'app.bsky.feed.defs#clickthroughAuthor', 288 feedContext: postSource.post.feedContext, 289 reqId: postSource.post.reqId, 290 }) 291 } 292 } 293 294 const onOpenEmbed = () => { 295 if (postSource) { 296 feedFeedback.sendInteraction({ 297 item: post.uri, 298 event: 'app.bsky.feed.defs#clickthroughEmbed', 299 feedContext: postSource.post.feedContext, 300 reqId: postSource.post.reqId, 301 }) 302 } 303 } 304 305 return ( 306 <> 307 <ThreadItemAnchorParentReplyLine isRoot={isRoot} /> 308 309 <View 310 testID={`postThreadItem-by-${post.author.handle}`} 311 style={[ 312 { 313 paddingHorizontal: OUTER_SPACE, 314 }, 315 isRoot && [a.pt_lg], 316 ]}> 317 {/* Show CTA for logged-out visitors - hide on desktop and check gate */} 318 {!gtTablet && <LoggedOutCTA gateName="cta_above_post_heading" />} 319 <View style={[a.flex_row, a.gap_md, a.pb_md]}> 320 <View collapsable={false}> 321 <PreviewableUserAvatar 322 size={42} 323 profile={post.author} 324 moderation={moderation.ui('avatar')} 325 type={post.author.associated?.labeler ? 'labeler' : 'user'} 326 live={live} 327 onBeforePress={onOpenAuthor} 328 /> 329 </View> 330 <Link 331 to={authorHref} 332 style={[a.flex_1]} 333 label={sanitizeDisplayName( 334 post.author.displayName || sanitizeHandle(post.author.handle), 335 moderation.ui('displayName'), 336 )} 337 onPress={onOpenAuthor}> 338 <View style={[a.flex_1, a.align_start]}> 339 <ProfileHoverCard did={post.author.did} style={[a.w_full]}> 340 <View style={[a.flex_row, a.align_center]}> 341 <Text 342 emoji 343 style={[ 344 a.flex_shrink, 345 a.text_lg, 346 a.font_bold, 347 a.leading_snug, 348 ]} 349 numberOfLines={1}> 350 {sanitizeDisplayName( 351 post.author.displayName || 352 sanitizeHandle(post.author.handle), 353 moderation.ui('displayName'), 354 )} 355 </Text> 356 357 <View style={[a.pl_xs]}> 358 <VerificationCheckButton profile={authorShadow} size="md" /> 359 </View> 360 </View> 361 <Text 362 style={[ 363 a.text_md, 364 a.leading_snug, 365 t.atoms.text_contrast_medium, 366 ]} 367 numberOfLines={1}> 368 {sanitizeHandle(post.author.handle, '@')} 369 </Text> 370 </ProfileHoverCard> 371 </View> 372 </Link> 373 {showFollowButton && ( 374 <View collapsable={false}> 375 <ThreadItemAnchorFollowButton did={post.author.did} /> 376 </View> 377 )} 378 </View> 379 <View style={[a.pb_sm]}> 380 <LabelsOnMyPost post={post} style={[a.pb_sm]} /> 381 <ContentHider 382 modui={moderation.ui('contentView')} 383 ignoreMute 384 childContainerStyle={[a.pt_sm]}> 385 <PostAlerts 386 modui={moderation.ui('contentView')} 387 size="lg" 388 includeMute 389 style={[a.pb_sm]} 390 additionalCauses={additionalPostAlerts} 391 /> 392 {richText?.text ? ( 393 <RichText 394 enableTags 395 selectable 396 value={richText} 397 style={[a.flex_1, a.text_xl]} 398 authorHandle={post.author.handle} 399 shouldProxyLinks={true} 400 /> 401 ) : undefined} 402 {post.embed && ( 403 <View style={[a.py_xs]}> 404 <Embed 405 embed={post.embed} 406 moderation={moderation} 407 viewContext={PostEmbedViewContext.ThreadHighlighted} 408 onOpen={onOpenEmbed} 409 /> 410 </View> 411 )} 412 </ContentHider> 413 <ExpandedPostDetails 414 post={item.value.post} 415 isThreadAuthor={isThreadAuthor} 416 /> 417 {post.repostCount !== 0 || 418 post.likeCount !== 0 || 419 post.quoteCount !== 0 || 420 post.bookmarkCount !== 0 ? ( 421 // Show this section unless we're *sure* it has no engagement. 422 <View 423 style={[ 424 a.flex_row, 425 a.flex_wrap, 426 a.align_center, 427 { 428 rowGap: a.gap_sm.gap, 429 columnGap: a.gap_lg.gap, 430 }, 431 a.border_t, 432 a.border_b, 433 a.mt_md, 434 a.py_md, 435 t.atoms.border_contrast_low, 436 ]}> 437 {post.repostCount != null && post.repostCount !== 0 ? ( 438 <Link to={repostsHref} label={_(msg`Reposts of this post`)}> 439 <Text 440 testID="repostCount-expanded" 441 style={[a.text_md, t.atoms.text_contrast_medium]}> 442 <Text style={[a.text_md, a.font_bold, t.atoms.text]}> 443 {formatPostStatCount(post.repostCount)} 444 </Text>{' '} 445 <Plural 446 value={post.repostCount} 447 one="repost" 448 other="reposts" 449 /> 450 </Text> 451 </Link> 452 ) : null} 453 {post.quoteCount != null && 454 post.quoteCount !== 0 && 455 !post.viewer?.embeddingDisabled ? ( 456 <Link to={quotesHref} label={_(msg`Quotes of this post`)}> 457 <Text 458 testID="quoteCount-expanded" 459 style={[a.text_md, t.atoms.text_contrast_medium]}> 460 <Text style={[a.text_md, a.font_bold, t.atoms.text]}> 461 {formatPostStatCount(post.quoteCount)} 462 </Text>{' '} 463 <Plural 464 value={post.quoteCount} 465 one="quote" 466 other="quotes" 467 /> 468 </Text> 469 </Link> 470 ) : null} 471 {post.likeCount != null && post.likeCount !== 0 ? ( 472 <Link to={likesHref} label={_(msg`Likes on this post`)}> 473 <Text 474 testID="likeCount-expanded" 475 style={[a.text_md, t.atoms.text_contrast_medium]}> 476 <Text style={[a.text_md, a.font_bold, t.atoms.text]}> 477 {formatPostStatCount(post.likeCount)} 478 </Text>{' '} 479 <Plural value={post.likeCount} one="like" other="likes" /> 480 </Text> 481 </Link> 482 ) : null} 483 {post.bookmarkCount != null && post.bookmarkCount !== 0 ? ( 484 <Text 485 testID="bookmarkCount-expanded" 486 style={[a.text_md, t.atoms.text_contrast_medium]}> 487 <Text style={[a.text_md, a.font_bold, t.atoms.text]}> 488 {formatPostStatCount(post.bookmarkCount)} 489 </Text>{' '} 490 <Plural value={post.bookmarkCount} one="save" other="saves" /> 491 </Text> 492 ) : null} 493 </View> 494 ) : null} 495 <View 496 style={[ 497 a.pt_sm, 498 a.pb_2xs, 499 { 500 marginLeft: -5, 501 }, 502 ]}> 503 <FeedFeedbackProvider value={feedFeedback}> 504 <PostControls 505 big 506 post={postShadow} 507 record={record} 508 richText={richText} 509 onPressReply={onPressReply} 510 logContext="PostThreadItem" 511 threadgateRecord={threadgateRecord} 512 feedContext={postSource?.post?.feedContext} 513 reqId={postSource?.post?.reqId} 514 viaRepost={viaRepost} 515 /> 516 </FeedFeedbackProvider> 517 </View> 518 </View> 519 </View> 520 </> 521 ) 522}) 523 524function ExpandedPostDetails({ 525 post, 526 isThreadAuthor, 527}: { 528 post: Extract<ThreadItem, {type: 'threadPost'}>['value']['post'] 529 isThreadAuthor: boolean 530}) { 531 const t = useTheme() 532 const {_, i18n} = useLingui() 533 const translate = useTranslate() 534 const isRootPost = !('reply' in post.record) 535 const langPrefs = useLanguagePrefs() 536 537 const needsTranslation = useMemo( 538 () => 539 Boolean( 540 langPrefs.primaryLanguage && 541 !isPostInLanguage(post, [langPrefs.primaryLanguage]), 542 ), 543 [post, langPrefs.primaryLanguage], 544 ) 545 546 const onTranslatePress = useCallback( 547 (e: GestureResponderEvent) => { 548 e.preventDefault() 549 translate(post.record.text || '', langPrefs.primaryLanguage) 550 551 if ( 552 bsky.dangerousIsType<AppBskyFeedPost.Record>( 553 post.record, 554 AppBskyFeedPost.isRecord, 555 ) 556 ) { 557 logger.metric('translate', { 558 sourceLanguages: post.record.langs ?? [], 559 targetLanguage: langPrefs.primaryLanguage, 560 textLength: post.record.text.length, 561 }) 562 } 563 564 return false 565 }, 566 [translate, langPrefs, post], 567 ) 568 569 return ( 570 <View style={[a.gap_md, a.pt_md, a.align_start]}> 571 <BackdatedPostIndicator post={post} /> 572 <View style={[a.flex_row, a.align_center, a.flex_wrap, a.gap_sm]}> 573 <Text style={[a.text_sm, t.atoms.text_contrast_medium]}> 574 {niceDate(i18n, post.indexedAt)} 575 </Text> 576 {isRootPost && ( 577 <WhoCanReply post={post} isThreadAuthor={isThreadAuthor} /> 578 )} 579 {needsTranslation && ( 580 <> 581 <Text style={[a.text_sm, t.atoms.text_contrast_medium]}> 582 &middot; 583 </Text> 584 585 <InlineLinkText 586 // overridden to open an intent on android, but keep 587 // as anchor tag for accessibility 588 to={getTranslatorLink( 589 post.record.text, 590 langPrefs.primaryLanguage, 591 )} 592 label={_(msg`Translate`)} 593 style={[a.text_sm]} 594 onPress={onTranslatePress}> 595 <Trans>Translate</Trans> 596 </InlineLinkText> 597 </> 598 )} 599 </View> 600 </View> 601 ) 602} 603 604function BackdatedPostIndicator({post}: {post: AppBskyFeedDefs.PostView}) { 605 const t = useTheme() 606 const {_, i18n} = useLingui() 607 const control = Prompt.usePromptControl() 608 609 const indexedAt = new Date(post.indexedAt) 610 const createdAt = bsky.dangerousIsType<AppBskyFeedPost.Record>( 611 post.record, 612 AppBskyFeedPost.isRecord, 613 ) 614 ? new Date(post.record.createdAt) 615 : new Date(post.indexedAt) 616 617 // backdated if createdAt is 24 hours or more before indexedAt 618 const isBackdated = 619 indexedAt.getTime() - createdAt.getTime() > 24 * 60 * 60 * 1000 620 621 if (!isBackdated) return null 622 623 const orange = colors.warning 624 625 return ( 626 <> 627 <Button 628 label={_(msg`Archived post`)} 629 accessibilityHint={_( 630 msg`Shows information about when this post was created`, 631 )} 632 onPress={e => { 633 e.preventDefault() 634 e.stopPropagation() 635 control.open() 636 }}> 637 {({hovered, pressed}) => ( 638 <View 639 style={[ 640 a.flex_row, 641 a.align_center, 642 a.rounded_full, 643 t.atoms.bg_contrast_25, 644 (hovered || pressed) && t.atoms.bg_contrast_50, 645 { 646 gap: 3, 647 paddingHorizontal: 6, 648 paddingVertical: 3, 649 }, 650 ]}> 651 <CalendarClockIcon fill={orange} size="sm" aria-hidden /> 652 <Text 653 style={[ 654 a.text_xs, 655 a.font_bold, 656 a.leading_tight, 657 t.atoms.text_contrast_medium, 658 ]}> 659 <Trans>Archived from {niceDate(i18n, createdAt)}</Trans> 660 </Text> 661 </View> 662 )} 663 </Button> 664 665 <Prompt.Outer control={control}> 666 <Prompt.TitleText> 667 <Trans>Archived post</Trans> 668 </Prompt.TitleText> 669 <Prompt.DescriptionText> 670 <Trans> 671 This post claims to have been created on{' '} 672 <RNText style={[a.font_bold]}>{niceDate(i18n, createdAt)}</RNText>, 673 but was first seen by Bluesky on{' '} 674 <RNText style={[a.font_bold]}>{niceDate(i18n, indexedAt)}</RNText>. 675 </Trans> 676 </Prompt.DescriptionText> 677 <Text 678 style={[ 679 a.text_md, 680 a.leading_snug, 681 t.atoms.text_contrast_high, 682 a.pb_xl, 683 ]}> 684 <Trans> 685 Bluesky cannot confirm the authenticity of the claimed date. 686 </Trans> 687 </Text> 688 <Prompt.Actions> 689 <Prompt.Action cta={_(msg`Okay`)} onPress={() => {}} /> 690 </Prompt.Actions> 691 </Prompt.Outer> 692 </> 693 ) 694} 695 696function getThreadAuthor( 697 post: AppBskyFeedDefs.PostView, 698 record: AppBskyFeedPost.Record, 699): string { 700 if (!record.reply) { 701 return post.author.did 702 } 703 try { 704 return new AtUri(record.reply.root.uri).host 705 } catch { 706 return '' 707 } 708} 709 710export function ThreadItemAnchorSkeleton() { 711 return ( 712 <View style={[a.p_lg, a.gap_md]}> 713 <Skele.Row style={[a.align_center, a.gap_md]}> 714 <Skele.Circle size={42} /> 715 716 <Skele.Col> 717 <Skele.Text style={[a.text_lg, {width: '20%'}]} /> 718 <Skele.Text blend style={[a.text_md, {width: '40%'}]} /> 719 </Skele.Col> 720 </Skele.Row> 721 722 <View> 723 <Skele.Text style={[a.text_xl, {width: '100%'}]} /> 724 <Skele.Text style={[a.text_xl, {width: '60%'}]} /> 725 </View> 726 727 <Skele.Text style={[a.text_sm, {width: '50%'}]} /> 728 729 <Skele.Row style={[a.justify_between]}> 730 <Skele.Pill blend size={24} /> 731 <Skele.Pill blend size={24} /> 732 <Skele.Pill blend size={24} /> 733 <Skele.Circle blend size={24} /> 734 <Skele.Circle blend size={24} /> 735 </Skele.Row> 736 </View> 737 ) 738}