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