this repo has no description
1import {memo, useCallback, useMemo, useState} from 'react'
2import {StyleSheet, View} from 'react-native'
3import {
4 type AppBskyActorDefs,
5 AppBskyFeedDefs,
6 AppBskyFeedPost,
7 AppBskyFeedThreadgate,
8 AtUri,
9 type ModerationDecision,
10 RichText as RichTextAPI,
11} from '@atproto/api'
12import {useQueryClient} from '@tanstack/react-query'
13
14import {type ReasonFeedSource} from '#/lib/api/feed/types'
15import {MAX_POST_LINES} from '#/lib/constants'
16import {useOpenComposer} from '#/lib/hooks/useOpenComposer'
17import {usePalette} from '#/lib/hooks/usePalette'
18import {makeProfileLink} from '#/lib/routes/links'
19import {countLines} from '#/lib/strings/helpers'
20import {
21 POST_TOMBSTONE,
22 type Shadow,
23 usePostShadow,
24} from '#/state/cache/post-shadow'
25import {useFeedFeedbackContext} from '#/state/feed-feedback'
26import {unstableCacheProfileView} from '#/state/queries/profile'
27import {useSession} from '#/state/session'
28import {useMergedThreadgateHiddenReplies} from '#/state/threadgate-hidden-replies'
29import {
30 buildPostSourceKey,
31 setUnstablePostSource,
32} from '#/state/unstable-post-source'
33import {Link} from '#/view/com/util/Link'
34import {PostMeta} from '#/view/com/util/PostMeta'
35import {PreviewableUserAvatar} from '#/view/com/util/UserAvatar'
36import {atoms as a} from '#/alf'
37import {ContentHider} from '#/components/moderation/ContentHider'
38import {LabelsOnMyPost} from '#/components/moderation/LabelsOnMe'
39import {PostAlerts} from '#/components/moderation/PostAlerts'
40import {type AppModerationCause} from '#/components/Pills'
41import {Embed} from '#/components/Post/Embed'
42import {PostEmbedViewContext} from '#/components/Post/Embed/types'
43import {PostRepliedTo} from '#/components/Post/PostRepliedTo'
44import {ShowMoreTextButton} from '#/components/Post/ShowMoreTextButton'
45import {TranslatedPost} from '#/components/Post/Translated'
46import {PostControls} from '#/components/PostControls'
47import {DiscoverDebug} from '#/components/PostControls/DiscoverDebug'
48import {RichText} from '#/components/RichText'
49import {SubtleHover} from '#/components/SubtleHover'
50import {useAnalytics} from '#/analytics'
51import {useActorStatus} from '#/features/liveNow'
52import * as bsky from '#/types/bsky'
53import {PostFeedReason} from './PostFeedReason'
54
55interface FeedItemProps {
56 record: AppBskyFeedPost.Record
57 reason:
58 | AppBskyFeedDefs.ReasonRepost
59 | AppBskyFeedDefs.ReasonPin
60 | ReasonFeedSource
61 | {[k: string]: unknown; $type: string}
62 | undefined
63 moderation: ModerationDecision
64 parentAuthor: AppBskyActorDefs.ProfileViewBasic | undefined
65 showReplyTo: boolean
66 isThreadChild?: boolean
67 isThreadLastChild?: boolean
68 isThreadParent?: boolean
69 feedContext: string | undefined
70 reqId: string | undefined
71 hideTopBorder?: boolean
72 isParentBlocked?: boolean
73 isParentNotFound?: boolean
74}
75
76export function PostFeedItem({
77 post,
78 record,
79 reason,
80 feedContext,
81 reqId,
82 moderation,
83 parentAuthor,
84 showReplyTo,
85 isThreadChild,
86 isThreadLastChild,
87 isThreadParent,
88 hideTopBorder,
89 isParentBlocked,
90 isParentNotFound,
91 rootPost,
92 onShowLess,
93}: FeedItemProps & {
94 post: AppBskyFeedDefs.PostView
95 rootPost: AppBskyFeedDefs.PostView
96 onShowLess?: (interaction: AppBskyFeedDefs.Interaction) => void
97}): React.ReactNode {
98 const postShadowed = usePostShadow(post)
99 const richText = useMemo(
100 () =>
101 new RichTextAPI({
102 text: record.text,
103 facets: record.facets,
104 }),
105 [record],
106 )
107 if (postShadowed === POST_TOMBSTONE) {
108 return null
109 }
110 if (richText && moderation) {
111 return (
112 <FeedItemInner
113 // Safeguard from clobbering per-post state below:
114 key={postShadowed.uri}
115 post={postShadowed}
116 record={record}
117 reason={reason}
118 feedContext={feedContext}
119 reqId={reqId}
120 richText={richText}
121 parentAuthor={parentAuthor}
122 showReplyTo={showReplyTo}
123 moderation={moderation}
124 isThreadChild={isThreadChild}
125 isThreadLastChild={isThreadLastChild}
126 isThreadParent={isThreadParent}
127 hideTopBorder={hideTopBorder}
128 isParentBlocked={isParentBlocked}
129 isParentNotFound={isParentNotFound}
130 rootPost={rootPost}
131 onShowLess={onShowLess}
132 />
133 )
134 }
135 return null
136}
137
138let FeedItemInner = ({
139 post,
140 record,
141 reason,
142 feedContext,
143 reqId,
144 richText,
145 moderation,
146 parentAuthor,
147 showReplyTo,
148 isThreadChild,
149 isThreadLastChild,
150 isThreadParent,
151 hideTopBorder,
152 isParentBlocked,
153 isParentNotFound,
154 rootPost,
155 onShowLess,
156}: FeedItemProps & {
157 richText: RichTextAPI
158 post: Shadow<AppBskyFeedDefs.PostView>
159 rootPost: AppBskyFeedDefs.PostView
160 onShowLess?: (interaction: AppBskyFeedDefs.Interaction) => void
161}): React.ReactNode => {
162 const ax = useAnalytics()
163 const queryClient = useQueryClient()
164 const {openComposer} = useOpenComposer()
165 const pal = usePalette('default')
166
167 const [hover, setHover] = useState(false)
168
169 const [href] = useMemo(() => {
170 const urip = new AtUri(post.uri)
171 return [makeProfileLink(post.author, 'post', urip.rkey), urip.rkey]
172 }, [post.uri, post.author])
173 const {sendInteraction, feedSourceInfo, feedDescriptor} =
174 useFeedFeedbackContext()
175
176 const onPressReply = () => {
177 sendInteraction({
178 item: post.uri,
179 event: 'app.bsky.feed.defs#interactionReply',
180 feedContext,
181 reqId,
182 })
183 openComposer({
184 replyTo: {
185 uri: post.uri,
186 cid: post.cid,
187 text: record.text || '',
188 author: post.author,
189 embed: post.embed,
190 moderation,
191 langs: record.langs,
192 },
193 logContext: 'PostReply',
194 })
195 }
196
197 const onOpenAuthor = () => {
198 sendInteraction({
199 item: post.uri,
200 event: 'app.bsky.feed.defs#clickthroughAuthor',
201 feedContext,
202 reqId,
203 })
204 ax.metric('post:clickthroughAuthor', {
205 uri: post.uri,
206 authorDid: post.author.did,
207 logContext: 'FeedItem',
208 feedDescriptor,
209 })
210 }
211
212 const onOpenReposter = () => {
213 sendInteraction({
214 item: post.uri,
215 event: 'app.bsky.feed.defs#clickthroughReposter',
216 feedContext,
217 reqId,
218 })
219 }
220
221 const onOpenEmbed = () => {
222 sendInteraction({
223 item: post.uri,
224 event: 'app.bsky.feed.defs#clickthroughEmbed',
225 feedContext,
226 reqId,
227 })
228 ax.metric('post:clickthroughEmbed', {
229 uri: post.uri,
230 authorDid: post.author.did,
231 logContext: 'FeedItem',
232 feedDescriptor,
233 })
234 }
235
236 const onBeforePress = () => {
237 sendInteraction({
238 item: post.uri,
239 event: 'app.bsky.feed.defs#clickthroughItem',
240 feedContext,
241 reqId,
242 })
243 ax.metric('post:clickthroughItem', {
244 uri: post.uri,
245 authorDid: post.author.did,
246 logContext: 'FeedItem',
247 feedDescriptor,
248 })
249 unstableCacheProfileView(queryClient, post.author)
250 setUnstablePostSource(buildPostSourceKey(post.uri, post.author.handle), {
251 feedSourceInfo,
252 post: {
253 post,
254 reason: AppBskyFeedDefs.isReasonRepost(reason) ? reason : undefined,
255 feedContext,
256 reqId,
257 },
258 })
259 }
260
261 const outerStyles = [
262 styles.outer,
263 {
264 borderColor: pal.colors.border,
265 paddingBottom:
266 isThreadLastChild || (!isThreadChild && !isThreadParent)
267 ? 8
268 : undefined,
269 borderTopWidth:
270 hideTopBorder || isThreadChild ? 0 : StyleSheet.hairlineWidth,
271 },
272 ]
273
274 /**
275 * If `post[0]` in this slice is the actual root post (not an orphan thread),
276 * then we may have a threadgate record to reference
277 */
278 const threadgateRecord = bsky.dangerousIsType<AppBskyFeedThreadgate.Record>(
279 rootPost.threadgate?.record,
280 AppBskyFeedThreadgate.isRecord,
281 )
282 ? rootPost.threadgate.record
283 : undefined
284
285 const {isActive: live} = useActorStatus(post.author)
286
287 const viaRepost = useMemo(() => {
288 if (AppBskyFeedDefs.isReasonRepost(reason) && reason.uri && reason.cid) {
289 return {
290 uri: reason.uri,
291 cid: reason.cid,
292 }
293 }
294 }, [reason])
295
296 return (
297 <Link
298 testID={`feedItem-by-${post.author.handle}`}
299 style={outerStyles}
300 href={href}
301 noFeedback
302 accessible={false}
303 onBeforePress={onBeforePress}
304 dataSet={{feedContext}}
305 onPointerEnter={() => {
306 setHover(true)
307 }}
308 onPointerLeave={() => {
309 setHover(false)
310 }}>
311 <SubtleHover hover={hover} />
312 <View style={{flexDirection: 'row', gap: 10, paddingLeft: 8}}>
313 <View style={{width: 42}}>
314 {isThreadChild && (
315 <View
316 style={[
317 styles.replyLine,
318 {
319 flexGrow: 1,
320 backgroundColor: pal.colors.replyLine,
321 marginBottom: 4,
322 },
323 ]}
324 />
325 )}
326 </View>
327
328 <View style={[a.pt_sm, a.flex_shrink]}>
329 {reason && (
330 <PostFeedReason
331 reason={reason}
332 moderation={moderation}
333 onOpenReposter={onOpenReposter}
334 />
335 )}
336 </View>
337 </View>
338
339 <View style={styles.layout}>
340 <View style={styles.layoutAvi}>
341 <PreviewableUserAvatar
342 size={42}
343 profile={post.author}
344 moderation={moderation.ui('avatar')}
345 type={post.author.associated?.labeler ? 'labeler' : 'user'}
346 onBeforePress={onOpenAuthor}
347 live={live}
348 />
349 {isThreadParent && (
350 <View
351 style={[
352 styles.replyLine,
353 {
354 flexGrow: 1,
355 backgroundColor: pal.colors.replyLine,
356 marginTop: live ? 8 : 4,
357 },
358 ]}
359 />
360 )}
361 </View>
362 <View style={styles.layoutContent}>
363 <PostMeta
364 author={post.author}
365 moderation={moderation}
366 timestamp={post.indexedAt}
367 postHref={href}
368 onOpenAuthor={onOpenAuthor}
369 />
370 {showReplyTo &&
371 (parentAuthor || isParentBlocked || isParentNotFound) && (
372 <PostRepliedTo
373 parentAuthor={parentAuthor}
374 isParentBlocked={isParentBlocked}
375 isParentNotFound={isParentNotFound}
376 />
377 )}
378 <LabelsOnMyPost post={post} />
379 <PostContent
380 moderation={moderation}
381 richText={richText}
382 postEmbed={post.embed}
383 postAuthor={post.author}
384 onOpenEmbed={onOpenEmbed}
385 post={post}
386 threadgateRecord={threadgateRecord}
387 />
388 <PostControls
389 post={post}
390 record={record}
391 richText={richText}
392 onPressReply={onPressReply}
393 logContext="FeedItem"
394 feedContext={feedContext}
395 reqId={reqId}
396 threadgateRecord={threadgateRecord}
397 onShowLess={onShowLess}
398 viaRepost={viaRepost}
399 />
400 </View>
401
402 <DiscoverDebug feedContext={feedContext} />
403 </View>
404 </Link>
405 )
406}
407FeedItemInner = memo(FeedItemInner)
408
409let PostContent = ({
410 post,
411 moderation,
412 richText,
413 postEmbed,
414 postAuthor,
415 onOpenEmbed,
416 threadgateRecord,
417}: {
418 moderation: ModerationDecision
419 richText: RichTextAPI
420 postEmbed: AppBskyFeedDefs.PostView['embed']
421 postAuthor: AppBskyFeedDefs.PostView['author']
422 onOpenEmbed: () => void
423 post: AppBskyFeedDefs.PostView
424 threadgateRecord?: AppBskyFeedThreadgate.Record
425}): React.ReactNode => {
426 const {currentAccount} = useSession()
427 const [limitLines, setLimitLines] = useState(
428 () => countLines(richText.text) >= MAX_POST_LINES,
429 )
430 const threadgateHiddenReplies = useMergedThreadgateHiddenReplies({
431 threadgateRecord,
432 })
433 const additionalPostAlerts: AppModerationCause[] = useMemo(() => {
434 const isPostHiddenByThreadgate = threadgateHiddenReplies.has(post.uri)
435 const rootPostUri = bsky.dangerousIsType<AppBskyFeedPost.Record>(
436 post.record,
437 AppBskyFeedPost.isRecord,
438 )
439 ? post.record?.reply?.root?.uri || post.uri
440 : undefined
441 const isControlledByViewer =
442 rootPostUri && new AtUri(rootPostUri).host === currentAccount?.did
443 return isControlledByViewer && isPostHiddenByThreadgate
444 ? [
445 {
446 type: 'reply-hidden',
447 source: {type: 'user', did: currentAccount?.did},
448 priority: 6,
449 },
450 ]
451 : []
452 }, [post, currentAccount?.did, threadgateHiddenReplies])
453
454 const record = useMemo<AppBskyFeedPost.Record | undefined>(
455 () =>
456 bsky.validate(post.record, AppBskyFeedPost.validateRecord)
457 ? post.record
458 : undefined,
459 [post],
460 )
461
462 const onPressShowMore = useCallback(() => {
463 setLimitLines(false)
464 }, [setLimitLines])
465
466 return (
467 <ContentHider
468 testID="contentHider-post"
469 modui={moderation.ui('contentList')}
470 ignoreMute
471 childContainerStyle={styles.contentHiderChild}>
472 <PostAlerts
473 modui={moderation.ui('contentList')}
474 style={[a.pb_xs]}
475 additionalCauses={additionalPostAlerts}
476 />
477 {richText.text ? (
478 <View style={[a.mb_2xs]}>
479 <RichText
480 enableTags
481 testID="postText"
482 value={richText}
483 numberOfLines={limitLines ? MAX_POST_LINES : undefined}
484 style={[a.flex_1, a.text_md]}
485 authorHandle={postAuthor.handle}
486 shouldProxyLinks={true}
487 />
488 {limitLines && (
489 <ShowMoreTextButton style={[a.text_md]} onPress={onPressShowMore} />
490 )}
491 </View>
492 ) : undefined}
493 {record && <TranslatedPost hideTranslateLink post={post} />}
494 {postEmbed ? (
495 <View style={[a.pb_xs]}>
496 <Embed
497 embed={postEmbed}
498 moderation={moderation}
499 onOpen={onOpenEmbed}
500 viewContext={PostEmbedViewContext.Feed}
501 />
502 </View>
503 ) : null}
504 </ContentHider>
505 )
506}
507PostContent = memo(PostContent)
508
509const styles = StyleSheet.create({
510 outer: {
511 paddingLeft: 10,
512 paddingRight: 15,
513 cursor: 'pointer',
514 },
515 replyLine: {
516 width: 2,
517 marginLeft: 'auto',
518 marginRight: 'auto',
519 },
520 layout: {
521 flexDirection: 'row',
522 marginTop: 1,
523 },
524 layoutAvi: {
525 paddingLeft: 8,
526 paddingRight: 10,
527 position: 'relative',
528 zIndex: 999,
529 },
530 layoutContent: {
531 position: 'relative',
532 flex: 1,
533 zIndex: 0,
534 },
535 alert: {
536 marginTop: 6,
537 marginBottom: 6,
538 },
539 contentHiderChild: {
540 marginTop: 6,
541 },
542 embed: {
543 marginBottom: 6,
544 },
545 translateLink: {
546 marginBottom: 6,
547 },
548})