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