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