mirror of https://git.lenooby09.tech/LeNooby09/social-app.git
1import React, {memo, useMemo} from 'react'
2import {StyleSheet, View} from 'react-native'
3import {
4 AtUri,
5 AppBskyFeedDefs,
6 AppBskyFeedPost,
7 RichText as RichTextAPI,
8 ModerationDecision,
9} from '@atproto/api'
10import {moderatePost_wrapped as moderatePost} from '#/lib/moderatePost_wrapped'
11import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome'
12import {PostThreadFollowBtn} from 'view/com/post-thread/PostThreadFollowBtn'
13import {Link, TextLink} from '../util/Link'
14import {RichText} from '#/components/RichText'
15import {Text} from '../util/text/Text'
16import {PreviewableUserAvatar} from '../util/UserAvatar'
17import {s} from 'lib/styles'
18import {niceDate} from 'lib/strings/time'
19import {sanitizeDisplayName} from 'lib/strings/display-names'
20import {sanitizeHandle} from 'lib/strings/handles'
21import {countLines, pluralize} from 'lib/strings/helpers'
22import {getTranslatorLink, isPostInLanguage} from '../../../locale/helpers'
23import {PostMeta} from '../util/PostMeta'
24import {PostEmbeds} from '../util/post-embeds'
25import {PostCtrls} from '../util/post-ctrls/PostCtrls'
26import {PostHider} from '../../../components/moderation/PostHider'
27import {ContentHider} from '../../../components/moderation/ContentHider'
28import {PostAlerts} from '../../../components/moderation/PostAlerts'
29import {LabelsOnMyPost} from '../../../components/moderation/LabelsOnMe'
30import {ErrorMessage} from '../util/error/ErrorMessage'
31import {usePalette} from 'lib/hooks/usePalette'
32import {formatCount} from '../util/numeric/format'
33import {makeProfileLink} from 'lib/routes/links'
34import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries'
35import {MAX_POST_LINES} from 'lib/constants'
36import {Trans, msg} from '@lingui/macro'
37import {useLingui} from '@lingui/react'
38import {useLanguagePrefs} from '#/state/preferences'
39import {useComposerControls} from '#/state/shell/composer'
40import {useModerationOpts} from '#/state/queries/preferences'
41import {useOpenLink} from '#/state/preferences/in-app-browser'
42import {Shadow, usePostShadow, POST_TOMBSTONE} from '#/state/cache/post-shadow'
43import {ThreadPost} from '#/state/queries/post-thread'
44import {useSession} from 'state/session'
45import {WhoCanReply} from '../threadgate/WhoCanReply'
46import {LoadingPlaceholder} from '../util/LoadingPlaceholder'
47import {atoms as a} from '#/alf'
48
49export function PostThreadItem({
50 post,
51 record,
52 treeView,
53 depth,
54 prevPost,
55 nextPost,
56 isHighlightedPost,
57 hasMore,
58 showChildReplyLine,
59 showParentReplyLine,
60 hasPrecedingItem,
61 onPostReply,
62}: {
63 post: AppBskyFeedDefs.PostView
64 record: AppBskyFeedPost.Record
65 treeView: boolean
66 depth: number
67 prevPost: ThreadPost | undefined
68 nextPost: ThreadPost | undefined
69 isHighlightedPost?: boolean
70 hasMore?: boolean
71 showChildReplyLine?: boolean
72 showParentReplyLine?: boolean
73 hasPrecedingItem: boolean
74 onPostReply: () => void
75}) {
76 const moderationOpts = useModerationOpts()
77 const postShadowed = usePostShadow(post)
78 const richText = useMemo(
79 () =>
80 new RichTextAPI({
81 text: record.text,
82 facets: record.facets,
83 }),
84 [record],
85 )
86 const moderation = useMemo(
87 () =>
88 post && moderationOpts ? moderatePost(post, moderationOpts) : undefined,
89 [post, moderationOpts],
90 )
91 if (postShadowed === POST_TOMBSTONE) {
92 return <PostThreadItemDeleted />
93 }
94 if (richText && moderation) {
95 return (
96 <PostThreadItemLoaded
97 // Safeguard from clobbering per-post state below:
98 key={postShadowed.uri}
99 post={postShadowed}
100 prevPost={prevPost}
101 nextPost={nextPost}
102 record={record}
103 richText={richText}
104 moderation={moderation}
105 treeView={treeView}
106 depth={depth}
107 isHighlightedPost={isHighlightedPost}
108 hasMore={hasMore}
109 showChildReplyLine={showChildReplyLine}
110 showParentReplyLine={showParentReplyLine}
111 hasPrecedingItem={hasPrecedingItem}
112 onPostReply={onPostReply}
113 />
114 )
115 }
116 return null
117}
118
119function PostThreadItemDeleted() {
120 const pal = usePalette('default')
121 return (
122 <View style={[styles.outer, pal.border, pal.view, s.p20, s.flexRow]}>
123 <FontAwesomeIcon icon={['far', 'trash-can']} color={pal.colors.icon} />
124 <Text style={[pal.textLight, s.ml10]}>
125 <Trans>This post has been deleted.</Trans>
126 </Text>
127 </View>
128 )
129}
130
131let PostThreadItemLoaded = ({
132 post,
133 record,
134 richText,
135 moderation,
136 treeView,
137 depth,
138 prevPost,
139 nextPost,
140 isHighlightedPost,
141 hasMore,
142 showChildReplyLine,
143 showParentReplyLine,
144 hasPrecedingItem,
145 onPostReply,
146}: {
147 post: Shadow<AppBskyFeedDefs.PostView>
148 record: AppBskyFeedPost.Record
149 richText: RichTextAPI
150 moderation: ModerationDecision
151 treeView: boolean
152 depth: number
153 prevPost: ThreadPost | undefined
154 nextPost: ThreadPost | undefined
155 isHighlightedPost?: boolean
156 hasMore?: boolean
157 showChildReplyLine?: boolean
158 showParentReplyLine?: boolean
159 hasPrecedingItem: boolean
160 onPostReply: () => void
161}): React.ReactNode => {
162 const pal = usePalette('default')
163 const {_} = useLingui()
164 const langPrefs = useLanguagePrefs()
165 const {openComposer} = useComposerControls()
166 const [limitLines, setLimitLines] = React.useState(
167 () => countLines(richText?.text) >= MAX_POST_LINES,
168 )
169 const {currentAccount} = useSession()
170 const rootUri = record.reply?.root?.uri || post.uri
171 const postHref = React.useMemo(() => {
172 const urip = new AtUri(post.uri)
173 return makeProfileLink(post.author, 'post', urip.rkey)
174 }, [post.uri, post.author])
175 const itemTitle = _(msg`Post by ${post.author.handle}`)
176 const authorHref = makeProfileLink(post.author)
177 const authorTitle = post.author.handle
178 const likesHref = React.useMemo(() => {
179 const urip = new AtUri(post.uri)
180 return makeProfileLink(post.author, 'post', urip.rkey, 'liked-by')
181 }, [post.uri, post.author])
182 const likesTitle = _(msg`Likes on this post`)
183 const repostsHref = React.useMemo(() => {
184 const urip = new AtUri(post.uri)
185 return makeProfileLink(post.author, 'post', urip.rkey, 'reposted-by')
186 }, [post.uri, post.author])
187 const repostsTitle = _(msg`Reposts of this post`)
188
189 const translatorUrl = getTranslatorLink(
190 record?.text || '',
191 langPrefs.primaryLanguage,
192 )
193 const needsTranslation = useMemo(
194 () =>
195 Boolean(
196 langPrefs.primaryLanguage &&
197 !isPostInLanguage(post, [langPrefs.primaryLanguage]),
198 ),
199 [post, langPrefs.primaryLanguage],
200 )
201
202 const onPressReply = React.useCallback(() => {
203 openComposer({
204 replyTo: {
205 uri: post.uri,
206 cid: post.cid,
207 text: record.text,
208 author: post.author,
209 embed: post.embed,
210 moderation,
211 },
212 onPost: onPostReply,
213 })
214 }, [openComposer, post, record, onPostReply, moderation])
215
216 const onPressShowMore = React.useCallback(() => {
217 setLimitLines(false)
218 }, [setLimitLines])
219
220 if (!record) {
221 return <ErrorMessage message={_(msg`Invalid or unsupported post record`)} />
222 }
223
224 if (isHighlightedPost) {
225 return (
226 <>
227 {rootUri !== post.uri && (
228 <View style={{paddingLeft: 16, flexDirection: 'row', height: 16}}>
229 <View style={{width: 38}}>
230 <View
231 style={[
232 styles.replyLine,
233 {
234 flexGrow: 1,
235 backgroundColor: pal.colors.border,
236 },
237 ]}
238 />
239 </View>
240 </View>
241 )}
242
243 <View
244 testID={`postThreadItem-by-${post.author.handle}`}
245 style={[styles.outer, styles.outerHighlighted, pal.border, pal.view]}
246 accessible={false}>
247 <View style={[styles.layout]}>
248 <View style={[styles.layoutAvi, {paddingBottom: 8}]}>
249 <PreviewableUserAvatar
250 size={42}
251 did={post.author.did}
252 handle={post.author.handle}
253 avatar={post.author.avatar}
254 moderation={moderation.ui('avatar')}
255 type={post.author.associated?.labeler ? 'labeler' : 'user'}
256 />
257 </View>
258 <View style={styles.layoutContent}>
259 <View
260 style={[styles.meta, styles.metaExpandedLine1, {zIndex: 1}]}>
261 <Link style={s.flex1} href={authorHref} title={authorTitle}>
262 <Text
263 type="xl-bold"
264 style={[pal.text]}
265 numberOfLines={1}
266 lineHeight={1.2}>
267 {sanitizeDisplayName(
268 post.author.displayName ||
269 sanitizeHandle(post.author.handle),
270 moderation.ui('displayName'),
271 )}
272 </Text>
273 </Link>
274 </View>
275 <View style={styles.meta}>
276 <Link style={s.flex1} href={authorHref} title={authorTitle}>
277 <Text type="md" style={[pal.textLight]} numberOfLines={1}>
278 {sanitizeHandle(post.author.handle, '@')}
279 </Text>
280 </Link>
281 </View>
282 </View>
283 {currentAccount?.did !== post.author.did && (
284 <PostThreadFollowBtn did={post.author.did} />
285 )}
286 </View>
287 <View style={[s.pl10, s.pr10, s.pb10]}>
288 <LabelsOnMyPost post={post} />
289 <ContentHider
290 modui={moderation.ui('contentView')}
291 ignoreMute
292 style={styles.contentHider}
293 childContainerStyle={styles.contentHiderChild}>
294 <PostAlerts
295 modui={moderation.ui('contentView')}
296 includeMute
297 style={[a.pt_2xs, a.pb_sm]}
298 />
299 {richText?.text ? (
300 <View
301 style={[
302 styles.postTextContainer,
303 styles.postTextLargeContainer,
304 ]}>
305 <RichText
306 enableTags
307 selectable
308 value={richText}
309 style={[a.flex_1, a.text_xl]}
310 authorHandle={post.author.handle}
311 />
312 </View>
313 ) : undefined}
314 {post.embed && (
315 <View style={[a.pb_sm]}>
316 <PostEmbeds embed={post.embed} moderation={moderation} />
317 </View>
318 )}
319 </ContentHider>
320 <ExpandedPostDetails
321 post={post}
322 translatorUrl={translatorUrl}
323 needsTranslation={needsTranslation}
324 />
325 {post.repostCount !== 0 || post.likeCount !== 0 ? (
326 // Show this section unless we're *sure* it has no engagement.
327 <View style={[styles.expandedInfo, pal.border]}>
328 {post.repostCount == null && post.likeCount == null && (
329 // If we're still loading and not sure, assume this post has engagement.
330 // This lets us avoid a layout shift for the common case (embedded post with likes/reposts).
331 // TODO: embeds should include metrics to avoid us having to guess.
332 <LoadingPlaceholder width={50} height={20} />
333 )}
334 {post.repostCount != null && post.repostCount !== 0 ? (
335 <Link
336 style={styles.expandedInfoItem}
337 href={repostsHref}
338 title={repostsTitle}>
339 <Text
340 testID="repostCount-expanded"
341 type="lg"
342 style={pal.textLight}>
343 <Text type="xl-bold" style={pal.text}>
344 {formatCount(post.repostCount)}
345 </Text>{' '}
346 {pluralize(post.repostCount, 'repost')}
347 </Text>
348 </Link>
349 ) : null}
350 {post.likeCount != null && post.likeCount !== 0 ? (
351 <Link
352 style={styles.expandedInfoItem}
353 href={likesHref}
354 title={likesTitle}>
355 <Text
356 testID="likeCount-expanded"
357 type="lg"
358 style={pal.textLight}>
359 <Text type="xl-bold" style={pal.text}>
360 {formatCount(post.likeCount)}
361 </Text>{' '}
362 {pluralize(post.likeCount, 'like')}
363 </Text>
364 </Link>
365 ) : null}
366 </View>
367 ) : null}
368 <View style={[s.pl10, s.pr10, s.pb5]}>
369 <PostCtrls
370 big
371 post={post}
372 record={record}
373 richText={richText}
374 onPressReply={onPressReply}
375 logContext="PostThreadItem"
376 />
377 </View>
378 </View>
379 </View>
380 <WhoCanReply post={post} />
381 </>
382 )
383 } else {
384 const isThreadedChild = treeView && depth > 0
385 const isThreadedChildAdjacentTop =
386 isThreadedChild && prevPost?.ctx.depth === depth && depth !== 1
387 const isThreadedChildAdjacentBot =
388 isThreadedChild && nextPost?.ctx.depth === depth
389 return (
390 <>
391 <PostOuterWrapper
392 post={post}
393 depth={depth}
394 showParentReplyLine={!!showParentReplyLine}
395 treeView={treeView}
396 hasPrecedingItem={hasPrecedingItem}>
397 <PostHider
398 testID={`postThreadItem-by-${post.author.handle}`}
399 href={postHref}
400 style={[pal.view]}
401 modui={moderation.ui('contentList')}
402 iconSize={isThreadedChild ? 26 : 38}
403 iconStyles={
404 isThreadedChild
405 ? {marginRight: 4}
406 : {marginLeft: 2, marginRight: 2}
407 }>
408 <View
409 style={{
410 flexDirection: 'row',
411 gap: 10,
412 paddingLeft: 8,
413 height: isThreadedChildAdjacentTop ? 8 : 16,
414 }}>
415 <View style={{width: 38}}>
416 {!isThreadedChild && showParentReplyLine && (
417 <View
418 style={[
419 styles.replyLine,
420 {
421 flexGrow: 1,
422 backgroundColor: pal.colors.replyLine,
423 marginBottom: 4,
424 },
425 ]}
426 />
427 )}
428 </View>
429 </View>
430
431 <View
432 style={[
433 styles.layout,
434 {
435 paddingBottom:
436 showChildReplyLine && !isThreadedChild
437 ? 0
438 : isThreadedChildAdjacentBot
439 ? 4
440 : 8,
441 },
442 ]}>
443 {/* If we are in threaded mode, the avatar is rendered in PostMeta */}
444 {!isThreadedChild && (
445 <View style={styles.layoutAvi}>
446 <PreviewableUserAvatar
447 size={38}
448 did={post.author.did}
449 handle={post.author.handle}
450 avatar={post.author.avatar}
451 moderation={moderation.ui('avatar')}
452 type={post.author.associated?.labeler ? 'labeler' : 'user'}
453 />
454
455 {showChildReplyLine && (
456 <View
457 style={[
458 styles.replyLine,
459 {
460 flexGrow: 1,
461 backgroundColor: pal.colors.replyLine,
462 marginTop: 4,
463 },
464 ]}
465 />
466 )}
467 </View>
468 )}
469
470 <View
471 style={
472 isThreadedChild
473 ? styles.layoutContentThreaded
474 : styles.layoutContent
475 }>
476 <PostMeta
477 author={post.author}
478 moderation={moderation}
479 authorHasWarning={!!post.author.labels?.length}
480 timestamp={post.indexedAt}
481 postHref={postHref}
482 showAvatar={isThreadedChild}
483 avatarModeration={moderation.ui('avatar')}
484 avatarSize={28}
485 displayNameType="md-bold"
486 displayNameStyle={isThreadedChild && s.ml2}
487 style={isThreadedChild && s.mb2}
488 />
489 <LabelsOnMyPost post={post} />
490 <PostAlerts
491 modui={moderation.ui('contentList')}
492 style={[a.pt_xs, a.pb_sm]}
493 />
494 {richText?.text ? (
495 <View style={styles.postTextContainer}>
496 <RichText
497 enableTags
498 value={richText}
499 style={[a.flex_1, a.text_md]}
500 numberOfLines={limitLines ? MAX_POST_LINES : undefined}
501 authorHandle={post.author.handle}
502 />
503 </View>
504 ) : undefined}
505 {limitLines ? (
506 <TextLink
507 text={_(msg`Show More`)}
508 style={pal.link}
509 onPress={onPressShowMore}
510 href="#"
511 />
512 ) : undefined}
513 {post.embed && (
514 <View style={[a.pb_xs]}>
515 <PostEmbeds embed={post.embed} moderation={moderation} />
516 </View>
517 )}
518 <PostCtrls
519 post={post}
520 record={record}
521 richText={richText}
522 onPressReply={onPressReply}
523 logContext="PostThreadItem"
524 />
525 </View>
526 </View>
527 {hasMore ? (
528 <Link
529 style={[
530 styles.loadMore,
531 {
532 paddingLeft: treeView ? 8 : 70,
533 paddingTop: 0,
534 paddingBottom: treeView ? 4 : 12,
535 },
536 ]}
537 href={postHref}
538 title={itemTitle}
539 noFeedback>
540 <Text type="sm-medium" style={pal.textLight}>
541 <Trans>More</Trans>
542 </Text>
543 <FontAwesomeIcon
544 icon="angle-right"
545 color={pal.colors.textLight}
546 size={14}
547 />
548 </Link>
549 ) : undefined}
550 </PostHider>
551 </PostOuterWrapper>
552 <WhoCanReply
553 post={post}
554 style={{
555 marginTop: 4,
556 }}
557 />
558 </>
559 )
560 }
561}
562PostThreadItemLoaded = memo(PostThreadItemLoaded)
563
564function PostOuterWrapper({
565 post,
566 treeView,
567 depth,
568 showParentReplyLine,
569 hasPrecedingItem,
570 children,
571}: React.PropsWithChildren<{
572 post: AppBskyFeedDefs.PostView
573 treeView: boolean
574 depth: number
575 showParentReplyLine: boolean
576 hasPrecedingItem: boolean
577}>) {
578 const {isMobile} = useWebMediaQueries()
579 const pal = usePalette('default')
580 if (treeView && depth > 0) {
581 return (
582 <View
583 style={[
584 pal.border,
585 styles.cursor,
586 {
587 flexDirection: 'row',
588 paddingHorizontal: isMobile ? 10 : 6,
589 borderTopWidth: depth === 1 ? 1 : 0,
590 },
591 ]}>
592 {Array.from(Array(depth - 1)).map((_, n: number) => (
593 <View
594 key={`${post.uri}-padding-${n}`}
595 style={{
596 borderLeftWidth: 2,
597 borderLeftColor: pal.colors.border,
598 marginLeft: isMobile ? 6 : 12,
599 paddingLeft: isMobile ? 6 : 8,
600 }}
601 />
602 ))}
603 <View style={{flex: 1}}>{children}</View>
604 </View>
605 )
606 }
607 return (
608 <View
609 style={[
610 styles.outer,
611 pal.border,
612 showParentReplyLine && hasPrecedingItem && styles.noTopBorder,
613 styles.cursor,
614 ]}>
615 {children}
616 </View>
617 )
618}
619
620function ExpandedPostDetails({
621 post,
622 needsTranslation,
623 translatorUrl,
624}: {
625 post: AppBskyFeedDefs.PostView
626 needsTranslation: boolean
627 translatorUrl: string
628}) {
629 const pal = usePalette('default')
630 const {_} = useLingui()
631 const openLink = useOpenLink()
632 const onTranslatePress = React.useCallback(
633 () => openLink(translatorUrl),
634 [openLink, translatorUrl],
635 )
636 return (
637 <View style={[s.flexRow, s.mt2, s.mb10]}>
638 <Text style={pal.textLight}>{niceDate(post.indexedAt)}</Text>
639 {needsTranslation && (
640 <>
641 <Text style={pal.textLight}> · </Text>
642 <Text
643 style={pal.link}
644 title={_(msg`Translate`)}
645 onPress={onTranslatePress}>
646 <Trans>Translate</Trans>
647 </Text>
648 </>
649 )}
650 </View>
651 )
652}
653
654const styles = StyleSheet.create({
655 outer: {
656 borderTopWidth: 1,
657 paddingLeft: 8,
658 },
659 outerHighlighted: {
660 paddingTop: 16,
661 paddingLeft: 8,
662 paddingRight: 8,
663 },
664 noTopBorder: {
665 borderTopWidth: 0,
666 },
667 layout: {
668 flexDirection: 'row',
669 paddingHorizontal: 8,
670 },
671 layoutAvi: {},
672 layoutContent: {
673 flex: 1,
674 marginLeft: 10,
675 },
676 layoutContentThreaded: {
677 flex: 1,
678 paddingRight: 10,
679 },
680 meta: {
681 flexDirection: 'row',
682 paddingVertical: 2,
683 },
684 metaExpandedLine1: {
685 paddingVertical: 0,
686 },
687 alert: {
688 marginBottom: 6,
689 },
690 postTextContainer: {
691 flexDirection: 'row',
692 alignItems: 'center',
693 flexWrap: 'wrap',
694 paddingBottom: 4,
695 paddingRight: 10,
696 },
697 postTextLargeContainer: {
698 paddingHorizontal: 0,
699 paddingRight: 0,
700 paddingBottom: 10,
701 },
702 translateLink: {
703 marginBottom: 6,
704 },
705 contentHider: {
706 marginBottom: 6,
707 },
708 contentHiderChild: {
709 marginTop: 6,
710 },
711 expandedInfo: {
712 flexDirection: 'row',
713 padding: 10,
714 borderTopWidth: 1,
715 borderBottomWidth: 1,
716 marginTop: 5,
717 marginBottom: 15,
718 },
719 expandedInfoItem: {
720 marginRight: 10,
721 },
722 loadMore: {
723 flexDirection: 'row',
724 alignItems: 'center',
725 justifyContent: 'flex-start',
726 gap: 4,
727 paddingHorizontal: 20,
728 },
729 replyLine: {
730 width: 2,
731 marginLeft: 'auto',
732 marginRight: 'auto',
733 },
734 cursor: {
735 // @ts-ignore web only
736 cursor: 'pointer',
737 },
738})