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 facets: post.record.facets,
123 author: post.author,
124 embed: post.embed,
125 moderation: anchor.moderation,
126 langs: post.record.langs,
127 },
128 onPostSuccess: optimisticOnPostReply,
129 logContext: 'PostReply',
130 })
131
132 if (anchorPostSource) {
133 feedFeedback.sendInteraction({
134 item: post.uri,
135 event: 'app.bsky.feed.defs#interactionReply',
136 feedContext: anchorPostSource.post.feedContext,
137 reqId: anchorPostSource.post.reqId,
138 })
139 }
140 }, [
141 anchor,
142 openComposer,
143 optimisticOnPostReply,
144 anchorPostSource,
145 feedFeedback,
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 const isTombstoneView = useMemo(() => {
394 if (slices.length > 1) return false
395 return slices.every(
396 s => s.type === 'threadPostBlocked' || s.type === 'threadPostNotFound',
397 )
398 }, [slices])
399
400 const renderItem = useCallback(
401 ({item, index}: {item: ThreadItem; index: number}) => {
402 if (item.type === 'threadPost') {
403 if (item.depth < 0) {
404 return (
405 <ThreadItemPost
406 item={item}
407 threadgateRecord={thread.data.threadgate?.record ?? undefined}
408 overrides={{
409 topBorder: index === 0,
410 }}
411 onPostSuccess={optimisticOnPostReply}
412 />
413 )
414 } else if (item.depth === 0) {
415 return (
416 /*
417 * Keep this view wrapped so that the anchor post is always index 0
418 * in the list and `maintainVisibleContentPosition` can do its
419 * thing.
420 */
421 <View collapsable={false}>
422 <View
423 /*
424 * IMPORTANT: this is a load-bearing key on all platforms. We
425 * want to force `onLayout` to fire any time the thread params
426 * change so that `deferParents` is always reset to `false` once
427 * the anchor post is rendered.
428 *
429 * If we ever add additional thread params to this screen, they
430 * will need to be added here.
431 */
432 key={item.uri + thread.state.view + thread.state.sort}
433 ref={anchorRef}
434 onLayout={() => setDeferParents(false)}
435 />
436 <ThreadItemAnchor
437 item={item}
438 threadgateRecord={thread.data.threadgate?.record ?? undefined}
439 onPostSuccess={optimisticOnPostReply}
440 postSource={anchorPostSource}
441 />
442 </View>
443 )
444 } else {
445 if (thread.state.view === 'tree') {
446 return (
447 <ThreadItemTreePost
448 item={item}
449 threadgateRecord={thread.data.threadgate?.record ?? undefined}
450 overrides={{
451 moderation: thread.state.otherItemsVisible && item.depth > 0,
452 }}
453 onPostSuccess={optimisticOnPostReply}
454 />
455 )
456 } else {
457 return (
458 <ThreadItemPost
459 item={item}
460 threadgateRecord={thread.data.threadgate?.record ?? undefined}
461 overrides={{
462 moderation: thread.state.otherItemsVisible && item.depth > 0,
463 }}
464 onPostSuccess={optimisticOnPostReply}
465 />
466 )
467 }
468 }
469 } else if (item.type === 'threadPostNoUnauthenticated') {
470 if (item.depth < 0) {
471 return <ThreadItemPostNoUnauthenticated item={item} />
472 } else if (item.depth === 0) {
473 return <ThreadItemAnchorNoUnauthenticated />
474 }
475 } else if (item.type === 'readMore') {
476 return (
477 <ThreadItemReadMore
478 item={item}
479 view={thread.state.view === 'tree' ? 'tree' : 'linear'}
480 />
481 )
482 } else if (item.type === 'readMoreUp') {
483 return <ThreadItemReadMoreUp item={item} />
484 } else if (item.type === 'threadPostBlocked') {
485 return <ThreadItemPostTombstone type="blocked" />
486 } else if (item.type === 'threadPostNotFound') {
487 return <ThreadItemPostTombstone type="not-found" />
488 } else if (item.type === 'replyComposer') {
489 return (
490 <View>
491 {gtMobile && (
492 <ThreadComposePrompt onPressCompose={onReplyToAnchor} />
493 )}
494 </View>
495 )
496 } else if (item.type === 'showOtherReplies') {
497 return <ThreadItemShowOtherReplies onPress={item.onPress} />
498 } else if (item.type === 'skeleton') {
499 if (item.item === 'anchor') {
500 return <ThreadItemAnchorSkeleton />
501 } else if (item.item === 'reply') {
502 if (thread.state.view === 'linear') {
503 return <ThreadItemPostSkeleton index={index} />
504 } else {
505 return <ThreadItemTreePostSkeleton index={index} />
506 }
507 } else if (item.item === 'replyComposer') {
508 return <ThreadItemReplyComposerSkeleton />
509 }
510 }
511 return null
512 },
513 [
514 thread,
515 optimisticOnPostReply,
516 onReplyToAnchor,
517 gtMobile,
518 anchorPostSource,
519 ],
520 )
521
522 const defaultListFooterHeight = hasParents ? windowHeight - 200 : undefined
523
524 return (
525 <PostThreadContextProvider context={thread.context}>
526 <Layout.Header.Outer headerRef={headerRef}>
527 <Layout.Header.BackButton />
528 <Layout.Header.Content>
529 <Layout.Header.TitleText>
530 <Trans context="description">Post</Trans>
531 </Layout.Header.TitleText>
532 </Layout.Header.Content>
533 <Layout.Header.Slot>
534 <HeaderDropdown
535 sort={thread.state.sort}
536 setSort={setSortWrapped}
537 view={thread.state.view}
538 setView={setViewWrapped}
539 />
540 </Layout.Header.Slot>
541 </Layout.Header.Outer>
542
543 {thread.state.error ? (
544 <ThreadError
545 error={thread.state.error}
546 onRetry={thread.actions.refetch}
547 />
548 ) : (
549 <List
550 ref={listRef}
551 data={slices}
552 renderItem={renderItem}
553 keyExtractor={keyExtractor}
554 onContentSizeChange={platform({
555 web: onContentSizeChangeWebOnly,
556 default: onContentSizeChangeNativeOnly,
557 })}
558 onStartReached={onStartReached}
559 onEndReached={onEndReached}
560 onEndReachedThreshold={4}
561 onStartReachedThreshold={1}
562 onItemSeen={item => {
563 // Track post:view for parent posts and replies (non-anchor posts)
564 if (item.type === 'threadPost' && item.depth !== 0) {
565 trackThreadItemView(item.value.post)
566 }
567 }}
568 /**
569 * NATIVE ONLY
570 * {@link https://reactnative.dev/docs/scrollview#maintainvisiblecontentposition}
571 */
572 maintainVisibleContentPosition={{minIndexForVisible: 0}}
573 desktopFixedHeight
574 sideBorders={false}
575 ListFooterComponent={
576 <ListFooter
577 /*
578 * On native, if `deferParents` is true, we need some extra buffer to
579 * account for the `on*ReachedThreshold` values.
580 *
581 * Otherwise, and on web, this value needs to be the height of
582 * the viewport _minus_ a sensible min-post height e.g. 200, so
583 * that there's enough scroll remaining to get the anchor post
584 * back to the top of the screen when handling scroll.
585 */
586 height={platform({
587 web: defaultListFooterHeight,
588 default: deferParents
589 ? windowHeight * 2
590 : defaultListFooterHeight,
591 })}
592 style={isTombstoneView ? {borderTopWidth: 0} : undefined}
593 />
594 }
595 initialNumToRender={initialNumToRender}
596 /**
597 * Default: 21
598 */
599 windowSize={7}
600 /**
601 * Default: 10
602 */
603 maxToRenderPerBatch={5}
604 /**
605 * Default: 50
606 */
607 updateCellsBatchingPeriod={100}
608 />
609 )}
610
611 {!gtMobile && canReply && hasSession && (
612 <MobileComposePrompt onPressReply={onReplyToAnchor} />
613 )}
614 </PostThreadContextProvider>
615 )
616}
617
618function MobileComposePrompt({onPressReply}: {onPressReply: () => unknown}) {
619 const {footerHeight} = useShellLayout()
620
621 const animatedStyle = useAnimatedStyle(() => {
622 return {
623 bottom: footerHeight.get(),
624 }
625 })
626
627 return (
628 <Animated.View style={[a.fixed, a.left_0, a.right_0, animatedStyle]}>
629 <ThreadComposePrompt onPressCompose={onPressReply} />
630 </Animated.View>
631 )
632}
633
634const keyExtractor = (item: ThreadItem) => {
635 return item.key
636}