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