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