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, currentAccount} = 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 currentDid = currentAccount?.did
158 const threadModerationCache = React.useMemo(() => {
159 const cache: ThreadModerationCache = new WeakMap()
160 if (thread && moderationOpts) {
161 fillThreadModerationCache(cache, thread, moderationOpts)
162 }
163 return cache
164 }, [thread, moderationOpts])
165
166 const skeleton = React.useMemo(() => {
167 const threadViewPrefs = preferences?.threadViewPrefs
168 if (!threadViewPrefs || !thread) return null
169
170 return createThreadSkeleton(
171 sortThread(thread, threadViewPrefs, threadModerationCache, currentDid),
172 !!currentDid,
173 treeView,
174 threadModerationCache,
175 hiddenRepliesState !== HiddenRepliesState.Hide,
176 )
177 }, [
178 thread,
179 preferences?.threadViewPrefs,
180 currentDid,
181 treeView,
182 threadModerationCache,
183 hiddenRepliesState,
184 ])
185
186 const error = React.useMemo(() => {
187 if (AppBskyFeedDefs.isNotFoundPost(thread)) {
188 return {
189 title: _(msg`Post not found`),
190 message: _(msg`The post may have been deleted.`),
191 }
192 } else if (skeleton?.highlightedPost.type === 'blocked') {
193 return {
194 title: _(msg`Post hidden`),
195 message: _(
196 msg`You have blocked the author or you have been blocked by the author.`,
197 ),
198 }
199 } else if (threadError?.message.startsWith('Post not found')) {
200 return {
201 title: _(msg`Post not found`),
202 message: _(msg`The post may have been deleted.`),
203 }
204 } else if (isThreadError) {
205 return {
206 message: threadError ? cleanError(threadError) : undefined,
207 }
208 }
209
210 return null
211 }, [thread, skeleton?.highlightedPost, isThreadError, _, threadError])
212
213 useEffect(() => {
214 if (error) {
215 onCanReply(false)
216 } else if (rootPost) {
217 onCanReply(!rootPost.viewer?.replyDisabled)
218 }
219 }, [rootPost, onCanReply, error])
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 hasParents =
317 skeleton?.highlightedPost?.type === 'post' &&
318 (skeleton.highlightedPost.ctx.isParentLoading ||
319 Boolean(skeleton?.parents && skeleton.parents.length > 0))
320 const showHeader =
321 isNative || (isTabletOrMobile && (!hasParents || !isFetching))
322
323 const renderItem = ({item, index}: {item: RowItem; index: number}) => {
324 if (item === REPLY_PROMPT && hasSession) {
325 return (
326 <View>
327 {!isMobile && <ComposePrompt onPressCompose={onPressReply} />}
328 </View>
329 )
330 } else if (item === SHOW_HIDDEN_REPLIES || item === SHOW_MUTED_REPLIES) {
331 return (
332 <PostThreadShowHiddenReplies
333 type={item === SHOW_HIDDEN_REPLIES ? 'hidden' : 'muted'}
334 onPress={() =>
335 setHiddenRepliesState(
336 item === SHOW_HIDDEN_REPLIES
337 ? HiddenRepliesState.Show
338 : HiddenRepliesState.ShowAndOverridePostHider,
339 )
340 }
341 hideTopBorder={index === 0}
342 />
343 )
344 } else if (isThreadNotFound(item)) {
345 return (
346 <View
347 style={[
348 a.p_lg,
349 index !== 0 && a.border_t,
350 t.atoms.border_contrast_low,
351 t.atoms.bg_contrast_25,
352 ]}>
353 <Text style={[a.font_bold, a.text_md, t.atoms.text_contrast_medium]}>
354 <Trans>Deleted post.</Trans>
355 </Text>
356 </View>
357 )
358 } else if (isThreadBlocked(item)) {
359 return (
360 <View
361 style={[
362 a.p_lg,
363 index !== 0 && a.border_t,
364 t.atoms.border_contrast_low,
365 t.atoms.bg_contrast_25,
366 ]}>
367 <Text style={[a.font_bold, a.text_md, t.atoms.text_contrast_medium]}>
368 <Trans>Blocked post.</Trans>
369 </Text>
370 </View>
371 )
372 } else if (isThreadPost(item)) {
373 if (!treeView && item.ctx.hasMoreSelfThread) {
374 return <PostThreadLoadMore post={item.post} />
375 }
376 const prev = isThreadPost(posts[index - 1])
377 ? (posts[index - 1] as ThreadPost)
378 : undefined
379 const next = isThreadPost(posts[index + 1])
380 ? (posts[index + 1] as ThreadPost)
381 : undefined
382 const showChildReplyLine = (next?.ctx.depth || 0) > item.ctx.depth
383 const showParentReplyLine =
384 (item.ctx.depth < 0 && !!item.parent) || item.ctx.depth > 1
385 const hasUnrevealedParents =
386 index === 0 && skeleton?.parents && maxParents < skeleton.parents.length
387 return (
388 <View
389 ref={item.ctx.isHighlightedPost ? highlightedPostRef : undefined}
390 onLayout={deferParents ? () => setDeferParents(false) : undefined}>
391 <PostThreadItem
392 post={item.post}
393 record={item.record}
394 moderation={threadModerationCache.get(item)}
395 treeView={treeView}
396 depth={item.ctx.depth}
397 prevPost={prev}
398 nextPost={next}
399 isHighlightedPost={item.ctx.isHighlightedPost}
400 hasMore={item.ctx.hasMore}
401 showChildReplyLine={showChildReplyLine}
402 showParentReplyLine={showParentReplyLine}
403 hasPrecedingItem={showParentReplyLine || !!hasUnrevealedParents}
404 overrideBlur={
405 hiddenRepliesState ===
406 HiddenRepliesState.ShowAndOverridePostHider &&
407 item.ctx.depth > 0
408 }
409 onPostReply={refetch}
410 hideTopBorder={index === 0 && !item.ctx.isParentLoading}
411 />
412 </View>
413 )
414 }
415 return null
416 }
417
418 if (!thread || !preferences || error) {
419 return (
420 <ListMaybePlaceholder
421 isLoading={!error}
422 isError={Boolean(error)}
423 noEmpty
424 onRetry={refetch}
425 errorTitle={error?.title}
426 errorMessage={error?.message}
427 />
428 )
429 }
430
431 return (
432 <CenteredView style={[a.flex_1]} sideBorders={true}>
433 {showHeader && (
434 <ViewHeader
435 title={_(msg({message: `Post`, context: 'description'}))}
436 showBorder
437 />
438 )}
439
440 <ScrollProvider onMomentumEnd={onMomentumEnd}>
441 <List
442 ref={ref}
443 data={posts}
444 renderItem={renderItem}
445 keyExtractor={keyExtractor}
446 onContentSizeChange={isNative ? undefined : onContentSizeChangeWeb}
447 onStartReached={onStartReached}
448 onEndReached={onEndReached}
449 onEndReachedThreshold={2}
450 onScrollToTop={onScrollToTop}
451 maintainVisibleContentPosition={
452 isNative && hasParents
453 ? MAINTAIN_VISIBLE_CONTENT_POSITION
454 : undefined
455 }
456 // @ts-ignore our .web version only -prf
457 desktopFixedHeight
458 removeClippedSubviews={isAndroid ? false : undefined}
459 ListFooterComponent={
460 <ListFooter
461 // Using `isFetching` over `isFetchingNextPage` is done on purpose here so we get the loader on
462 // initial render
463 isFetchingNextPage={isFetching}
464 error={cleanError(threadError)}
465 onRetry={refetch}
466 // 300 is based on the minimum height of a post. This is enough extra height for the `maintainVisPos` to
467 // work without causing weird jumps on web or glitches on native
468 height={windowHeight - 200}
469 />
470 }
471 initialNumToRender={initialNumToRender}
472 windowSize={11}
473 sideBorders={false}
474 />
475 </ScrollProvider>
476 </CenteredView>
477 )
478}
479
480function isThreadPost(v: unknown): v is ThreadPost {
481 return !!v && typeof v === 'object' && 'type' in v && v.type === 'post'
482}
483
484function isThreadNotFound(v: unknown): v is ThreadNotFound {
485 return !!v && typeof v === 'object' && 'type' in v && v.type === 'not-found'
486}
487
488function isThreadBlocked(v: unknown): v is ThreadBlocked {
489 return !!v && typeof v === 'object' && 'type' in v && v.type === 'blocked'
490}
491
492function createThreadSkeleton(
493 node: ThreadNode,
494 hasSession: boolean,
495 treeView: boolean,
496 modCache: ThreadModerationCache,
497 showHiddenReplies: boolean,
498): ThreadSkeletonParts | null {
499 if (!node) return null
500
501 return {
502 parents: Array.from(flattenThreadParents(node, hasSession)),
503 highlightedPost: node,
504 replies: Array.from(
505 flattenThreadReplies(
506 node,
507 hasSession,
508 treeView,
509 modCache,
510 showHiddenReplies,
511 ),
512 ),
513 }
514}
515
516function* flattenThreadParents(
517 node: ThreadNode,
518 hasSession: boolean,
519): Generator<YieldedItem, void> {
520 if (node.type === 'post') {
521 if (node.parent) {
522 yield* flattenThreadParents(node.parent, hasSession)
523 }
524 if (!node.ctx.isHighlightedPost) {
525 yield node
526 }
527 } else if (node.type === 'not-found') {
528 yield node
529 } else if (node.type === 'blocked') {
530 yield node
531 }
532}
533
534// The enum is ordered to make them easy to merge
535enum HiddenReplyType {
536 None = 0,
537 Muted = 1,
538 Hidden = 2,
539}
540
541function* flattenThreadReplies(
542 node: ThreadNode,
543 hasSession: boolean,
544 treeView: boolean,
545 modCache: ThreadModerationCache,
546 showHiddenReplies: boolean,
547): Generator<YieldedItem, HiddenReplyType> {
548 if (node.type === 'post') {
549 // dont show pwi-opted-out posts to logged out users
550 if (!hasSession && hasPwiOptOut(node)) {
551 return HiddenReplyType.None
552 }
553
554 // handle blurred items
555 if (node.ctx.depth > 0) {
556 const modui = modCache.get(node)?.ui('contentList')
557 if (modui?.blur || modui?.filter) {
558 if (!showHiddenReplies || node.ctx.depth > 1) {
559 if ((modui.blurs[0] || modui.filters[0]).type === 'muted') {
560 return HiddenReplyType.Muted
561 }
562 return HiddenReplyType.Hidden
563 }
564 }
565 }
566
567 if (!node.ctx.isHighlightedPost) {
568 yield node
569 }
570
571 if (node.replies?.length) {
572 let hiddenReplies = HiddenReplyType.None
573 for (const reply of node.replies) {
574 let hiddenReply = yield* flattenThreadReplies(
575 reply,
576 hasSession,
577 treeView,
578 modCache,
579 showHiddenReplies,
580 )
581 if (hiddenReply > hiddenReplies) {
582 hiddenReplies = hiddenReply
583 }
584 if (!treeView && !node.ctx.isHighlightedPost) {
585 break
586 }
587 }
588
589 // show control to enable hidden replies
590 if (node.ctx.depth === 0) {
591 if (hiddenReplies === HiddenReplyType.Muted) {
592 yield SHOW_MUTED_REPLIES
593 } else if (hiddenReplies === HiddenReplyType.Hidden) {
594 yield SHOW_HIDDEN_REPLIES
595 }
596 }
597 }
598 } else if (node.type === 'not-found') {
599 yield node
600 } else if (node.type === 'blocked') {
601 yield node
602 }
603 return HiddenReplyType.None
604}
605
606function hasPwiOptOut(node: ThreadPost) {
607 return !!node.post.author.labels?.find(l => l.val === '!no-unauthenticated')
608}
609
610function hasBranchingReplies(node?: ThreadNode) {
611 if (!node) {
612 return false
613 }
614 if (node.type !== 'post') {
615 return false
616 }
617 if (!node.replies) {
618 return false
619 }
620 if (node.replies.length === 1) {
621 return hasBranchingReplies(node.replies[0])
622 }
623 return true
624}