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