mirror of https://git.lenooby09.tech/LeNooby09/social-app.git
1import React, {memo, useMemo, useState} from 'react'
2import {StyleSheet, View} from 'react-native'
3import {
4 AppBskyActorDefs,
5 AppBskyFeedDefs,
6 AppBskyFeedPost,
7 AtUri,
8 ModerationDecision,
9 RichText as RichTextAPI,
10} from '@atproto/api'
11import {
12 FontAwesomeIcon,
13 FontAwesomeIconStyle,
14} from '@fortawesome/react-native-fontawesome'
15import {msg, Trans} from '@lingui/macro'
16import {useLingui} from '@lingui/react'
17import {useQueryClient} from '@tanstack/react-query'
18
19import {POST_TOMBSTONE, Shadow, usePostShadow} from '#/state/cache/post-shadow'
20import {useFeedFeedbackContext} from '#/state/feed-feedback'
21import {useComposerControls} from '#/state/shell/composer'
22import {isReasonFeedSource, ReasonFeedSource} from 'lib/api/feed/types'
23import {MAX_POST_LINES} from 'lib/constants'
24import {usePalette} from 'lib/hooks/usePalette'
25import {makeProfileLink} from 'lib/routes/links'
26import {sanitizeDisplayName} from 'lib/strings/display-names'
27import {sanitizeHandle} from 'lib/strings/handles'
28import {countLines} from 'lib/strings/helpers'
29import {s} from 'lib/styles'
30import {precacheProfile} from 'state/queries/profile'
31import {atoms as a} from '#/alf'
32import {ContentHider} from '#/components/moderation/ContentHider'
33import {ProfileHoverCard} from '#/components/ProfileHoverCard'
34import {RichText} from '#/components/RichText'
35import {LabelsOnMyPost} from '../../../components/moderation/LabelsOnMe'
36import {PostAlerts} from '../../../components/moderation/PostAlerts'
37import {FeedNameText} from '../util/FeedInfoText'
38import {Link, TextLink, TextLinkOnWebOnly} from '../util/Link'
39import {PostCtrls} from '../util/post-ctrls/PostCtrls'
40import {PostEmbeds} from '../util/post-embeds'
41import {PostMeta} from '../util/PostMeta'
42import {Text} from '../util/text/Text'
43import {PreviewableUserAvatar} from '../util/UserAvatar'
44import {AviFollowButton} from './AviFollowButton'
45import hairlineWidth = StyleSheet.hairlineWidth
46import {Repost_Stroke2_Corner2_Rounded as Repost} from '#/components/icons/Repost'
47
48interface FeedItemProps {
49 record: AppBskyFeedPost.Record
50 reason: AppBskyFeedDefs.ReasonRepost | ReasonFeedSource | undefined
51 moderation: ModerationDecision
52 parentAuthor: AppBskyActorDefs.ProfileViewBasic | undefined
53 showReplyTo: boolean
54 isThreadChild?: boolean
55 isThreadLastChild?: boolean
56 isThreadParent?: boolean
57 feedContext: string | undefined
58 hideTopBorder?: boolean
59 isParentBlocked?: boolean
60}
61
62export function FeedItem({
63 post,
64 record,
65 reason,
66 feedContext,
67 moderation,
68 parentAuthor,
69 showReplyTo,
70 isThreadChild,
71 isThreadLastChild,
72 isThreadParent,
73 hideTopBorder,
74 isParentBlocked,
75}: FeedItemProps & {post: AppBskyFeedDefs.PostView}): React.ReactNode {
76 const postShadowed = usePostShadow(post)
77 const richText = useMemo(
78 () =>
79 new RichTextAPI({
80 text: record.text,
81 facets: record.facets,
82 }),
83 [record],
84 )
85 if (postShadowed === POST_TOMBSTONE) {
86 return null
87 }
88 if (richText && moderation) {
89 return (
90 <FeedItemInner
91 // Safeguard from clobbering per-post state below:
92 key={postShadowed.uri}
93 post={postShadowed}
94 record={record}
95 reason={reason}
96 feedContext={feedContext}
97 richText={richText}
98 parentAuthor={parentAuthor}
99 showReplyTo={showReplyTo}
100 moderation={moderation}
101 isThreadChild={isThreadChild}
102 isThreadLastChild={isThreadLastChild}
103 isThreadParent={isThreadParent}
104 hideTopBorder={hideTopBorder}
105 isParentBlocked={isParentBlocked}
106 />
107 )
108 }
109 return null
110}
111
112let FeedItemInner = ({
113 post,
114 record,
115 reason,
116 feedContext,
117 richText,
118 moderation,
119 parentAuthor,
120 showReplyTo,
121 isThreadChild,
122 isThreadLastChild,
123 isThreadParent,
124 hideTopBorder,
125 isParentBlocked,
126}: FeedItemProps & {
127 richText: RichTextAPI
128 post: Shadow<AppBskyFeedDefs.PostView>
129}): React.ReactNode => {
130 const queryClient = useQueryClient()
131 const {openComposer} = useComposerControls()
132 const pal = usePalette('default')
133 const {_} = useLingui()
134 const href = useMemo(() => {
135 const urip = new AtUri(post.uri)
136 return makeProfileLink(post.author, 'post', urip.rkey)
137 }, [post.uri, post.author])
138 const {sendInteraction} = useFeedFeedbackContext()
139
140 const onPressReply = React.useCallback(() => {
141 sendInteraction({
142 item: post.uri,
143 event: 'app.bsky.feed.defs#interactionReply',
144 feedContext,
145 })
146 openComposer({
147 replyTo: {
148 uri: post.uri,
149 cid: post.cid,
150 text: record.text || '',
151 author: post.author,
152 embed: post.embed,
153 moderation,
154 },
155 })
156 }, [post, record, openComposer, moderation, sendInteraction, feedContext])
157
158 const onOpenAuthor = React.useCallback(() => {
159 sendInteraction({
160 item: post.uri,
161 event: 'app.bsky.feed.defs#clickthroughAuthor',
162 feedContext,
163 })
164 }, [sendInteraction, post, feedContext])
165
166 const onOpenReposter = React.useCallback(() => {
167 sendInteraction({
168 item: post.uri,
169 event: 'app.bsky.feed.defs#clickthroughReposter',
170 feedContext,
171 })
172 }, [sendInteraction, post, feedContext])
173
174 const onOpenEmbed = React.useCallback(() => {
175 sendInteraction({
176 item: post.uri,
177 event: 'app.bsky.feed.defs#clickthroughEmbed',
178 feedContext,
179 })
180 }, [sendInteraction, post, feedContext])
181
182 const onBeforePress = React.useCallback(() => {
183 sendInteraction({
184 item: post.uri,
185 event: 'app.bsky.feed.defs#clickthroughItem',
186 feedContext,
187 })
188 precacheProfile(queryClient, post.author)
189 }, [queryClient, post, sendInteraction, feedContext])
190
191 const outerStyles = [
192 styles.outer,
193 {
194 borderColor: pal.colors.border,
195 paddingBottom:
196 isThreadLastChild || (!isThreadChild && !isThreadParent)
197 ? 8
198 : undefined,
199 borderTopWidth: hideTopBorder || isThreadChild ? 0 : hairlineWidth,
200 },
201 ]
202
203 return (
204 <Link
205 testID={`feedItem-by-${post.author.handle}`}
206 style={outerStyles}
207 href={href}
208 noFeedback
209 accessible={false}
210 onBeforePress={onBeforePress}
211 dataSet={{feedContext}}>
212 <View style={{flexDirection: 'row', gap: 10, paddingLeft: 8}}>
213 <View style={{width: 52}}>
214 {isThreadChild && (
215 <View
216 style={[
217 styles.replyLine,
218 {
219 flexGrow: 1,
220 backgroundColor: pal.colors.replyLine,
221 marginBottom: 4,
222 },
223 ]}
224 />
225 )}
226 </View>
227
228 <View style={{paddingTop: 12, flexShrink: 1}}>
229 {isReasonFeedSource(reason) ? (
230 <Link href={reason.href}>
231 <Text
232 type="sm-bold"
233 style={pal.textLight}
234 lineHeight={1.2}
235 numberOfLines={1}>
236 <Trans context="from-feed">
237 From{' '}
238 <FeedNameText
239 type="sm-bold"
240 uri={reason.uri}
241 href={reason.href}
242 lineHeight={1.2}
243 numberOfLines={1}
244 style={pal.textLight}
245 />
246 </Trans>
247 </Text>
248 </Link>
249 ) : AppBskyFeedDefs.isReasonRepost(reason) ? (
250 <Link
251 style={styles.includeReason}
252 href={makeProfileLink(reason.by)}
253 title={_(
254 msg`Reposted by ${sanitizeDisplayName(
255 reason.by.displayName || reason.by.handle,
256 )}`,
257 )}
258 onBeforePress={onOpenReposter}>
259 <Repost
260 style={{color: pal.colors.textLight, marginRight: 3}}
261 width={14}
262 height={14}
263 />
264 <Text
265 type="sm-bold"
266 style={pal.textLight}
267 lineHeight={1.2}
268 numberOfLines={1}>
269 <Trans>
270 Reposted by{' '}
271 <ProfileHoverCard inline did={reason.by.did}>
272 <TextLinkOnWebOnly
273 type="sm-bold"
274 style={pal.textLight}
275 lineHeight={1.2}
276 numberOfLines={1}
277 text={sanitizeDisplayName(
278 reason.by.displayName ||
279 sanitizeHandle(reason.by.handle),
280 moderation.ui('displayName'),
281 )}
282 href={makeProfileLink(reason.by)}
283 onBeforePress={onOpenReposter}
284 />
285 </ProfileHoverCard>
286 </Trans>
287 </Text>
288 </Link>
289 ) : null}
290 </View>
291 </View>
292
293 <View style={styles.layout}>
294 <View style={styles.layoutAvi}>
295 <AviFollowButton author={post.author} moderation={moderation}>
296 <PreviewableUserAvatar
297 size={52}
298 profile={post.author}
299 moderation={moderation.ui('avatar')}
300 type={post.author.associated?.labeler ? 'labeler' : 'user'}
301 onBeforePress={onOpenAuthor}
302 />
303 </AviFollowButton>
304 {isThreadParent && (
305 <View
306 style={[
307 styles.replyLine,
308 {
309 flexGrow: 1,
310 backgroundColor: pal.colors.replyLine,
311 marginTop: 4,
312 },
313 ]}
314 />
315 )}
316 </View>
317 <View style={styles.layoutContent}>
318 <PostMeta
319 author={post.author}
320 moderation={moderation}
321 authorHasWarning={!!post.author.labels?.length}
322 timestamp={post.indexedAt}
323 postHref={href}
324 onOpenAuthor={onOpenAuthor}
325 />
326 {!isThreadChild && showReplyTo && parentAuthor && (
327 <ReplyToLabel blocked={isParentBlocked} profile={parentAuthor} />
328 )}
329 <LabelsOnMyPost post={post} />
330 <PostContent
331 moderation={moderation}
332 richText={richText}
333 postEmbed={post.embed}
334 postAuthor={post.author}
335 onOpenEmbed={onOpenEmbed}
336 />
337 <PostCtrls
338 post={post}
339 record={record}
340 richText={richText}
341 onPressReply={onPressReply}
342 logContext="FeedItem"
343 feedContext={feedContext}
344 />
345 </View>
346 </View>
347 </Link>
348 )
349}
350FeedItemInner = memo(FeedItemInner)
351
352let PostContent = ({
353 moderation,
354 richText,
355 postEmbed,
356 postAuthor,
357 onOpenEmbed,
358}: {
359 moderation: ModerationDecision
360 richText: RichTextAPI
361 postEmbed: AppBskyFeedDefs.PostView['embed']
362 postAuthor: AppBskyFeedDefs.PostView['author']
363 onOpenEmbed: () => void
364}): React.ReactNode => {
365 const pal = usePalette('default')
366 const {_} = useLingui()
367 const [limitLines, setLimitLines] = useState(
368 () => countLines(richText.text) >= MAX_POST_LINES,
369 )
370
371 const onPressShowMore = React.useCallback(() => {
372 setLimitLines(false)
373 }, [setLimitLines])
374
375 return (
376 <ContentHider
377 testID="contentHider-post"
378 modui={moderation.ui('contentList')}
379 ignoreMute
380 childContainerStyle={styles.contentHiderChild}>
381 <PostAlerts modui={moderation.ui('contentList')} style={[a.py_2xs]} />
382 {richText.text ? (
383 <View style={styles.postTextContainer}>
384 <RichText
385 enableTags
386 testID="postText"
387 value={richText}
388 numberOfLines={limitLines ? MAX_POST_LINES : undefined}
389 style={[a.flex_1, a.text_md]}
390 authorHandle={postAuthor.handle}
391 />
392 </View>
393 ) : undefined}
394 {limitLines ? (
395 <TextLink
396 text={_(msg`Show More`)}
397 style={pal.link}
398 onPress={onPressShowMore}
399 href="#"
400 />
401 ) : undefined}
402 {postEmbed ? (
403 <View style={[a.pb_xs]}>
404 <PostEmbeds
405 embed={postEmbed}
406 moderation={moderation}
407 onOpen={onOpenEmbed}
408 />
409 </View>
410 ) : null}
411 </ContentHider>
412 )
413}
414PostContent = memo(PostContent)
415
416function ReplyToLabel({
417 profile,
418 blocked,
419}: {
420 profile: AppBskyActorDefs.ProfileViewBasic
421 blocked?: boolean
422}) {
423 const pal = usePalette('default')
424 return (
425 <View style={[s.flexRow, s.mb2, s.alignCenter]}>
426 <FontAwesomeIcon
427 icon="reply"
428 size={9}
429 style={[{color: pal.colors.textLight} as FontAwesomeIconStyle, s.mr5]}
430 />
431 <Text
432 type="md"
433 style={[pal.textLight, s.mr2]}
434 lineHeight={1.2}
435 numberOfLines={1}>
436 {blocked ? (
437 <Trans context="description">Reply to a blocked post</Trans>
438 ) : (
439 <Trans context="description">
440 Reply to{' '}
441 <ProfileHoverCard inline did={profile.did}>
442 <TextLinkOnWebOnly
443 type="md"
444 style={pal.textLight}
445 lineHeight={1.2}
446 numberOfLines={1}
447 href={makeProfileLink(profile)}
448 text={
449 profile.displayName
450 ? sanitizeDisplayName(profile.displayName)
451 : sanitizeHandle(profile.handle)
452 }
453 />
454 </ProfileHoverCard>
455 </Trans>
456 )}
457 </Text>
458 </View>
459 )
460}
461
462const styles = StyleSheet.create({
463 outer: {
464 paddingLeft: 10,
465 paddingRight: 15,
466 // @ts-ignore web only -prf
467 cursor: 'pointer',
468 overflow: 'hidden',
469 },
470 replyLine: {
471 width: 2,
472 marginLeft: 'auto',
473 marginRight: 'auto',
474 },
475 includeReason: {
476 flexDirection: 'row',
477 alignItems: 'center',
478 marginTop: 2,
479 marginBottom: 2,
480 marginLeft: -18,
481 },
482 layout: {
483 flexDirection: 'row',
484 marginTop: 1,
485 gap: 10,
486 },
487 layoutAvi: {
488 paddingLeft: 8,
489 position: 'relative',
490 zIndex: 999,
491 },
492 layoutContent: {
493 position: 'relative',
494 flex: 1,
495 zIndex: 0,
496 },
497 alert: {
498 marginTop: 6,
499 marginBottom: 6,
500 },
501 postTextContainer: {
502 flexDirection: 'row',
503 alignItems: 'center',
504 flexWrap: 'wrap',
505 paddingBottom: 2,
506 },
507 contentHiderChild: {
508 marginTop: 6,
509 },
510 embed: {
511 marginBottom: 6,
512 },
513 translateLink: {
514 marginBottom: 6,
515 },
516})