forked from
jollywhoppers.com/witchsky.app
Bluesky app fork with some witchin' additions 馃挮
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 {
22 POST_TOMBSTONE,
23 type Shadow,
24 usePostShadow,
25} from '#/state/cache/post-shadow'
26import {useProfileShadow} from '#/state/cache/profile-shadow'
27import {FeedFeedbackProvider, useFeedFeedback} from '#/state/feed-feedback'
28import {useLanguagePrefs} from '#/state/preferences'
29import {type ThreadItem} from '#/state/queries/usePostThread/types'
30import {useSession} from '#/state/session'
31import {type OnPostSuccessData} from '#/state/shell/composer'
32import {useMergedThreadgateHiddenReplies} from '#/state/threadgate-hidden-replies'
33import {type PostSource} from '#/state/unstable-post-source'
34import {PreviewableUserAvatar} from '#/view/com/util/UserAvatar'
35import {ThreadItemAnchorFollowButton} from '#/screens/PostThread/components/ThreadItemAnchorFollowButton'
36import {
37 LINEAR_AVI_WIDTH,
38 OUTER_SPACE,
39 REPLY_LINE_WIDTH,
40} from '#/screens/PostThread/const'
41import {atoms as a, useTheme} from '#/alf'
42import {colors} from '#/components/Admonition'
43import {Button} from '#/components/Button'
44import {DebugFieldDisplay} from '#/components/DebugFieldDisplay'
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 {ContentHider} from '#/components/moderation/ContentHider'
49import {LabelsOnMyPost} from '#/components/moderation/LabelsOnMe'
50import {PostAlerts} from '#/components/moderation/PostAlerts'
51import {type AppModerationCause} from '#/components/Pills'
52import {Embed, PostEmbedViewContext} from '#/components/Post/Embed'
53import {PostControls, PostControlsSkeleton} from '#/components/PostControls'
54import {useFormatPostStatCount} from '#/components/PostControls/util'
55import {ProfileHoverCard} from '#/components/ProfileHoverCard'
56import * as Prompt from '#/components/Prompt'
57import {RichText} from '#/components/RichText'
58import * as Skele from '#/components/Skeleton'
59import {Text} from '#/components/Typography'
60import {VerificationCheckButton} from '#/components/verification/VerificationCheckButton'
61import {WhoCanReply} from '#/components/WhoCanReply'
62import {useAnalytics} from '#/analytics'
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 <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 </Text>
464 </Link>
465 ) : null}
466 {post.quoteCount != null &&
467 post.quoteCount !== 0 &&
468 !post.viewer?.embeddingDisabled ? (
469 <Link to={quotesHref} label={_(msg`Quotes of this post`)}>
470 <Text
471 testID="quoteCount-expanded"
472 style={[a.text_md, t.atoms.text_contrast_medium]}>
473 <Text style={[a.text_md, a.font_semi_bold, t.atoms.text]}>
474 {formatPostStatCount(post.quoteCount)}
475 </Text>{' '}
476 <Plural
477 value={post.quoteCount}
478 one="quote"
479 other="quotes"
480 />
481 </Text>
482 </Link>
483 ) : null}
484 {post.likeCount != null && post.likeCount !== 0 ? (
485 <Link to={likesHref} label={_(msg`Likes on this post`)}>
486 <Text
487 testID="likeCount-expanded"
488 style={[a.text_md, t.atoms.text_contrast_medium]}>
489 <Text style={[a.text_md, a.font_semi_bold, t.atoms.text]}>
490 {formatPostStatCount(post.likeCount)}
491 </Text>{' '}
492 <Plural value={post.likeCount} one="like" other="likes" />
493 </Text>
494 </Link>
495 ) : null}
496 {post.bookmarkCount != null && post.bookmarkCount !== 0 ? (
497 <Text
498 testID="bookmarkCount-expanded"
499 style={[a.text_md, t.atoms.text_contrast_medium]}>
500 <Text style={[a.text_md, a.font_semi_bold, t.atoms.text]}>
501 {formatPostStatCount(post.bookmarkCount)}
502 </Text>{' '}
503 <Plural value={post.bookmarkCount} one="save" other="saves" />
504 </Text>
505 ) : null}
506 </View>
507 ) : null}
508 <View
509 style={[
510 a.pt_sm,
511 a.pb_2xs,
512 {
513 marginLeft: -5,
514 },
515 ]}>
516 <FeedFeedbackProvider value={feedFeedback}>
517 <PostControls
518 big
519 post={postShadow}
520 record={record}
521 richText={richText}
522 onPressReply={onPressReply}
523 logContext="PostThreadItem"
524 threadgateRecord={threadgateRecord}
525 feedContext={postSource?.post?.feedContext}
526 reqId={postSource?.post?.reqId}
527 viaRepost={viaRepost}
528 />
529 </FeedFeedbackProvider>
530 </View>
531 <DebugFieldDisplay subject={post} />
532 </View>
533 </View>
534 </>
535 )
536})
537
538function ExpandedPostDetails({
539 post,
540 isThreadAuthor,
541}: {
542 post: Extract<ThreadItem, {type: 'threadPost'}>['value']['post']
543 isThreadAuthor: boolean
544}) {
545 const t = useTheme()
546 const ax = useAnalytics()
547 const {_, i18n} = useLingui()
548 const translate = useTranslate()
549 const isRootPost = !('reply' in post.record)
550 const langPrefs = useLanguagePrefs()
551
552 const needsTranslation = useMemo(
553 () =>
554 Boolean(
555 langPrefs.primaryLanguage &&
556 !isPostInLanguage(post, [langPrefs.primaryLanguage]),
557 ),
558 [post, langPrefs.primaryLanguage],
559 )
560
561 const onTranslatePress = useCallback(
562 (e: GestureResponderEvent) => {
563 e.preventDefault()
564 translate(post.record.text || '', langPrefs.primaryLanguage)
565
566 if (
567 bsky.dangerousIsType<AppBskyFeedPost.Record>(
568 post.record,
569 AppBskyFeedPost.isRecord,
570 )
571 ) {
572 ax.metric('translate', {
573 sourceLanguages: post.record.langs ?? [],
574 targetLanguage: langPrefs.primaryLanguage,
575 textLength: post.record.text.length,
576 })
577 }
578
579 return false
580 },
581 [ax, translate, langPrefs, post],
582 )
583
584 return (
585 <View style={[a.gap_md, a.pt_md, a.align_start]}>
586 <BackdatedPostIndicator post={post} />
587 <View style={[a.flex_row, a.align_center, a.flex_wrap, a.gap_sm]}>
588 <Text style={[a.text_sm, t.atoms.text_contrast_medium]}>
589 {niceDate(i18n, post.indexedAt, 'dot separated')}
590 </Text>
591 {isRootPost && (
592 <WhoCanReply post={post} isThreadAuthor={isThreadAuthor} />
593 )}
594 {needsTranslation && (
595 <>
596 <Text style={[a.text_sm, t.atoms.text_contrast_medium]}>
597 ·
598 </Text>
599
600 <InlineLinkText
601 // overridden to open an intent on android, but keep
602 // as anchor tag for accessibility
603 to={getTranslatorLink(
604 post.record.text,
605 langPrefs.primaryLanguage,
606 )}
607 label={_(msg`Translate`)}
608 style={[a.text_sm]}
609 onPress={onTranslatePress}>
610 <Trans>Translate</Trans>
611 </InlineLinkText>
612 </>
613 )}
614 </View>
615 </View>
616 )
617}
618
619function BackdatedPostIndicator({post}: {post: AppBskyFeedDefs.PostView}) {
620 const t = useTheme()
621 const {_, i18n} = useLingui()
622 const control = Prompt.usePromptControl()
623
624 const indexedAt = new Date(post.indexedAt)
625 const createdAt = bsky.dangerousIsType<AppBskyFeedPost.Record>(
626 post.record,
627 AppBskyFeedPost.isRecord,
628 )
629 ? new Date(post.record.createdAt)
630 : new Date(post.indexedAt)
631
632 // backdated if createdAt is 24 hours or more before indexedAt
633 const isBackdated =
634 indexedAt.getTime() - createdAt.getTime() > 24 * 60 * 60 * 1000
635
636 if (!isBackdated) return null
637
638 const orange = colors.warning
639
640 return (
641 <>
642 <Button
643 label={_(msg`Archived post`)}
644 accessibilityHint={_(
645 msg`Shows information about when this post was created`,
646 )}
647 onPress={e => {
648 e.preventDefault()
649 e.stopPropagation()
650 control.open()
651 }}>
652 {({hovered, pressed}) => (
653 <View
654 style={[
655 a.flex_row,
656 a.align_center,
657 a.rounded_full,
658 t.atoms.bg_contrast_25,
659 (hovered || pressed) && t.atoms.bg_contrast_50,
660 {
661 gap: 3,
662 paddingHorizontal: 6,
663 paddingVertical: 3,
664 },
665 ]}>
666 <CalendarClockIcon fill={orange} size="sm" aria-hidden />
667 <Text
668 style={[
669 a.text_xs,
670 a.font_semi_bold,
671 a.leading_tight,
672 t.atoms.text_contrast_medium,
673 ]}>
674 <Trans>Archived from {niceDate(i18n, createdAt, 'medium')}</Trans>
675 </Text>
676 </View>
677 )}
678 </Button>
679
680 <Prompt.Outer control={control}>
681 <Prompt.Content>
682 <Prompt.TitleText>
683 <Trans>Archived post</Trans>
684 </Prompt.TitleText>
685 <Prompt.DescriptionText>
686 <Trans>
687 This post claims to have been created on{' '}
688 <RNText style={[a.font_semi_bold]}>
689 {niceDate(i18n, createdAt)}
690 </RNText>
691 , but was first seen by Bluesky on{' '}
692 <RNText style={[a.font_semi_bold]}>
693 {niceDate(i18n, indexedAt)}
694 </RNText>
695 .
696 </Trans>
697 </Prompt.DescriptionText>
698 <Prompt.DescriptionText>
699 <Trans>
700 Bluesky cannot confirm the authenticity of the claimed date.
701 </Trans>
702 </Prompt.DescriptionText>
703 </Prompt.Content>
704 <Prompt.Actions>
705 <Prompt.Action cta={_(msg`Okay`)} onPress={() => {}} />
706 </Prompt.Actions>
707 </Prompt.Outer>
708 </>
709 )
710}
711
712function getThreadAuthor(
713 post: AppBskyFeedDefs.PostView,
714 record: AppBskyFeedPost.Record,
715): string {
716 if (!record.reply) {
717 return post.author.did
718 }
719 try {
720 return new AtUri(record.reply.root.uri).host
721 } catch {
722 return ''
723 }
724}
725
726export function ThreadItemAnchorSkeleton() {
727 return (
728 <View style={[a.p_lg, a.gap_md]}>
729 <Skele.Row style={[a.align_center, a.gap_md]}>
730 <Skele.Circle size={42} />
731
732 <Skele.Col>
733 <Skele.Text style={[a.text_lg, {width: '20%'}]} />
734 <Skele.Text blend style={[a.text_md, {width: '40%'}]} />
735 </Skele.Col>
736 </Skele.Row>
737
738 <View>
739 <Skele.Text style={[a.text_xl, {width: '100%'}]} />
740 <Skele.Text style={[a.text_xl, {width: '60%'}]} />
741 </View>
742
743 <Skele.Text style={[a.text_sm, {width: '50%'}]} />
744
745 <PostControlsSkeleton big />
746 </View>
747 )
748}