mirror of https://git.lenooby09.tech/LeNooby09/social-app.git
1import React, {useRef} from 'react'
2import {StyleSheet, useWindowDimensions, View} from 'react-native'
3import {runOnJS} from 'react-native-reanimated'
4import Animated from 'react-native-reanimated'
5import {useSafeAreaInsets} from 'react-native-safe-area-context'
6import {AppBskyFeedDefs} from '@atproto/api'
7import {msg, Trans} from '@lingui/macro'
8import {useLingui} from '@lingui/react'
9
10import {moderatePost_wrapped as moderatePost} from '#/lib/moderatePost_wrapped'
11import {clamp} from '#/lib/numbers'
12import {ScrollProvider} from '#/lib/ScrollContext'
13import {isAndroid, isNative, isWeb} from '#/platform/detection'
14import {useModerationOpts} from '#/state/preferences/moderation-opts'
15import {
16 fillThreadModerationCache,
17 sortThread,
18 ThreadBlocked,
19 ThreadModerationCache,
20 ThreadNode,
21 ThreadNotFound,
22 ThreadPost,
23 usePostThreadQuery,
24} from '#/state/queries/post-thread'
25import {usePreferencesQuery} from '#/state/queries/preferences'
26import {useSession} from '#/state/session'
27import {useComposerControls} from '#/state/shell'
28import {useInitialNumToRender} from 'lib/hooks/useInitialNumToRender'
29import {useMinimalShellFabTransform} from 'lib/hooks/useMinimalShellTransform'
30import {useSetTitle} from 'lib/hooks/useSetTitle'
31import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries'
32import {sanitizeDisplayName} from 'lib/strings/display-names'
33import {cleanError} from 'lib/strings/errors'
34import {CenteredView} from 'view/com/util/Views'
35import {atoms as a, useTheme} from '#/alf'
36import {ListFooter, ListMaybePlaceholder} from '#/components/Lists'
37import {Text} from '#/components/Typography'
38import {List, ListMethods} from '../util/List'
39import {ViewHeader} from '../util/ViewHeader'
40import {PostThreadComposePrompt} from './PostThreadComposePrompt'
41import {PostThreadItem} from './PostThreadItem'
42import {PostThreadLoadMore} from './PostThreadLoadMore'
43import {PostThreadShowHiddenReplies} from './PostThreadShowHiddenReplies'
44
45// FlatList maintainVisibleContentPosition breaks if too many items
46// are prepended. This seems to be an optimal number based on *shrug*.
47const PARENTS_CHUNK_SIZE = 15
48
49const MAINTAIN_VISIBLE_CONTENT_POSITION = {
50 // We don't insert any elements before the root row while loading.
51 // So the row we want to use as the scroll anchor is the first row.
52 minIndexForVisible: 0,
53}
54
55const REPLY_PROMPT = {_reactKey: '__reply__'}
56const LOAD_MORE = {_reactKey: '__load_more__'}
57const SHOW_HIDDEN_REPLIES = {_reactKey: '__show_hidden_replies__'}
58const SHOW_MUTED_REPLIES = {_reactKey: '__show_muted_replies__'}
59
60enum HiddenRepliesState {
61 Hide,
62 Show,
63 ShowAndOverridePostHider,
64}
65
66type YieldedItem =
67 | ThreadPost
68 | ThreadBlocked
69 | ThreadNotFound
70 | typeof SHOW_HIDDEN_REPLIES
71 | typeof SHOW_MUTED_REPLIES
72type RowItem =
73 | YieldedItem
74 // TODO: TS doesn't actually enforce it's one of these, it only enforces matching shape.
75 | typeof REPLY_PROMPT
76 | typeof LOAD_MORE
77
78type ThreadSkeletonParts = {
79 parents: YieldedItem[]
80 highlightedPost: ThreadNode
81 replies: YieldedItem[]
82}
83
84const keyExtractor = (item: RowItem) => {
85 return item._reactKey
86}
87
88export function PostThread({uri}: {uri: string | undefined}) {
89 const {hasSession, currentAccount} = useSession()
90 const {_} = useLingui()
91 const t = useTheme()
92 const {isMobile, isTabletOrMobile} = useWebMediaQueries()
93 const initialNumToRender = useInitialNumToRender()
94 const {height: windowHeight} = useWindowDimensions()
95 const [hiddenRepliesState, setHiddenRepliesState] = React.useState(
96 HiddenRepliesState.Hide,
97 )
98
99 const {data: preferences} = usePreferencesQuery()
100 const {
101 isFetching,
102 isError: isThreadError,
103 error: threadError,
104 refetch,
105 data: thread,
106 } = usePostThreadQuery(uri)
107
108 const treeView = React.useMemo(
109 () =>
110 !!preferences?.threadViewPrefs?.lab_treeViewEnabled &&
111 hasBranchingReplies(thread),
112 [preferences?.threadViewPrefs, thread],
113 )
114 const rootPost = thread?.type === 'post' ? thread.post : undefined
115 const rootPostRecord = thread?.type === 'post' ? thread.record : undefined
116
117 const moderationOpts = useModerationOpts()
118 const isNoPwi = React.useMemo(() => {
119 const mod =
120 rootPost && moderationOpts
121 ? moderatePost(rootPost, moderationOpts)
122 : undefined
123 return !!mod
124 ?.ui('contentList')
125 .blurs.find(
126 cause =>
127 cause.type === 'label' &&
128 cause.labelDef.identifier === '!no-unauthenticated',
129 )
130 }, [rootPost, moderationOpts])
131
132 // Values used for proper rendering of parents
133 const ref = useRef<ListMethods>(null)
134 const highlightedPostRef = useRef<View | null>(null)
135 const [maxParents, setMaxParents] = React.useState(
136 isWeb ? Infinity : PARENTS_CHUNK_SIZE,
137 )
138 const [maxReplies, setMaxReplies] = React.useState(50)
139
140 useSetTitle(
141 rootPost && !isNoPwi
142 ? `${sanitizeDisplayName(
143 rootPost.author.displayName || `@${rootPost.author.handle}`,
144 )}: "${rootPostRecord!.text}"`
145 : '',
146 )
147
148 // On native, this is going to start out `true`. We'll toggle it to `false` after the initial render if flushed.
149 // This ensures that the first render contains no parents--even if they are already available in the cache.
150 // We need to delay showing them so that we can use maintainVisibleContentPosition to keep the main post on screen.
151 // On the web this is not necessary because we can synchronously adjust the scroll in onContentSizeChange instead.
152 const [deferParents, setDeferParents] = React.useState(isNative)
153
154 const currentDid = currentAccount?.did
155 const threadModerationCache = React.useMemo(() => {
156 const cache: ThreadModerationCache = new WeakMap()
157 if (thread && moderationOpts) {
158 fillThreadModerationCache(cache, thread, moderationOpts)
159 }
160 return cache
161 }, [thread, moderationOpts])
162
163 const [justPostedUris, setJustPostedUris] = React.useState(
164 () => new Set<string>(),
165 )
166
167 const skeleton = React.useMemo(() => {
168 const threadViewPrefs = preferences?.threadViewPrefs
169 if (!threadViewPrefs || !thread) return null
170
171 return createThreadSkeleton(
172 sortThread(
173 thread,
174 threadViewPrefs,
175 threadModerationCache,
176 currentDid,
177 justPostedUris,
178 ),
179 !!currentDid,
180 treeView,
181 threadModerationCache,
182 hiddenRepliesState !== HiddenRepliesState.Hide,
183 )
184 }, [
185 thread,
186 preferences?.threadViewPrefs,
187 currentDid,
188 treeView,
189 threadModerationCache,
190 hiddenRepliesState,
191 justPostedUris,
192 ])
193
194 const error = React.useMemo(() => {
195 if (AppBskyFeedDefs.isNotFoundPost(thread)) {
196 return {
197 title: _(msg`Post not found`),
198 message: _(msg`The post may have been deleted.`),
199 }
200 } else if (skeleton?.highlightedPost.type === 'blocked') {
201 return {
202 title: _(msg`Post hidden`),
203 message: _(
204 msg`You have blocked the author or you have been blocked by the author.`,
205 ),
206 }
207 } else if (threadError?.message.startsWith('Post not found')) {
208 return {
209 title: _(msg`Post not found`),
210 message: _(msg`The post may have been deleted.`),
211 }
212 } else if (isThreadError) {
213 return {
214 message: threadError ? cleanError(threadError) : undefined,
215 }
216 }
217
218 return null
219 }, [thread, skeleton?.highlightedPost, isThreadError, _, threadError])
220
221 // construct content
222 const posts = React.useMemo(() => {
223 if (!skeleton) return []
224
225 const {parents, highlightedPost, replies} = skeleton
226 let arr: RowItem[] = []
227 if (highlightedPost.type === 'post') {
228 // We want to wait for parents to load before rendering.
229 // If you add something here, you'll need to update both
230 // maintainVisibleContentPosition and onContentSizeChange
231 // to "hold onto" the correct row instead of the first one.
232
233 if (!highlightedPost.ctx.isParentLoading && !deferParents) {
234 // When progressively revealing parents, rendering a placeholder
235 // here will cause scrolling jumps. Don't add it unless you test it.
236 // QT'ing this thread is a great way to test all the scrolling hacks:
237 // https://bsky.app/profile/www.mozzius.dev/post/3kjqhblh6qk2o
238
239 // Everything is loaded
240 let startIndex = Math.max(0, parents.length - maxParents)
241 for (let i = startIndex; i < parents.length; i++) {
242 arr.push(parents[i])
243 }
244 }
245 arr.push(highlightedPost)
246 if (!highlightedPost.post.viewer?.replyDisabled) {
247 arr.push(REPLY_PROMPT)
248 }
249 for (let i = 0; i < replies.length; i++) {
250 arr.push(replies[i])
251 if (i === maxReplies) {
252 break
253 }
254 }
255 }
256 return arr
257 }, [skeleton, deferParents, maxParents, maxReplies])
258
259 // This is only used on the web to keep the post in view when its parents load.
260 // On native, we rely on `maintainVisibleContentPosition` instead.
261 const didAdjustScrollWeb = useRef<boolean>(false)
262 const onContentSizeChangeWeb = React.useCallback(() => {
263 // only run once
264 if (didAdjustScrollWeb.current) {
265 return
266 }
267 // wait for loading to finish
268 if (thread?.type === 'post' && !!thread.parent) {
269 function onMeasure(pageY: number) {
270 ref.current?.scrollToOffset({
271 animated: false,
272 offset: pageY,
273 })
274 }
275 // Measure synchronously to avoid a layout jump.
276 const domNode = highlightedPostRef.current
277 if (domNode) {
278 const pageY = (domNode as any as Element).getBoundingClientRect().top
279 onMeasure(pageY)
280 }
281 didAdjustScrollWeb.current = true
282 }
283 }, [thread])
284
285 // On native, we reveal parents in chunks. Although they're all already
286 // loaded and FlatList already has its own virtualization, unfortunately FlatList
287 // has a bug that causes the content to jump around if too many items are getting
288 // prepended at once. It also jumps around if items get prepended during scroll.
289 // To work around this, we prepend rows after scroll bumps against the top and rests.
290 const needsBumpMaxParents = React.useRef(false)
291 const onStartReached = React.useCallback(() => {
292 if (skeleton?.parents && maxParents < skeleton.parents.length) {
293 needsBumpMaxParents.current = true
294 }
295 }, [maxParents, skeleton?.parents])
296 const bumpMaxParentsIfNeeded = React.useCallback(() => {
297 if (!isNative) {
298 return
299 }
300 if (needsBumpMaxParents.current) {
301 needsBumpMaxParents.current = false
302 setMaxParents(n => n + PARENTS_CHUNK_SIZE)
303 }
304 }, [])
305 const onScrollToTop = bumpMaxParentsIfNeeded
306 const onMomentumEnd = React.useCallback(() => {
307 'worklet'
308 runOnJS(bumpMaxParentsIfNeeded)()
309 }, [bumpMaxParentsIfNeeded])
310
311 const onEndReached = React.useCallback(() => {
312 if (isFetching || posts.length < maxReplies) return
313 setMaxReplies(prev => prev + 50)
314 }, [isFetching, maxReplies, posts.length])
315
316 const onPostReply = React.useCallback(
317 (postUri: string | undefined) => {
318 refetch()
319 if (postUri) {
320 setJustPostedUris(set => {
321 const nextSet = new Set(set)
322 nextSet.add(postUri)
323 return nextSet
324 })
325 }
326 },
327 [refetch],
328 )
329
330 const {openComposer} = useComposerControls()
331 const onPressReply = React.useCallback(() => {
332 if (thread?.type !== 'post') {
333 return
334 }
335 openComposer({
336 replyTo: {
337 uri: thread.post.uri,
338 cid: thread.post.cid,
339 text: thread.record.text,
340 author: thread.post.author,
341 embed: thread.post.embed,
342 },
343 onPost: onPostReply,
344 })
345 }, [openComposer, thread, onPostReply])
346
347 const canReply = !error && rootPost && !rootPost.viewer?.replyDisabled
348 const hasParents =
349 skeleton?.highlightedPost?.type === 'post' &&
350 (skeleton.highlightedPost.ctx.isParentLoading ||
351 Boolean(skeleton?.parents && skeleton.parents.length > 0))
352 const showHeader =
353 isNative || (isTabletOrMobile && (!hasParents || !isFetching))
354
355 const renderItem = ({item, index}: {item: RowItem; index: number}) => {
356 if (item === REPLY_PROMPT && hasSession) {
357 return (
358 <View>
359 {!isMobile && (
360 <PostThreadComposePrompt onPressCompose={onPressReply} />
361 )}
362 </View>
363 )
364 } else if (item === SHOW_HIDDEN_REPLIES || item === SHOW_MUTED_REPLIES) {
365 return (
366 <PostThreadShowHiddenReplies
367 type={item === SHOW_HIDDEN_REPLIES ? 'hidden' : 'muted'}
368 onPress={() =>
369 setHiddenRepliesState(
370 item === SHOW_HIDDEN_REPLIES
371 ? HiddenRepliesState.Show
372 : HiddenRepliesState.ShowAndOverridePostHider,
373 )
374 }
375 hideTopBorder={index === 0}
376 />
377 )
378 } else if (isThreadNotFound(item)) {
379 return (
380 <View
381 style={[
382 a.p_lg,
383 index !== 0 && a.border_t,
384 t.atoms.border_contrast_low,
385 t.atoms.bg_contrast_25,
386 ]}>
387 <Text style={[a.font_bold, a.text_md, t.atoms.text_contrast_medium]}>
388 <Trans>Deleted post.</Trans>
389 </Text>
390 </View>
391 )
392 } else if (isThreadBlocked(item)) {
393 return (
394 <View
395 style={[
396 a.p_lg,
397 index !== 0 && a.border_t,
398 t.atoms.border_contrast_low,
399 t.atoms.bg_contrast_25,
400 ]}>
401 <Text style={[a.font_bold, a.text_md, t.atoms.text_contrast_medium]}>
402 <Trans>Blocked post.</Trans>
403 </Text>
404 </View>
405 )
406 } else if (isThreadPost(item)) {
407 if (!treeView && item.ctx.hasMoreSelfThread) {
408 return <PostThreadLoadMore post={item.post} />
409 }
410 const prev = isThreadPost(posts[index - 1])
411 ? (posts[index - 1] as ThreadPost)
412 : undefined
413 const next = isThreadPost(posts[index + 1])
414 ? (posts[index + 1] as ThreadPost)
415 : undefined
416 const showChildReplyLine = (next?.ctx.depth || 0) > item.ctx.depth
417 const showParentReplyLine =
418 (item.ctx.depth < 0 && !!item.parent) || item.ctx.depth > 1
419 const hasUnrevealedParents =
420 index === 0 && skeleton?.parents && maxParents < skeleton.parents.length
421 return (
422 <View
423 ref={item.ctx.isHighlightedPost ? highlightedPostRef : undefined}
424 onLayout={deferParents ? () => setDeferParents(false) : undefined}>
425 <PostThreadItem
426 post={item.post}
427 record={item.record}
428 moderation={threadModerationCache.get(item)}
429 treeView={treeView}
430 depth={item.ctx.depth}
431 prevPost={prev}
432 nextPost={next}
433 isHighlightedPost={item.ctx.isHighlightedPost}
434 hasMore={item.ctx.hasMore}
435 showChildReplyLine={showChildReplyLine}
436 showParentReplyLine={showParentReplyLine}
437 hasPrecedingItem={showParentReplyLine || !!hasUnrevealedParents}
438 overrideBlur={
439 hiddenRepliesState ===
440 HiddenRepliesState.ShowAndOverridePostHider &&
441 item.ctx.depth > 0
442 }
443 onPostReply={onPostReply}
444 hideTopBorder={index === 0 && !item.ctx.isParentLoading}
445 />
446 </View>
447 )
448 }
449 return null
450 }
451
452 if (!thread || !preferences || error) {
453 return (
454 <ListMaybePlaceholder
455 isLoading={!error}
456 isError={Boolean(error)}
457 noEmpty
458 onRetry={refetch}
459 errorTitle={error?.title}
460 errorMessage={error?.message}
461 />
462 )
463 }
464
465 return (
466 <CenteredView style={[a.flex_1]} sideBorders={true}>
467 {showHeader && (
468 <ViewHeader
469 title={_(msg({message: `Post`, context: 'description'}))}
470 showBorder
471 />
472 )}
473
474 <ScrollProvider onMomentumEnd={onMomentumEnd}>
475 <List
476 ref={ref}
477 data={posts}
478 renderItem={renderItem}
479 keyExtractor={keyExtractor}
480 onContentSizeChange={isNative ? undefined : onContentSizeChangeWeb}
481 onStartReached={onStartReached}
482 onEndReached={onEndReached}
483 onEndReachedThreshold={2}
484 onScrollToTop={onScrollToTop}
485 maintainVisibleContentPosition={
486 isNative && hasParents
487 ? MAINTAIN_VISIBLE_CONTENT_POSITION
488 : undefined
489 }
490 // @ts-ignore our .web version only -prf
491 desktopFixedHeight
492 removeClippedSubviews={isAndroid ? false : undefined}
493 ListFooterComponent={
494 <ListFooter
495 // Using `isFetching` over `isFetchingNextPage` is done on purpose here so we get the loader on
496 // initial render
497 isFetchingNextPage={isFetching}
498 error={cleanError(threadError)}
499 onRetry={refetch}
500 // 300 is based on the minimum height of a post. This is enough extra height for the `maintainVisPos` to
501 // work without causing weird jumps on web or glitches on native
502 height={windowHeight - 200}
503 />
504 }
505 initialNumToRender={initialNumToRender}
506 windowSize={11}
507 sideBorders={false}
508 />
509 </ScrollProvider>
510 {isMobile && canReply && hasSession && (
511 <MobileComposePrompt onPressReply={onPressReply} />
512 )}
513 </CenteredView>
514 )
515}
516
517function MobileComposePrompt({onPressReply}: {onPressReply: () => unknown}) {
518 const safeAreaInsets = useSafeAreaInsets()
519 const fabMinimalShellTransform = useMinimalShellFabTransform()
520 return (
521 <Animated.View
522 style={[
523 styles.prompt,
524 fabMinimalShellTransform,
525 {
526 bottom: clamp(safeAreaInsets.bottom, 15, 30),
527 },
528 ]}>
529 <PostThreadComposePrompt onPressCompose={onPressReply} />
530 </Animated.View>
531 )
532}
533
534function isThreadPost(v: unknown): v is ThreadPost {
535 return !!v && typeof v === 'object' && 'type' in v && v.type === 'post'
536}
537
538function isThreadNotFound(v: unknown): v is ThreadNotFound {
539 return !!v && typeof v === 'object' && 'type' in v && v.type === 'not-found'
540}
541
542function isThreadBlocked(v: unknown): v is ThreadBlocked {
543 return !!v && typeof v === 'object' && 'type' in v && v.type === 'blocked'
544}
545
546function createThreadSkeleton(
547 node: ThreadNode,
548 hasSession: boolean,
549 treeView: boolean,
550 modCache: ThreadModerationCache,
551 showHiddenReplies: boolean,
552): ThreadSkeletonParts | null {
553 if (!node) return null
554
555 return {
556 parents: Array.from(flattenThreadParents(node, hasSession)),
557 highlightedPost: node,
558 replies: Array.from(
559 flattenThreadReplies(
560 node,
561 hasSession,
562 treeView,
563 modCache,
564 showHiddenReplies,
565 ),
566 ),
567 }
568}
569
570function* flattenThreadParents(
571 node: ThreadNode,
572 hasSession: boolean,
573): Generator<YieldedItem, void> {
574 if (node.type === 'post') {
575 if (node.parent) {
576 yield* flattenThreadParents(node.parent, hasSession)
577 }
578 if (!node.ctx.isHighlightedPost) {
579 yield node
580 }
581 } else if (node.type === 'not-found') {
582 yield node
583 } else if (node.type === 'blocked') {
584 yield node
585 }
586}
587
588// The enum is ordered to make them easy to merge
589enum HiddenReplyType {
590 None = 0,
591 Muted = 1,
592 Hidden = 2,
593}
594
595function* flattenThreadReplies(
596 node: ThreadNode,
597 hasSession: boolean,
598 treeView: boolean,
599 modCache: ThreadModerationCache,
600 showHiddenReplies: boolean,
601): Generator<YieldedItem, HiddenReplyType> {
602 if (node.type === 'post') {
603 // dont show pwi-opted-out posts to logged out users
604 if (!hasSession && hasPwiOptOut(node)) {
605 return HiddenReplyType.None
606 }
607
608 // handle blurred items
609 if (node.ctx.depth > 0) {
610 const modui = modCache.get(node)?.ui('contentList')
611 if (modui?.blur || modui?.filter) {
612 if (!showHiddenReplies || node.ctx.depth > 1) {
613 if ((modui.blurs[0] || modui.filters[0]).type === 'muted') {
614 return HiddenReplyType.Muted
615 }
616 return HiddenReplyType.Hidden
617 }
618 }
619 }
620
621 if (!node.ctx.isHighlightedPost) {
622 yield node
623 }
624
625 if (node.replies?.length) {
626 let hiddenReplies = HiddenReplyType.None
627 for (const reply of node.replies) {
628 let hiddenReply = yield* flattenThreadReplies(
629 reply,
630 hasSession,
631 treeView,
632 modCache,
633 showHiddenReplies,
634 )
635 if (hiddenReply > hiddenReplies) {
636 hiddenReplies = hiddenReply
637 }
638 if (!treeView && !node.ctx.isHighlightedPost) {
639 break
640 }
641 }
642
643 // show control to enable hidden replies
644 if (node.ctx.depth === 0) {
645 if (hiddenReplies === HiddenReplyType.Muted) {
646 yield SHOW_MUTED_REPLIES
647 } else if (hiddenReplies === HiddenReplyType.Hidden) {
648 yield SHOW_HIDDEN_REPLIES
649 }
650 }
651 }
652 } else if (node.type === 'not-found') {
653 yield node
654 } else if (node.type === 'blocked') {
655 yield node
656 }
657 return HiddenReplyType.None
658}
659
660function hasPwiOptOut(node: ThreadPost) {
661 return !!node.post.author.labels?.find(l => l.val === '!no-unauthenticated')
662}
663
664function hasBranchingReplies(node?: ThreadNode) {
665 if (!node) {
666 return false
667 }
668 if (node.type !== 'post') {
669 return false
670 }
671 if (!node.replies) {
672 return false
673 }
674 if (node.replies.length === 1) {
675 return hasBranchingReplies(node.replies[0])
676 }
677 return true
678}
679
680const styles = StyleSheet.create({
681 prompt: {
682 // @ts-ignore web-only
683 position: isWeb ? 'fixed' : 'absolute',
684 left: 0,
685 right: 0,
686 },
687})