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