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