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 {useActorStatus} from '#/lib/actor-status'
15import {type ReasonFeedSource} from '#/lib/api/feed/types'
16import {MAX_POST_LINES} from '#/lib/constants'
17import {useOpenComposer} from '#/lib/hooks/useOpenComposer'
18import {usePalette} from '#/lib/hooks/usePalette'
19import {makeProfileLink} from '#/lib/routes/links'
20import {countLines} from '#/lib/strings/helpers'
21import {
22 POST_TOMBSTONE,
23 type Shadow,
24 usePostShadow,
25} from '#/state/cache/post-shadow'
26import {useFeedFeedbackContext} from '#/state/feed-feedback'
27import {unstableCacheProfileView} from '#/state/queries/profile'
28import {useSession} from '#/state/session'
29import {useMergedThreadgateHiddenReplies} from '#/state/threadgate-hidden-replies'
30import {
31 buildPostSourceKey,
32 setUnstablePostSource,
33} from '#/state/unstable-post-source'
34import {Link} from '#/view/com/util/Link'
35import {PostMeta} from '#/view/com/util/PostMeta'
36import {PreviewableUserAvatar} from '#/view/com/util/UserAvatar'
37import {atoms as a} from '#/alf'
38import {ContentHider} from '#/components/moderation/ContentHider'
39import {LabelsOnMyPost} from '#/components/moderation/LabelsOnMe'
40import {PostAlerts} from '#/components/moderation/PostAlerts'
41import {type AppModerationCause} from '#/components/Pills'
42import {Embed} from '#/components/Post/Embed'
43import {PostEmbedViewContext} from '#/components/Post/Embed/types'
44import {PostRepliedTo} from '#/components/Post/PostRepliedTo'
45import {ShowMoreTextButton} from '#/components/Post/ShowMoreTextButton'
46import {PostControls} from '#/components/PostControls'
47import {DiscoverDebug} from '#/components/PostControls/DiscoverDebug'
48import {RichText} from '#/components/RichText'
49import {SubtleHover} from '#/components/SubtleHover'
50import {useAnalytics} from '#/analytics'
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 })
197 }
198
199 const onOpenAuthor = () => {
200 sendInteraction({
201 item: post.uri,
202 event: 'app.bsky.feed.defs#clickthroughAuthor',
203 feedContext,
204 reqId,
205 })
206 ax.metric('post:clickthroughAuthor', {
207 uri: post.uri,
208 authorDid: post.author.did,
209 logContext: 'FeedItem',
210 feedDescriptor,
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 ax.metric('post:clickthroughEmbed', {
231 uri: post.uri,
232 authorDid: post.author.did,
233 logContext: 'FeedItem',
234 feedDescriptor,
235 })
236 }
237
238 const onBeforePress = () => {
239 sendInteraction({
240 item: post.uri,
241 event: 'app.bsky.feed.defs#clickthroughItem',
242 feedContext,
243 reqId,
244 })
245 ax.metric('post:clickthroughItem', {
246 uri: post.uri,
247 authorDid: post.author.did,
248 logContext: 'FeedItem',
249 feedDescriptor,
250 })
251 unstableCacheProfileView(queryClient, post.author)
252 setUnstablePostSource(buildPostSourceKey(post.uri, post.author.handle), {
253 feedSourceInfo,
254 post: {
255 post,
256 reason: AppBskyFeedDefs.isReasonRepost(reason) ? reason : undefined,
257 feedContext,
258 reqId,
259 },
260 })
261 }
262
263 const outerStyles = [
264 styles.outer,
265 {
266 borderColor: pal.colors.border,
267 paddingBottom:
268 isThreadLastChild || (!isThreadChild && !isThreadParent)
269 ? 8
270 : undefined,
271 borderTopWidth:
272 hideTopBorder || isThreadChild ? 0 : StyleSheet.hairlineWidth,
273 },
274 ]
275
276 /**
277 * If `post[0]` in this slice is the actual root post (not an orphan thread),
278 * then we may have a threadgate record to reference
279 */
280 const threadgateRecord = bsky.dangerousIsType<AppBskyFeedThreadgate.Record>(
281 rootPost.threadgate?.record,
282 AppBskyFeedThreadgate.isRecord,
283 )
284 ? rootPost.threadgate.record
285 : undefined
286
287 const {isActive: live} = useActorStatus(post.author)
288
289 const viaRepost = useMemo(() => {
290 if (AppBskyFeedDefs.isReasonRepost(reason) && reason.uri && reason.cid) {
291 return {
292 uri: reason.uri,
293 cid: reason.cid,
294 }
295 }
296 }, [reason])
297
298 return (
299 <Link
300 testID={`feedItem-by-${post.author.handle}`}
301 style={outerStyles}
302 href={href}
303 noFeedback
304 accessible={false}
305 onBeforePress={onBeforePress}
306 dataSet={{feedContext}}
307 onPointerEnter={() => {
308 setHover(true)
309 }}
310 onPointerLeave={() => {
311 setHover(false)
312 }}>
313 <SubtleHover hover={hover} />
314 <View style={{flexDirection: 'row', gap: 10, paddingLeft: 8}}>
315 <View style={{width: isCarouselItem ? 0 : 42}}>
316 {isThreadChild && (
317 <View
318 style={[
319 styles.replyLine,
320 {
321 flexGrow: 1,
322 backgroundColor: pal.colors.replyLine,
323 marginBottom: 4,
324 },
325 ]}
326 />
327 )}
328 </View>
329
330 <View style={[a.pt_sm, a.flex_shrink]}>
331 {reason && (
332 <PostFeedReason
333 reason={reason}
334 moderation={moderation}
335 onOpenReposter={onOpenReposter}
336 />
337 )}
338 </View>
339 </View>
340
341 <View style={styles.layout}>
342 <View style={styles.layoutAvi}>
343 <PreviewableUserAvatar
344 size={42}
345 profile={post.author}
346 moderation={moderation.ui('avatar')}
347 type={post.author.associated?.labeler ? 'labeler' : 'user'}
348 onBeforePress={onOpenAuthor}
349 live={live}
350 />
351 {isThreadParent && (
352 <View
353 style={[
354 styles.replyLine,
355 {
356 flexGrow: 1,
357 backgroundColor: pal.colors.replyLine,
358 marginTop: live ? 8 : 4,
359 },
360 ]}
361 />
362 )}
363 </View>
364 <View style={styles.layoutContent}>
365 <PostMeta
366 author={post.author}
367 moderation={moderation}
368 timestamp={post.indexedAt}
369 postHref={href}
370 onOpenAuthor={onOpenAuthor}
371 />
372 {showReplyTo &&
373 (parentAuthor || isParentBlocked || isParentNotFound) && (
374 <PostRepliedTo
375 parentAuthor={parentAuthor}
376 isParentBlocked={isParentBlocked}
377 isParentNotFound={isParentNotFound}
378 />
379 )}
380 <LabelsOnMyPost post={post} />
381 <PostContent
382 moderation={moderation}
383 richText={richText}
384 postEmbed={post.embed}
385 postAuthor={post.author}
386 onOpenEmbed={onOpenEmbed}
387 post={post}
388 threadgateRecord={threadgateRecord}
389 />
390 <PostControls
391 post={post}
392 record={record}
393 richText={richText}
394 onPressReply={onPressReply}
395 logContext="FeedItem"
396 feedContext={feedContext}
397 reqId={reqId}
398 threadgateRecord={threadgateRecord}
399 onShowLess={onShowLess}
400 viaRepost={viaRepost}
401 />
402 </View>
403
404 <DiscoverDebug feedContext={feedContext} />
405 </View>
406 </Link>
407 )
408}
409FeedItemInner = memo(FeedItemInner)
410
411let PostContent = ({
412 post,
413 moderation,
414 richText,
415 postEmbed,
416 postAuthor,
417 onOpenEmbed,
418 threadgateRecord,
419}: {
420 moderation: ModerationDecision
421 richText: RichTextAPI
422 postEmbed: AppBskyFeedDefs.PostView['embed']
423 postAuthor: AppBskyFeedDefs.PostView['author']
424 onOpenEmbed: () => void
425 post: AppBskyFeedDefs.PostView
426 threadgateRecord?: AppBskyFeedThreadgate.Record
427}): React.ReactNode => {
428 const {currentAccount} = useSession()
429 const [limitLines, setLimitLines] = useState(
430 () => countLines(richText.text) >= MAX_POST_LINES,
431 )
432 const threadgateHiddenReplies = useMergedThreadgateHiddenReplies({
433 threadgateRecord,
434 })
435 const additionalPostAlerts: AppModerationCause[] = useMemo(() => {
436 const isPostHiddenByThreadgate = threadgateHiddenReplies.has(post.uri)
437 const rootPostUri = bsky.dangerousIsType<AppBskyFeedPost.Record>(
438 post.record,
439 AppBskyFeedPost.isRecord,
440 )
441 ? post.record?.reply?.root?.uri || post.uri
442 : undefined
443 const isControlledByViewer =
444 rootPostUri && new AtUri(rootPostUri).host === currentAccount?.did
445 return isControlledByViewer && isPostHiddenByThreadgate
446 ? [
447 {
448 type: 'reply-hidden',
449 source: {type: 'user', did: currentAccount?.did},
450 priority: 6,
451 },
452 ]
453 : []
454 }, [post, currentAccount?.did, threadgateHiddenReplies])
455
456 const onPressShowMore = useCallback(() => {
457 setLimitLines(false)
458 }, [setLimitLines])
459
460 return (
461 <ContentHider
462 testID="contentHider-post"
463 modui={moderation.ui('contentList')}
464 ignoreMute
465 childContainerStyle={styles.contentHiderChild}>
466 <PostAlerts
467 modui={moderation.ui('contentList')}
468 style={[a.pb_xs]}
469 additionalCauses={additionalPostAlerts}
470 />
471 {richText.text ? (
472 <>
473 <RichText
474 enableTags
475 testID="postText"
476 value={richText}
477 numberOfLines={limitLines ? MAX_POST_LINES : undefined}
478 style={[a.flex_1, a.text_md]}
479 authorHandle={postAuthor.handle}
480 shouldProxyLinks={true}
481 />
482 {limitLines && (
483 <ShowMoreTextButton style={[a.text_md]} onPress={onPressShowMore} />
484 )}
485 </>
486 ) : undefined}
487 {postEmbed ? (
488 <View style={[a.pb_xs]}>
489 <Embed
490 embed={postEmbed}
491 moderation={moderation}
492 onOpen={onOpenEmbed}
493 viewContext={PostEmbedViewContext.Feed}
494 />
495 </View>
496 ) : null}
497 </ContentHider>
498 )
499}
500PostContent = memo(PostContent)
501
502const styles = StyleSheet.create({
503 outer: {
504 paddingLeft: 10,
505 paddingRight: 15,
506 cursor: 'pointer',
507 },
508 replyLine: {
509 width: 2,
510 marginLeft: 'auto',
511 marginRight: 'auto',
512 },
513 layout: {
514 flexDirection: 'row',
515 marginTop: 1,
516 },
517 layoutAvi: {
518 paddingLeft: 8,
519 paddingRight: 10,
520 position: 'relative',
521 zIndex: 999,
522 },
523 layoutContent: {
524 position: 'relative',
525 flex: 1,
526 zIndex: 0,
527 },
528 alert: {
529 marginTop: 6,
530 marginBottom: 6,
531 },
532 contentHiderChild: {
533 marginTop: 6,
534 },
535 embed: {
536 marginBottom: 6,
537 },
538 translateLink: {
539 marginBottom: 6,
540 },
541})