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