mirror of https://git.lenooby09.tech/LeNooby09/social-app.git
1import React, {memo, useMemo} from 'react'
2import {StyleSheet, View} from 'react-native'
3import {
4 AppBskyFeedDefs,
5 AppBskyFeedPost,
6 AtUri,
7 ModerationDecision,
8 RichText as RichTextAPI,
9} from '@atproto/api'
10import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome'
11import {msg, Trans} from '@lingui/macro'
12import {useLingui} from '@lingui/react'
13
14import {moderatePost_wrapped as moderatePost} from '#/lib/moderatePost_wrapped'
15import {POST_TOMBSTONE, Shadow, usePostShadow} from '#/state/cache/post-shadow'
16import {useLanguagePrefs} from '#/state/preferences'
17import {useOpenLink} from '#/state/preferences/in-app-browser'
18import {ThreadPost} from '#/state/queries/post-thread'
19import {useModerationOpts} from '#/state/queries/preferences'
20import {useComposerControls} from '#/state/shell/composer'
21import {MAX_POST_LINES} from 'lib/constants'
22import {usePalette} from 'lib/hooks/usePalette'
23import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries'
24import {makeProfileLink} from 'lib/routes/links'
25import {sanitizeDisplayName} from 'lib/strings/display-names'
26import {sanitizeHandle} from 'lib/strings/handles'
27import {countLines, pluralize} from 'lib/strings/helpers'
28import {niceDate} from 'lib/strings/time'
29import {s} from 'lib/styles'
30import {isWeb} from 'platform/detection'
31import {useSession} from 'state/session'
32import {PostThreadFollowBtn} from 'view/com/post-thread/PostThreadFollowBtn'
33import {atoms as a} from '#/alf'
34import {RichText} from '#/components/RichText'
35import {ContentHider} from '../../../components/moderation/ContentHider'
36import {LabelsOnMyPost} from '../../../components/moderation/LabelsOnMe'
37import {PostAlerts} from '../../../components/moderation/PostAlerts'
38import {PostHider} from '../../../components/moderation/PostHider'
39import {getTranslatorLink, isPostInLanguage} from '../../../locale/helpers'
40import {WhoCanReply} from '../threadgate/WhoCanReply'
41import {ErrorMessage} from '../util/error/ErrorMessage'
42import {Link, TextLink} from '../util/Link'
43import {formatCount} from '../util/numeric/format'
44import {PostCtrls} from '../util/post-ctrls/PostCtrls'
45import {PostEmbeds} from '../util/post-embeds'
46import {PostMeta} from '../util/PostMeta'
47import {Text} from '../util/text/Text'
48import {PreviewableUserAvatar} from '../util/UserAvatar'
49
50export function PostThreadItem({
51 post,
52 record,
53 treeView,
54 depth,
55 prevPost,
56 nextPost,
57 isHighlightedPost,
58 hasMore,
59 showChildReplyLine,
60 showParentReplyLine,
61 hasPrecedingItem,
62 onPostReply,
63}: {
64 post: AppBskyFeedDefs.PostView
65 record: AppBskyFeedPost.Record
66 treeView: boolean
67 depth: number
68 prevPost: ThreadPost | undefined
69 nextPost: ThreadPost | undefined
70 isHighlightedPost?: boolean
71 hasMore?: boolean
72 showChildReplyLine?: boolean
73 showParentReplyLine?: boolean
74 hasPrecedingItem: boolean
75 onPostReply: () => void
76}) {
77 const moderationOpts = useModerationOpts()
78 const postShadowed = usePostShadow(post)
79 const richText = useMemo(
80 () =>
81 new RichTextAPI({
82 text: record.text,
83 facets: record.facets,
84 }),
85 [record],
86 )
87 const moderation = useMemo(
88 () =>
89 post && moderationOpts ? moderatePost(post, moderationOpts) : undefined,
90 [post, moderationOpts],
91 )
92 if (postShadowed === POST_TOMBSTONE) {
93 return <PostThreadItemDeleted />
94 }
95 if (richText && moderation) {
96 return (
97 <PostThreadItemLoaded
98 // Safeguard from clobbering per-post state below:
99 key={postShadowed.uri}
100 post={postShadowed}
101 prevPost={prevPost}
102 nextPost={nextPost}
103 record={record}
104 richText={richText}
105 moderation={moderation}
106 treeView={treeView}
107 depth={depth}
108 isHighlightedPost={isHighlightedPost}
109 hasMore={hasMore}
110 showChildReplyLine={showChildReplyLine}
111 showParentReplyLine={showParentReplyLine}
112 hasPrecedingItem={hasPrecedingItem}
113 onPostReply={onPostReply}
114 />
115 )
116 }
117 return null
118}
119
120function PostThreadItemDeleted() {
121 const pal = usePalette('default')
122 return (
123 <View style={[styles.outer, pal.border, pal.view, s.p20, s.flexRow]}>
124 <FontAwesomeIcon icon={['far', 'trash-can']} color={pal.colors.icon} />
125 <Text style={[pal.textLight, s.ml10]}>
126 <Trans>This post has been deleted.</Trans>
127 </Text>
128 </View>
129 )
130}
131
132let PostThreadItemLoaded = ({
133 post,
134 record,
135 richText,
136 moderation,
137 treeView,
138 depth,
139 prevPost,
140 nextPost,
141 isHighlightedPost,
142 hasMore,
143 showChildReplyLine,
144 showParentReplyLine,
145 hasPrecedingItem,
146 onPostReply,
147}: {
148 post: Shadow<AppBskyFeedDefs.PostView>
149 record: AppBskyFeedPost.Record
150 richText: RichTextAPI
151 moderation: ModerationDecision
152 treeView: boolean
153 depth: number
154 prevPost: ThreadPost | undefined
155 nextPost: ThreadPost | undefined
156 isHighlightedPost?: boolean
157 hasMore?: boolean
158 showChildReplyLine?: boolean
159 showParentReplyLine?: boolean
160 hasPrecedingItem: boolean
161 onPostReply: () => void
162}): React.ReactNode => {
163 const pal = usePalette('default')
164 const {_} = useLingui()
165 const langPrefs = useLanguagePrefs()
166 const {openComposer} = useComposerControls()
167 const [limitLines, setLimitLines] = React.useState(
168 () => countLines(richText?.text) >= MAX_POST_LINES,
169 )
170 const {currentAccount} = useSession()
171 const rootUri = record.reply?.root?.uri || post.uri
172 const postHref = React.useMemo(() => {
173 const urip = new AtUri(post.uri)
174 return makeProfileLink(post.author, 'post', urip.rkey)
175 }, [post.uri, post.author])
176 const itemTitle = _(msg`Post by ${post.author.handle}`)
177 const authorHref = makeProfileLink(post.author)
178 const authorTitle = post.author.handle
179 const likesHref = React.useMemo(() => {
180 const urip = new AtUri(post.uri)
181 return makeProfileLink(post.author, 'post', urip.rkey, 'liked-by')
182 }, [post.uri, post.author])
183 const likesTitle = _(msg`Likes on this post`)
184 const repostsHref = React.useMemo(() => {
185 const urip = new AtUri(post.uri)
186 return makeProfileLink(post.author, 'post', urip.rkey, 'reposted-by')
187 }, [post.uri, post.author])
188 const repostsTitle = _(msg`Reposts of this post`)
189
190 const translatorUrl = getTranslatorLink(
191 record?.text || '',
192 langPrefs.primaryLanguage,
193 )
194 const needsTranslation = useMemo(
195 () =>
196 Boolean(
197 langPrefs.primaryLanguage &&
198 !isPostInLanguage(post, [langPrefs.primaryLanguage]),
199 ),
200 [post, langPrefs.primaryLanguage],
201 )
202
203 const onPressReply = React.useCallback(() => {
204 openComposer({
205 replyTo: {
206 uri: post.uri,
207 cid: post.cid,
208 text: record.text,
209 author: post.author,
210 embed: post.embed,
211 moderation,
212 },
213 onPost: onPostReply,
214 })
215 }, [openComposer, post, record, onPostReply, moderation])
216
217 const onPressShowMore = React.useCallback(() => {
218 setLimitLines(false)
219 }, [setLimitLines])
220
221 if (!record) {
222 return <ErrorMessage message={_(msg`Invalid or unsupported post record`)} />
223 }
224
225 if (isHighlightedPost) {
226 return (
227 <>
228 {rootUri !== post.uri && (
229 <View style={{paddingLeft: 16, flexDirection: 'row', height: 16}}>
230 <View style={{width: 38}}>
231 <View
232 style={[
233 styles.replyLine,
234 {
235 flexGrow: 1,
236 backgroundColor: pal.colors.border,
237 },
238 ]}
239 />
240 </View>
241 </View>
242 )}
243
244 <View
245 testID={`postThreadItem-by-${post.author.handle}`}
246 style={[styles.outer, styles.outerHighlighted, pal.border, pal.view]}
247 accessible={false}>
248 <View style={[styles.layout]}>
249 <View style={[styles.layoutAvi, {paddingBottom: 8}]}>
250 <PreviewableUserAvatar
251 size={42}
252 did={post.author.did}
253 handle={post.author.handle}
254 avatar={post.author.avatar}
255 moderation={moderation.ui('avatar')}
256 type={post.author.associated?.labeler ? 'labeler' : 'user'}
257 />
258 </View>
259 <View style={styles.layoutContent}>
260 <View
261 style={[styles.meta, styles.metaExpandedLine1, {zIndex: 1}]}>
262 <Link style={s.flex1} href={authorHref} title={authorTitle}>
263 <Text
264 type="xl-bold"
265 style={[pal.text]}
266 numberOfLines={1}
267 lineHeight={1.2}>
268 {sanitizeDisplayName(
269 post.author.displayName ||
270 sanitizeHandle(post.author.handle),
271 moderation.ui('displayName'),
272 )}
273 </Text>
274 </Link>
275 </View>
276 <View style={styles.meta}>
277 <Link style={s.flex1} href={authorHref} title={authorTitle}>
278 <Text type="md" style={[pal.textLight]} numberOfLines={1}>
279 {sanitizeHandle(post.author.handle, '@')}
280 </Text>
281 </Link>
282 </View>
283 </View>
284 {currentAccount?.did !== post.author.did && (
285 <PostThreadFollowBtn did={post.author.did} />
286 )}
287 </View>
288 <View style={[s.pl10, s.pr10, s.pb10]}>
289 <LabelsOnMyPost post={post} />
290 <ContentHider
291 modui={moderation.ui('contentView')}
292 ignoreMute
293 style={styles.contentHider}
294 childContainerStyle={styles.contentHiderChild}>
295 <PostAlerts
296 modui={moderation.ui('contentView')}
297 includeMute
298 style={[a.pt_2xs, a.pb_sm]}
299 />
300 {richText?.text ? (
301 <View
302 style={[
303 styles.postTextContainer,
304 styles.postTextLargeContainer,
305 ]}>
306 <RichText
307 enableTags
308 selectable
309 value={richText}
310 style={[a.flex_1, a.text_xl]}
311 authorHandle={post.author.handle}
312 />
313 </View>
314 ) : undefined}
315 {post.embed && (
316 <View style={[a.pb_sm]}>
317 <PostEmbeds embed={post.embed} moderation={moderation} />
318 </View>
319 )}
320 </ContentHider>
321 <ExpandedPostDetails
322 post={post}
323 translatorUrl={translatorUrl}
324 needsTranslation={needsTranslation}
325 />
326 {post.repostCount !== 0 || post.likeCount !== 0 ? (
327 // Show this section unless we're *sure* it has no engagement.
328 <View style={[styles.expandedInfo, pal.border]}>
329 {post.repostCount != null && post.repostCount !== 0 ? (
330 <Link
331 style={styles.expandedInfoItem}
332 href={repostsHref}
333 title={repostsTitle}>
334 <Text
335 testID="repostCount-expanded"
336 type="lg"
337 style={pal.textLight}>
338 <Text type="xl-bold" style={pal.text}>
339 {formatCount(post.repostCount)}
340 </Text>{' '}
341 {pluralize(post.repostCount, 'repost')}
342 </Text>
343 </Link>
344 ) : null}
345 {post.likeCount != null && post.likeCount !== 0 ? (
346 <Link
347 style={styles.expandedInfoItem}
348 href={likesHref}
349 title={likesTitle}>
350 <Text
351 testID="likeCount-expanded"
352 type="lg"
353 style={pal.textLight}>
354 <Text type="xl-bold" style={pal.text}>
355 {formatCount(post.likeCount)}
356 </Text>{' '}
357 {pluralize(post.likeCount, 'like')}
358 </Text>
359 </Link>
360 ) : null}
361 </View>
362 ) : null}
363 <View style={[s.pl10, s.pr10, s.pb5]}>
364 <PostCtrls
365 big
366 post={post}
367 record={record}
368 richText={richText}
369 onPressReply={onPressReply}
370 logContext="PostThreadItem"
371 />
372 </View>
373 </View>
374 </View>
375 <WhoCanReply post={post} />
376 </>
377 )
378 } else {
379 const isThreadedChild = treeView && depth > 0
380 const isThreadedChildAdjacentTop =
381 isThreadedChild && prevPost?.ctx.depth === depth && depth !== 1
382 const isThreadedChildAdjacentBot =
383 isThreadedChild && nextPost?.ctx.depth === depth
384 return (
385 <>
386 <PostOuterWrapper
387 post={post}
388 depth={depth}
389 showParentReplyLine={!!showParentReplyLine}
390 treeView={treeView}
391 hasPrecedingItem={hasPrecedingItem}>
392 <PostHider
393 testID={`postThreadItem-by-${post.author.handle}`}
394 href={postHref}
395 style={[pal.view]}
396 modui={moderation.ui('contentList')}
397 iconSize={isThreadedChild ? 26 : 38}
398 iconStyles={
399 isThreadedChild
400 ? {marginRight: 4}
401 : {marginLeft: 2, marginRight: 2}
402 }>
403 <View
404 style={{
405 flexDirection: 'row',
406 gap: 10,
407 paddingLeft: 8,
408 height: isThreadedChildAdjacentTop ? 8 : 16,
409 }}>
410 <View style={{width: 38}}>
411 {!isThreadedChild && showParentReplyLine && (
412 <View
413 style={[
414 styles.replyLine,
415 {
416 flexGrow: 1,
417 backgroundColor: pal.colors.replyLine,
418 marginBottom: 4,
419 },
420 ]}
421 />
422 )}
423 </View>
424 </View>
425
426 <View
427 style={[
428 styles.layout,
429 {
430 paddingBottom:
431 showChildReplyLine && !isThreadedChild
432 ? 0
433 : isThreadedChildAdjacentBot
434 ? 4
435 : 8,
436 },
437 ]}>
438 {/* If we are in threaded mode, the avatar is rendered in PostMeta */}
439 {!isThreadedChild && (
440 <View style={styles.layoutAvi}>
441 <PreviewableUserAvatar
442 size={38}
443 did={post.author.did}
444 handle={post.author.handle}
445 avatar={post.author.avatar}
446 moderation={moderation.ui('avatar')}
447 type={post.author.associated?.labeler ? 'labeler' : 'user'}
448 />
449
450 {showChildReplyLine && (
451 <View
452 style={[
453 styles.replyLine,
454 {
455 flexGrow: 1,
456 backgroundColor: pal.colors.replyLine,
457 marginTop: 4,
458 },
459 ]}
460 />
461 )}
462 </View>
463 )}
464
465 <View
466 style={
467 isThreadedChild
468 ? styles.layoutContentThreaded
469 : styles.layoutContent
470 }>
471 <PostMeta
472 author={post.author}
473 moderation={moderation}
474 authorHasWarning={!!post.author.labels?.length}
475 timestamp={post.indexedAt}
476 postHref={postHref}
477 showAvatar={isThreadedChild}
478 avatarModeration={moderation.ui('avatar')}
479 avatarSize={28}
480 displayNameType="md-bold"
481 displayNameStyle={isThreadedChild && s.ml2}
482 style={
483 isThreadedChild && {
484 alignItems: 'center',
485 paddingBottom: isWeb ? 5 : 2,
486 }
487 }
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 },
679 meta: {
680 flexDirection: 'row',
681 paddingVertical: 2,
682 },
683 metaExpandedLine1: {
684 paddingVertical: 0,
685 },
686 alert: {
687 marginBottom: 6,
688 },
689 postTextContainer: {
690 flexDirection: 'row',
691 alignItems: 'center',
692 flexWrap: 'wrap',
693 paddingBottom: 4,
694 paddingRight: 10,
695 },
696 postTextLargeContainer: {
697 paddingHorizontal: 0,
698 paddingRight: 0,
699 paddingBottom: 10,
700 },
701 translateLink: {
702 marginBottom: 6,
703 },
704 contentHider: {
705 marginBottom: 6,
706 },
707 contentHiderChild: {
708 marginTop: 6,
709 },
710 expandedInfo: {
711 flexDirection: 'row',
712 padding: 10,
713 borderTopWidth: 1,
714 borderBottomWidth: 1,
715 marginTop: 5,
716 marginBottom: 15,
717 },
718 expandedInfoItem: {
719 marginRight: 10,
720 },
721 loadMore: {
722 flexDirection: 'row',
723 alignItems: 'center',
724 justifyContent: 'flex-start',
725 gap: 4,
726 paddingHorizontal: 20,
727 },
728 replyLine: {
729 width: 2,
730 marginLeft: 'auto',
731 marginRight: 'auto',
732 },
733 cursor: {
734 // @ts-ignore web only
735 cursor: 'pointer',
736 },
737})