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