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