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, select, useTheme} 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 t = useTheme()
175 const {currentAccount} = useSession()
176
177 const [hover, setHover] = useState(false)
178
179 const [href] = useMemo(() => {
180 const urip = new AtUri(post.uri)
181 return [makeProfileLink(post.author, 'post', urip.rkey), urip.rkey]
182 }, [post.uri, post.author])
183 const {sendInteraction, feedSourceInfo, feedDescriptor} =
184 useFeedFeedbackContext()
185
186 const onPressReply = () => {
187 sendInteraction({
188 item: post.uri,
189 event: 'app.bsky.feed.defs#interactionReply',
190 feedContext,
191 reqId,
192 })
193 openComposer({
194 replyTo: {
195 uri: post.uri,
196 cid: post.cid,
197 text: record.text || '',
198 author: post.author,
199 embed: post.embed,
200 moderation,
201 langs: record.langs,
202 },
203 logContext: 'PostReply',
204 })
205 }
206
207 const onOpenAuthor = () => {
208 sendInteraction({
209 item: post.uri,
210 event: 'app.bsky.feed.defs#clickthroughAuthor',
211 feedContext,
212 reqId,
213 })
214 ax.metric('post:clickthroughAuthor', {
215 uri: post.uri,
216 authorDid: post.author.did,
217 logContext: 'FeedItem',
218 feedDescriptor,
219 })
220 }
221
222 const onOpenReposter = () => {
223 sendInteraction({
224 item: post.uri,
225 event: 'app.bsky.feed.defs#clickthroughReposter',
226 feedContext,
227 reqId,
228 })
229 }
230
231 const onOpenEmbed = () => {
232 sendInteraction({
233 item: post.uri,
234 event: 'app.bsky.feed.defs#clickthroughEmbed',
235 feedContext,
236 reqId,
237 })
238 ax.metric('post:clickthroughEmbed', {
239 uri: post.uri,
240 authorDid: post.author.did,
241 logContext: 'FeedItem',
242 feedDescriptor,
243 })
244 }
245
246 const onBeforePress = () => {
247 sendInteraction({
248 item: post.uri,
249 event: 'app.bsky.feed.defs#clickthroughItem',
250 feedContext,
251 reqId,
252 })
253 ax.metric('post:clickthroughItem', {
254 uri: post.uri,
255 authorDid: post.author.did,
256 logContext: 'FeedItem',
257 feedDescriptor,
258 })
259 unstableCacheProfileView(queryClient, post.author)
260 setUnstablePostSource(buildPostSourceKey(post.uri, post.author.handle), {
261 feedSourceInfo,
262 post: {
263 post,
264 reason: AppBskyFeedDefs.isReasonRepost(reason) ? reason : undefined,
265 feedContext,
266 reqId,
267 },
268 })
269 }
270
271 const outerStyles = [
272 styles.outer,
273 {
274 borderColor: pal.colors.border,
275 paddingBottom:
276 isThreadLastChild || (!isThreadChild && !isThreadParent)
277 ? 8
278 : undefined,
279 borderTopWidth:
280 hideTopBorder || isThreadChild ? 0 : StyleSheet.hairlineWidth,
281 },
282 ]
283
284 /**
285 * If `post[0]` in this slice is the actual root post (not an orphan thread),
286 * then we may have a threadgate record to reference
287 */
288 const threadgateRecord = bsky.dangerousIsType<AppBskyFeedThreadgate.Record>(
289 rootPost.threadgate?.record,
290 AppBskyFeedThreadgate.isRecord,
291 )
292 ? rootPost.threadgate.record
293 : undefined
294
295 const {isActive: live} = useActorStatus(post.author)
296
297 const viaRepost = useMemo(() => {
298 if (AppBskyFeedDefs.isReasonRepost(reason) && reason.uri && reason.cid) {
299 return {
300 uri: reason.uri,
301 cid: reason.cid,
302 }
303 }
304 }, [reason])
305
306 const threadgateHiddenReplies = useMergedThreadgateHiddenReplies({
307 threadgateRecord,
308 })
309 const additionalPostAlerts: AppModerationCause[] = useMemo(() => {
310 const isPostHiddenByThreadgate = threadgateHiddenReplies.has(post.uri)
311 const rootPostUri = bsky.dangerousIsType<AppBskyFeedPost.Record>(
312 post.record,
313 AppBskyFeedPost.isRecord,
314 )
315 ? post.record?.reply?.root?.uri || post.uri
316 : undefined
317 const isControlledByViewer =
318 rootPostUri && new AtUri(rootPostUri).host === currentAccount?.did
319 return isControlledByViewer && isPostHiddenByThreadgate
320 ? [
321 {
322 type: 'reply-hidden',
323 source: {type: 'user', did: currentAccount?.did},
324 priority: 6,
325 },
326 ]
327 : []
328 }, [post, currentAccount?.did, threadgateHiddenReplies])
329
330 return (
331 <GalleryBleed>
332 <Link
333 testID={`feedItem-by-${post.author.handle}`}
334 style={outerStyles}
335 href={href}
336 noFeedback
337 accessible={false}
338 onBeforePress={onBeforePress}
339 dataSet={{feedContext}}
340 onPointerEnter={() => {
341 setHover(true)
342 }}
343 onPointerLeave={() => {
344 setHover(false)
345 }}>
346 <SubtleHover hover={hover} />
347 <View style={{flexDirection: 'row', gap: 10, paddingLeft: 8}}>
348 <View style={{width: isCarouselItem ? 0 : 42}}>
349 {isThreadChild && (
350 <View
351 style={[
352 styles.replyLine,
353 {
354 backgroundColor: select(t.name, {
355 light: t.palette.contrast_100,
356 dim: t.palette.contrast_200,
357 dark: t.palette.contrast_200,
358 }),
359 marginBottom: 4,
360 },
361 ]}
362 />
363 )}
364 </View>
365
366 <View style={[a.pt_sm, a.flex_shrink]}>
367 {reason && (
368 <PostFeedReason
369 reason={reason}
370 moderation={moderation}
371 onOpenReposter={onOpenReposter}
372 />
373 )}
374 </View>
375 </View>
376
377 <View style={styles.layout}>
378 <View style={styles.layoutAvi}>
379 <PreviewableUserAvatar
380 size={42}
381 profile={post.author}
382 moderation={moderation.ui('avatar')}
383 type={post.author.associated?.labeler ? 'labeler' : 'user'}
384 onBeforePress={onOpenAuthor}
385 live={live}
386 />
387 {isThreadParent && (
388 <View
389 style={[
390 styles.replyLine,
391 {
392 backgroundColor: select(t.name, {
393 light: t.palette.contrast_100,
394 dim: t.palette.contrast_200,
395 dark: t.palette.contrast_200,
396 }),
397 marginTop: live ? 8 : 4,
398 },
399 ]}
400 />
401 )}
402 </View>
403 <View
404 style={[
405 styles.layoutContent,
406 maybeApplyGalleryOffsetStyles('meta', {
407 post,
408 modui: moderation.ui('contentList'),
409 additionalCauses: additionalPostAlerts,
410 }),
411 ]}>
412 <PostMeta
413 author={post.author}
414 moderation={moderation}
415 timestamp={post.indexedAt}
416 postHref={href}
417 onOpenAuthor={onOpenAuthor}
418 />
419 {showReplyTo &&
420 (parentAuthor || isParentBlocked || isParentNotFound) && (
421 <PostRepliedTo
422 parentAuthor={parentAuthor}
423 isParentBlocked={isParentBlocked}
424 isParentNotFound={isParentNotFound}
425 />
426 )}
427 <LabelsOnMyPost post={post} />
428 <PostContent
429 moderation={moderation}
430 richText={richText}
431 postEmbed={post.embed}
432 postAuthor={post.author}
433 onOpenEmbed={onOpenEmbed}
434 post={post}
435 additionalPostAlerts={additionalPostAlerts}
436 />
437 <PostControls
438 post={post}
439 record={record}
440 richText={richText}
441 onPressReply={onPressReply}
442 logContext="FeedItem"
443 feedContext={feedContext}
444 reqId={reqId}
445 threadgateRecord={threadgateRecord}
446 onShowLess={onShowLess}
447 viaRepost={viaRepost}
448 />
449 </View>
450
451 <DiscoverDebug feedContext={feedContext} />
452 </View>
453 </Link>
454 </GalleryBleed>
455 )
456}
457FeedItemInner = memo(FeedItemInner)
458
459let PostContent = ({
460 post,
461 moderation,
462 richText,
463 postEmbed,
464 postAuthor,
465 onOpenEmbed,
466 additionalPostAlerts,
467}: {
468 moderation: ModerationDecision
469 richText: RichTextAPI
470 postEmbed: AppBskyFeedDefs.PostView['embed']
471 postAuthor: AppBskyFeedDefs.PostView['author']
472 onOpenEmbed: () => void
473 post: AppBskyFeedDefs.PostView
474 additionalPostAlerts?: AppModerationCause[]
475}): React.ReactNode => {
476 const [limitLines, setLimitLines] = useState(
477 () => countLines(richText.text) >= MAX_POST_LINES,
478 )
479
480 const record = useMemo<AppBskyFeedPost.Record | undefined>(
481 () =>
482 bsky.validate(post.record, AppBskyFeedPost.validateRecord)
483 ? post.record
484 : undefined,
485 [post],
486 )
487
488 const onPressShowMore = useCallback(() => {
489 setLimitLines(false)
490 }, [setLimitLines])
491
492 return (
493 <ContentHider
494 testID="contentHider-post"
495 modui={moderation.ui('contentList')}
496 ignoreMute
497 childContainerStyle={styles.contentHiderChild}>
498 <PostAlerts
499 modui={moderation.ui('contentList')}
500 style={[a.pb_xs]}
501 additionalCauses={additionalPostAlerts}
502 />
503 {richText.text ? (
504 <View style={[a.mb_2xs]}>
505 <RichText
506 enableTags
507 testID="postText"
508 value={richText}
509 numberOfLines={limitLines ? MAX_POST_LINES : undefined}
510 style={[a.flex_1, a.text_md]}
511 authorHandle={postAuthor.handle}
512 shouldProxyLinks={true}
513 />
514 {limitLines && (
515 <ShowMoreTextButton style={[a.text_md]} onPress={onPressShowMore} />
516 )}
517 </View>
518 ) : undefined}
519 {record && <TranslatedPost hideTranslateLink post={post} />}
520 {postEmbed ? (
521 <View
522 style={[
523 a.pb_xs,
524 maybeApplyGalleryOffsetStyles('embed', {
525 post,
526 modui: moderation.ui('contentList'),
527 additionalCauses: additionalPostAlerts,
528 }),
529 ]}>
530 <Embed
531 embed={postEmbed}
532 moderation={moderation}
533 onOpen={onOpenEmbed}
534 viewContext={PostEmbedViewContext.Feed}
535 />
536 </View>
537 ) : null}
538 </ContentHider>
539 )
540}
541PostContent = memo(PostContent)
542
543const styles = StyleSheet.create({
544 outer: {
545 paddingLeft: 10,
546 paddingRight: 15,
547 cursor: 'pointer',
548 },
549 replyLine: {
550 flexGrow: 1,
551 width: 2,
552 marginLeft: 'auto',
553 marginRight: 'auto',
554 },
555 layout: {
556 flexDirection: 'row',
557 marginTop: 1,
558 },
559 layoutAvi: {
560 paddingLeft: 8,
561 paddingRight: 10,
562 },
563 layoutContent: {
564 flex: 1,
565 },
566 alert: {
567 marginTop: 6,
568 marginBottom: 6,
569 },
570 contentHiderChild: {
571 marginTop: 6,
572 },
573 embed: {
574 marginBottom: 6,
575 },
576 translateLink: {
577 marginBottom: 6,
578 },
579})