mirror of https://git.lenooby09.tech/LeNooby09/social-app.git
1import {useCallback, useMemo, useRef, useState} from 'react'
2import {useWindowDimensions, View} from 'react-native'
3import Animated, {useAnimatedStyle} from 'react-native-reanimated'
4import {Trans} from '@lingui/macro'
5
6import {useInitialNumToRender} from '#/lib/hooks/useInitialNumToRender'
7import {useOpenComposer} from '#/lib/hooks/useOpenComposer'
8import {useFeedFeedback} from '#/state/feed-feedback'
9import {type ThreadViewOption} from '#/state/queries/preferences/useThreadPreferences'
10import {type ThreadItem, usePostThread} from '#/state/queries/usePostThread'
11import {useSession} from '#/state/session'
12import {type OnPostSuccessData} from '#/state/shell/composer'
13import {useShellLayout} from '#/state/shell/shell-layout'
14import {useUnstablePostSource} from '#/state/unstable-post-source'
15import {List, type ListMethods} from '#/view/com/util/List'
16import {HeaderDropdown} from '#/screens/PostThread/components/HeaderDropdown'
17import {ThreadComposePrompt} from '#/screens/PostThread/components/ThreadComposePrompt'
18import {ThreadError} from '#/screens/PostThread/components/ThreadError'
19import {
20 ThreadItemAnchor,
21 ThreadItemAnchorSkeleton,
22} from '#/screens/PostThread/components/ThreadItemAnchor'
23import {ThreadItemAnchorNoUnauthenticated} from '#/screens/PostThread/components/ThreadItemAnchorNoUnauthenticated'
24import {
25 ThreadItemPost,
26 ThreadItemPostSkeleton,
27} from '#/screens/PostThread/components/ThreadItemPost'
28import {ThreadItemPostNoUnauthenticated} from '#/screens/PostThread/components/ThreadItemPostNoUnauthenticated'
29import {ThreadItemPostTombstone} from '#/screens/PostThread/components/ThreadItemPostTombstone'
30import {ThreadItemReadMore} from '#/screens/PostThread/components/ThreadItemReadMore'
31import {ThreadItemReadMoreUp} from '#/screens/PostThread/components/ThreadItemReadMoreUp'
32import {ThreadItemReplyComposerSkeleton} from '#/screens/PostThread/components/ThreadItemReplyComposer'
33import {ThreadItemShowOtherReplies} from '#/screens/PostThread/components/ThreadItemShowOtherReplies'
34import {
35 ThreadItemTreePost,
36 ThreadItemTreePostSkeleton,
37} from '#/screens/PostThread/components/ThreadItemTreePost'
38import {atoms as a, native, platform, useBreakpoints, web} from '#/alf'
39import * as Layout from '#/components/Layout'
40import {ListFooter} from '#/components/Lists'
41import {LoggedOutCTA} from '#/components/LoggedOutCTA'
42
43const PARENT_CHUNK_SIZE = 5
44const CHILDREN_CHUNK_SIZE = 50
45
46export function PostThread({uri}: {uri: string}) {
47 const {gtMobile} = useBreakpoints()
48 const {hasSession} = useSession()
49 const initialNumToRender = useInitialNumToRender()
50 const {height: windowHeight} = useWindowDimensions()
51 const anchorPostSource = useUnstablePostSource(uri)
52 const feedFeedback = useFeedFeedback(
53 anchorPostSource?.feedSourceInfo,
54 hasSession,
55 )
56
57 /*
58 * One query to rule them all
59 */
60 const thread = usePostThread({anchor: uri})
61 const {anchor, hasParents} = useMemo(() => {
62 // eslint-disable-next-line @typescript-eslint/no-shadow
63 let hasParents = false
64 for (const item of thread.data.items) {
65 if (item.type === 'threadPost' && item.depth === 0) {
66 return {anchor: item, hasParents}
67 }
68 hasParents = true
69 }
70 return {hasParents}
71 }, [thread.data.items])
72
73 const {openComposer} = useOpenComposer()
74 const optimisticOnPostReply = useCallback(
75 (payload: OnPostSuccessData) => {
76 if (payload) {
77 const {replyToUri, posts} = payload
78 if (replyToUri && posts.length) {
79 thread.actions.insertReplies(replyToUri, posts)
80 }
81 }
82 },
83 [thread],
84 )
85 const onReplyToAnchor = useCallback(() => {
86 if (anchor?.type !== 'threadPost') {
87 return
88 }
89 const post = anchor.value.post
90 openComposer({
91 replyTo: {
92 uri: anchor.uri,
93 cid: post.cid,
94 text: post.record.text,
95 author: post.author,
96 embed: post.embed,
97 moderation: anchor.moderation,
98 langs: post.record.langs,
99 },
100 onPostSuccess: optimisticOnPostReply,
101 })
102
103 if (anchorPostSource) {
104 feedFeedback.sendInteraction({
105 item: post.uri,
106 event: 'app.bsky.feed.defs#interactionReply',
107 feedContext: anchorPostSource.post.feedContext,
108 reqId: anchorPostSource.post.reqId,
109 })
110 }
111 }, [
112 anchor,
113 openComposer,
114 optimisticOnPostReply,
115 anchorPostSource,
116 feedFeedback,
117 ])
118
119 const isRoot = !!anchor && anchor.value.post.record.reply === undefined
120 const canReply = !anchor?.value.post?.viewer?.replyDisabled
121 const [maxParentCount, setMaxParentCount] = useState(PARENT_CHUNK_SIZE)
122 const [maxChildrenCount, setMaxChildrenCount] = useState(CHILDREN_CHUNK_SIZE)
123 const totalParentCount = useRef(0) // recomputed below
124 const totalChildrenCount = useRef(thread.data.items.length) // recomputed below
125 const listRef = useRef<ListMethods>(null)
126 const anchorRef = useRef<View | null>(null)
127 const headerRef = useRef<View | null>(null)
128
129 /*
130 * On a cold load, parents are not prepended until the anchor post has
131 * rendered as the first item in the list. This gives us a consistent
132 * reference point for which to pin the anchor post to the top of the screen.
133 *
134 * We simulate a cold load any time the user changes the view or sort params
135 * so that this handling is consistent.
136 *
137 * On native, `maintainVisibleContentPosition={{minIndexForVisible: 0}}` gives
138 * us this for free, since the anchor post is the first item in the list.
139 *
140 * On web, `onContentSizeChange` is used to get ahead of next paint and handle
141 * this scrolling.
142 */
143 const [deferParents, setDeferParents] = useState(true)
144 /**
145 * Used to flag whether we should scroll to the anchor post. On a cold load,
146 * this is always true. And when a user changes thread parameters, we also
147 * manually set this to true.
148 */
149 const shouldHandleScroll = useRef(true)
150 /**
151 * Called any time the content size of the list changes, _just_ before paint.
152 *
153 * We want this to fire every time we change params (which will reset
154 * `deferParents` via `onLayout` on the anchor post, due to the key change),
155 * or click into a new post (which will result in a fresh `deferParents`
156 * hook).
157 *
158 * The result being: any intentional change in view by the user will result
159 * in the anchor being pinned as the first item.
160 */
161 const onContentSizeChangeWebOnly = web(() => {
162 const list = listRef.current
163 const anchor = anchorRef.current as any as Element
164 const header = headerRef.current as any as Element
165
166 if (list && anchor && header && shouldHandleScroll.current) {
167 const anchorOffsetTop = anchor.getBoundingClientRect().top
168 const headerHeight = header.getBoundingClientRect().height
169
170 /*
171 * `deferParents` is `true` on a cold load, and always reset to
172 * `true` when params change via `prepareForParamsUpdate`.
173 *
174 * On a cold load or a push to a new post, on the first pass of this
175 * logic, the anchor post is the first item in the list. Therefore
176 * `anchorOffsetTop - headerHeight` will be 0.
177 *
178 * When a user changes thread params, on the first pass of this logic,
179 * the anchor post may not move (if there are no parents above it), or it
180 * may have gone off the screen above, because of the sudden lack of
181 * parents due to `deferParents === true`. This negative value (minus
182 * `headerHeight`) will result in a _negative_ `offset` value, which will
183 * scroll the anchor post _down_ to the top of the screen.
184 *
185 * However, `prepareForParamsUpdate` also resets scroll to `0`, so when a user
186 * changes params, the anchor post's offset will actually be equivalent
187 * to the `headerHeight` because of how the DOM is stacked on web.
188 * Therefore, `anchorOffsetTop - headerHeight` will once again be 0,
189 * which means the first pass in this case will result in no scroll.
190 *
191 * Then, once parents are prepended, this will fire again. Now, the
192 * `anchorOffsetTop` will be positive, which minus the header height,
193 * will give us a _positive_ offset, which will scroll the anchor post
194 * back _up_ to the top of the screen.
195 */
196 list.scrollToOffset({
197 offset: anchorOffsetTop - headerHeight,
198 })
199
200 /*
201 * After the second pass, `deferParents` will be `false`, and we need
202 * to ensure this doesn't run again until scroll handling is requested
203 * again via `shouldHandleScroll.current === true` and a params
204 * change via `prepareForParamsUpdate`.
205 *
206 * The `isRoot` here is needed because if we're looking at the anchor
207 * post, this handler will not fire after `deferParents` is set to
208 * `false`, since there are no parents to render above it. In this case,
209 * we want to make sure `shouldHandleScroll` is set to `false` so that
210 * subsequent size changes unrelated to a params change (like pagination)
211 * do not affect scroll.
212 */
213 if (!deferParents || isRoot) shouldHandleScroll.current = false
214 }
215 })
216
217 /**
218 * Ditto the above, but for native.
219 */
220 const onContentSizeChangeNativeOnly = native(() => {
221 const list = listRef.current
222 const anchor = anchorRef.current
223
224 if (list && anchor && shouldHandleScroll.current) {
225 /*
226 * `prepareForParamsUpdate` is called any time the user changes thread params like
227 * `view` or `sort`, which sets `deferParents(true)` and resets the
228 * scroll to the top of the list. However, there is a split second
229 * where the top of the list is wherever the parents _just were_. So if
230 * there were parents, the anchor is not at the top of the list just
231 * prior to this handler being called.
232 *
233 * Once this handler is called, the anchor post is the first item in
234 * the list (because of `deferParents` being `true`), and so we can
235 * synchronously scroll the list back to the top of the list (which is
236 * 0 on native, no need to handle `headerHeight`).
237 */
238 list.scrollToOffset({
239 animated: false,
240 offset: 0,
241 })
242
243 /*
244 * After this first pass, `deferParents` will be `false`, and those
245 * will render in. However, the anchor post will retain its position
246 * because of `maintainVisibleContentPosition` handling on native. So we
247 * don't need to let this handler run again, like we do on web.
248 */
249 shouldHandleScroll.current = false
250 }
251 })
252
253 /**
254 * Called any time the user changes thread params, such as `view` or `sort`.
255 * Prepares the UI for repositioning of the scroll so that the anchor post is
256 * always at the top after a params change.
257 *
258 * No need to handle max parents here, deferParents will handle that and we
259 * want it to re-render with the same items above the anchor.
260 */
261 const prepareForParamsUpdate = useCallback(() => {
262 /**
263 * Truncate list so that anchor post is the first item in the list. Manual
264 * scroll handling on web is predicated on this, and on native, this allows
265 * `maintainVisibleContentPosition` to do its thing.
266 */
267 setDeferParents(true)
268 // reset this to a lower value for faster re-render
269 setMaxChildrenCount(CHILDREN_CHUNK_SIZE)
270 // set flag
271 shouldHandleScroll.current = true
272 }, [setDeferParents, setMaxChildrenCount])
273
274 const setSortWrapped = useCallback(
275 (sort: string) => {
276 prepareForParamsUpdate()
277 thread.actions.setSort(sort)
278 },
279 [thread, prepareForParamsUpdate],
280 )
281
282 const setViewWrapped = useCallback(
283 (view: ThreadViewOption) => {
284 prepareForParamsUpdate()
285 thread.actions.setView(view)
286 },
287 [thread, prepareForParamsUpdate],
288 )
289
290 const onStartReached = () => {
291 if (thread.state.isFetching) return
292 // can be true after `prepareForParamsUpdate` is called
293 if (deferParents) return
294 // prevent any state mutations if we know we're done
295 if (maxParentCount >= totalParentCount.current) return
296 setMaxParentCount(n => n + PARENT_CHUNK_SIZE)
297 }
298
299 const onEndReached = () => {
300 if (thread.state.isFetching) return
301 // can be true after `prepareForParamsUpdate` is called
302 if (deferParents) return
303 // prevent any state mutations if we know we're done
304 if (maxChildrenCount >= totalChildrenCount.current) return
305 setMaxChildrenCount(prev => prev + CHILDREN_CHUNK_SIZE)
306 }
307
308 const slices = useMemo(() => {
309 const results: ThreadItem[] = []
310
311 if (!thread.data.items.length) return results
312
313 /*
314 * Pagination hack, tracks the # of items below the anchor post.
315 */
316 let childrenCount = 0
317
318 for (let i = 0; i < thread.data.items.length; i++) {
319 const item = thread.data.items[i]
320 /*
321 * Need to check `depth`, since not found or blocked posts are not
322 * `threadPost`s, but still have `depth`.
323 */
324 const hasDepth = 'depth' in item
325
326 /*
327 * Handle anchor post.
328 */
329 if (hasDepth && item.depth === 0) {
330 results.push(item)
331
332 // Recalculate total parents current index.
333 totalParentCount.current = i
334 // Recalculate total children using (length - 1) - current index.
335 totalChildrenCount.current = thread.data.items.length - 1 - i
336
337 /*
338 * Walk up the parents, limiting by `maxParentCount`
339 */
340 if (!deferParents) {
341 const start = i - 1
342 if (start >= 0) {
343 const limit = Math.max(0, start - maxParentCount)
344 for (let pi = start; pi >= limit; pi--) {
345 results.unshift(thread.data.items[pi])
346 }
347 }
348 }
349 } else {
350 // ignore any parent items
351 if (item.type === 'readMoreUp' || (hasDepth && item.depth < 0)) continue
352 // can exit early if we've reached the max children count
353 if (childrenCount > maxChildrenCount) break
354
355 results.push(item)
356 childrenCount++
357 }
358 }
359
360 return results
361 }, [thread, deferParents, maxParentCount, maxChildrenCount])
362
363 const isTombstoneView = useMemo(() => {
364 if (slices.length > 1) return false
365 return slices.every(
366 s => s.type === 'threadPostBlocked' || s.type === 'threadPostNotFound',
367 )
368 }, [slices])
369
370 const renderItem = useCallback(
371 ({item, index}: {item: ThreadItem; index: number}) => {
372 if (item.type === 'threadPost') {
373 if (item.depth < 0) {
374 return (
375 <ThreadItemPost
376 item={item}
377 threadgateRecord={thread.data.threadgate?.record ?? undefined}
378 overrides={{
379 topBorder: index === 0,
380 }}
381 onPostSuccess={optimisticOnPostReply}
382 />
383 )
384 } else if (item.depth === 0) {
385 return (
386 /*
387 * Keep this view wrapped so that the anchor post is always index 0
388 * in the list and `maintainVisibleContentPosition` can do its
389 * thing.
390 */
391 <View collapsable={false}>
392 <View
393 /*
394 * IMPORTANT: this is a load-bearing key on all platforms. We
395 * want to force `onLayout` to fire any time the thread params
396 * change so that `deferParents` is always reset to `false` once
397 * the anchor post is rendered.
398 *
399 * If we ever add additional thread params to this screen, they
400 * will need to be added here.
401 */
402 key={item.uri + thread.state.view + thread.state.sort}
403 ref={anchorRef}
404 onLayout={() => setDeferParents(false)}
405 />
406 <ThreadItemAnchor
407 item={item}
408 threadgateRecord={thread.data.threadgate?.record ?? undefined}
409 onPostSuccess={optimisticOnPostReply}
410 postSource={anchorPostSource}
411 />
412 {/* Show CTA for logged-out visitors */}
413 <LoggedOutCTA style={a.px_lg} gateName="cta_above_post_replies" />
414 </View>
415 )
416 } else {
417 if (thread.state.view === 'tree') {
418 return (
419 <ThreadItemTreePost
420 item={item}
421 threadgateRecord={thread.data.threadgate?.record ?? undefined}
422 overrides={{
423 moderation: thread.state.otherItemsVisible && item.depth > 0,
424 }}
425 onPostSuccess={optimisticOnPostReply}
426 />
427 )
428 } else {
429 return (
430 <ThreadItemPost
431 item={item}
432 threadgateRecord={thread.data.threadgate?.record ?? undefined}
433 overrides={{
434 moderation: thread.state.otherItemsVisible && item.depth > 0,
435 }}
436 onPostSuccess={optimisticOnPostReply}
437 />
438 )
439 }
440 }
441 } else if (item.type === 'threadPostNoUnauthenticated') {
442 if (item.depth < 0) {
443 return <ThreadItemPostNoUnauthenticated item={item} />
444 } else if (item.depth === 0) {
445 return <ThreadItemAnchorNoUnauthenticated />
446 }
447 } else if (item.type === 'readMore') {
448 return (
449 <ThreadItemReadMore
450 item={item}
451 view={thread.state.view === 'tree' ? 'tree' : 'linear'}
452 />
453 )
454 } else if (item.type === 'readMoreUp') {
455 return <ThreadItemReadMoreUp item={item} />
456 } else if (item.type === 'threadPostBlocked') {
457 return <ThreadItemPostTombstone type="blocked" />
458 } else if (item.type === 'threadPostNotFound') {
459 return <ThreadItemPostTombstone type="not-found" />
460 } else if (item.type === 'replyComposer') {
461 return (
462 <View>
463 {gtMobile && (
464 <ThreadComposePrompt onPressCompose={onReplyToAnchor} />
465 )}
466 </View>
467 )
468 } else if (item.type === 'showOtherReplies') {
469 return <ThreadItemShowOtherReplies onPress={item.onPress} />
470 } else if (item.type === 'skeleton') {
471 if (item.item === 'anchor') {
472 return <ThreadItemAnchorSkeleton />
473 } else if (item.item === 'reply') {
474 if (thread.state.view === 'linear') {
475 return <ThreadItemPostSkeleton index={index} />
476 } else {
477 return <ThreadItemTreePostSkeleton index={index} />
478 }
479 } else if (item.item === 'replyComposer') {
480 return <ThreadItemReplyComposerSkeleton />
481 }
482 }
483 return null
484 },
485 [
486 thread,
487 optimisticOnPostReply,
488 onReplyToAnchor,
489 gtMobile,
490 anchorPostSource,
491 ],
492 )
493
494 const defaultListFooterHeight = hasParents ? windowHeight - 200 : undefined
495
496 return (
497 <>
498 <Layout.Header.Outer headerRef={headerRef}>
499 <Layout.Header.BackButton />
500 <Layout.Header.Content>
501 <Layout.Header.TitleText>
502 <Trans context="description">Post</Trans>
503 </Layout.Header.TitleText>
504 </Layout.Header.Content>
505 <Layout.Header.Slot>
506 <HeaderDropdown
507 sort={thread.state.sort}
508 setSort={setSortWrapped}
509 view={thread.state.view}
510 setView={setViewWrapped}
511 />
512 </Layout.Header.Slot>
513 </Layout.Header.Outer>
514
515 {thread.state.error ? (
516 <ThreadError
517 error={thread.state.error}
518 onRetry={thread.actions.refetch}
519 />
520 ) : (
521 <List
522 ref={listRef}
523 data={slices}
524 renderItem={renderItem}
525 keyExtractor={keyExtractor}
526 onContentSizeChange={platform({
527 web: onContentSizeChangeWebOnly,
528 default: onContentSizeChangeNativeOnly,
529 })}
530 onStartReached={onStartReached}
531 onEndReached={onEndReached}
532 onEndReachedThreshold={4}
533 onStartReachedThreshold={1}
534 /**
535 * NATIVE ONLY
536 * {@link https://reactnative.dev/docs/scrollview#maintainvisiblecontentposition}
537 */
538 maintainVisibleContentPosition={{minIndexForVisible: 0}}
539 desktopFixedHeight
540 sideBorders={false}
541 ListFooterComponent={
542 <ListFooter
543 /*
544 * On native, if `deferParents` is true, we need some extra buffer to
545 * account for the `on*ReachedThreshold` values.
546 *
547 * Otherwise, and on web, this value needs to be the height of
548 * the viewport _minus_ a sensible min-post height e.g. 200, so
549 * that there's enough scroll remaining to get the anchor post
550 * back to the top of the screen when handling scroll.
551 */
552 height={platform({
553 web: defaultListFooterHeight,
554 default: deferParents
555 ? windowHeight * 2
556 : defaultListFooterHeight,
557 })}
558 style={isTombstoneView ? {borderTopWidth: 0} : undefined}
559 />
560 }
561 initialNumToRender={initialNumToRender}
562 /**
563 * Default: 21
564 */
565 windowSize={7}
566 /**
567 * Default: 10
568 */
569 maxToRenderPerBatch={5}
570 /**
571 * Default: 50
572 */
573 updateCellsBatchingPeriod={100}
574 />
575 )}
576
577 {!gtMobile && canReply && hasSession && (
578 <MobileComposePrompt onPressReply={onReplyToAnchor} />
579 )}
580 </>
581 )
582}
583
584function MobileComposePrompt({onPressReply}: {onPressReply: () => unknown}) {
585 const {footerHeight} = useShellLayout()
586
587 const animatedStyle = useAnimatedStyle(() => {
588 return {
589 bottom: footerHeight.get(),
590 }
591 })
592
593 return (
594 <Animated.View style={[a.fixed, a.left_0, a.right_0, animatedStyle]}>
595 <ThreadComposePrompt onPressCompose={onPressReply} />
596 </Animated.View>
597 )
598}
599
600const keyExtractor = (item: ThreadItem) => {
601 return item.key
602}