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