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