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