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