Bluesky app fork with some witchin' additions 馃挮
witchsky.app
bluesky
fork
client
1import {
2 startTransition,
3 useCallback,
4 useEffect,
5 useMemo,
6 useRef,
7 useState,
8} from 'react'
9import {useWindowDimensions, View} from 'react-native'
10import Animated, {useAnimatedStyle} from 'react-native-reanimated'
11import {Trans} from '@lingui/react/macro'
12
13import {useInitialNumToRender} from '#/lib/hooks/useInitialNumToRender'
14import {useNonReactiveCallback} from '#/lib/hooks/useNonReactiveCallback'
15import {useOpenComposer} from '#/lib/hooks/useOpenComposer'
16import {usePostViewTracking} from '#/lib/hooks/usePostViewTracking'
17import {useFeedFeedback} from '#/state/feed-feedback'
18import {type ThreadViewOption} from '#/state/queries/preferences/useThreadPreferences'
19import {
20 PostThreadContextProvider,
21 type ThreadItem,
22 usePostThread,
23} from '#/state/queries/usePostThread'
24import {useSession} from '#/state/session'
25import {type OnPostSuccessData} from '#/state/shell/composer'
26import {useShellLayout} from '#/state/shell/shell-layout'
27import {useUnstablePostSource} from '#/state/unstable-post-source'
28import {List, type ListMethods} from '#/view/com/util/List'
29import {HeaderDropdown} from '#/screens/PostThread/components/HeaderDropdown'
30import {ThreadComposePrompt} from '#/screens/PostThread/components/ThreadComposePrompt'
31import {ThreadError} from '#/screens/PostThread/components/ThreadError'
32import {
33 ThreadItemAnchor,
34 ThreadItemAnchorSkeleton,
35} from '#/screens/PostThread/components/ThreadItemAnchor'
36import {ThreadItemAnchorNoUnauthenticated} from '#/screens/PostThread/components/ThreadItemAnchorNoUnauthenticated'
37import {
38 ThreadItemPost,
39 ThreadItemPostSkeleton,
40} from '#/screens/PostThread/components/ThreadItemPost'
41import {ThreadItemPostNoUnauthenticated} from '#/screens/PostThread/components/ThreadItemPostNoUnauthenticated'
42import {ThreadItemPostTombstone} from '#/screens/PostThread/components/ThreadItemPostTombstone'
43import {ThreadItemReadMore} from '#/screens/PostThread/components/ThreadItemReadMore'
44import {ThreadItemReadMoreUp} from '#/screens/PostThread/components/ThreadItemReadMoreUp'
45import {ThreadItemReplyComposerSkeleton} from '#/screens/PostThread/components/ThreadItemReplyComposer'
46import {ThreadItemShowOtherReplies} from '#/screens/PostThread/components/ThreadItemShowOtherReplies'
47import {
48 ThreadItemTreePost,
49 ThreadItemTreePostSkeleton,
50} from '#/screens/PostThread/components/ThreadItemTreePost'
51import {atoms as a, native, platform, useBreakpoints, web} from '#/alf'
52import * as Layout from '#/components/Layout'
53import {ListFooter} from '#/components/Lists'
54import {useAnalytics} from '#/analytics'
55import {IS_NATIVE} from '#/env'
56
57const PARENT_CHUNK_SIZE = IS_NATIVE ? 5 : 20
58const CHILDREN_CHUNK_SIZE = 50
59
60export function PostThread({uri}: {uri: string}) {
61 const ax = useAnalytics()
62 const {gtMobile} = useBreakpoints()
63 const {hasSession} = useSession()
64 const initialNumToRender = useInitialNumToRender()
65 const {height: windowHeight} = useWindowDimensions()
66 const anchorPostSource = useUnstablePostSource(uri)
67 const feedFeedback = useFeedFeedback(
68 anchorPostSource?.feedSourceInfo,
69 hasSession,
70 )
71
72 /*
73 * One query to rule them all
74 */
75 const thread = usePostThread({anchor: uri})
76 const {anchor, hasParents} = (() => {
77 let hasParents = false
78 for (const item of thread.data.items) {
79 if (item.type === 'threadPost' && item.depth === 0) {
80 return {anchor: item, hasParents}
81 }
82 hasParents = true
83 }
84 return {hasParents}
85 })()
86
87 // Track post:view event when anchor post is viewed
88 const seenPostUriRef = useRef<string | null>(null)
89 useEffect(() => {
90 if (
91 anchor?.type === 'threadPost' &&
92 anchor.value.post.uri !== seenPostUriRef.current
93 ) {
94 const post = anchor.value.post
95 seenPostUriRef.current = post.uri
96
97 ax.metric('post:view', {
98 uri: post.uri,
99 authorDid: post.author.did,
100 logContext: 'Post',
101 feedDescriptor: feedFeedback.feedDescriptor,
102 })
103 }
104 }, [ax, anchor, feedFeedback.feedDescriptor])
105
106 // Track post:view events for parent posts and replies (non-anchor posts)
107 const trackThreadItemView = usePostViewTracking('PostThreadItem')
108
109 const {openComposer} = useOpenComposer()
110 const optimisticOnPostReply = useNonReactiveCallback(
111 (payload: OnPostSuccessData) => {
112 if (payload) {
113 const {replyToUri, posts} = payload
114 if (replyToUri && posts.length) {
115 thread.actions.insertReplies(replyToUri, posts)
116 }
117 }
118 },
119 )
120 const onReplyToAnchor = useNonReactiveCallback(() => {
121 if (anchor?.type !== 'threadPost') {
122 return
123 }
124 const post = anchor.value.post
125 openComposer({
126 replyTo: {
127 uri: anchor.uri,
128 cid: post.cid,
129 text: post.record.text,
130 author: post.author,
131 embed: post.embed,
132 moderation: anchor.moderation,
133 langs: post.record.langs,
134 },
135 onPostSuccess: optimisticOnPostReply,
136 logContext: 'PostReply',
137 })
138
139 if (anchorPostSource) {
140 feedFeedback.sendInteraction({
141 item: post.uri,
142 event: 'app.bsky.feed.defs#interactionReply',
143 feedContext: anchorPostSource.post.feedContext,
144 reqId: anchorPostSource.post.reqId,
145 })
146 }
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 /**
395 * Defer rendering reply skeletons so that the anchor post (from cache)
396 * can paint without being blocked by skeleton layout work. On mount,
397 * skeletons are filtered out. After the first render, they're added
398 * back via a low-priority transition.
399 */
400 const [showReplySkeletons, setShowReplySkeletons] = useState(false)
401 useEffect(() => {
402 if (thread.state.isPlaceholderData && !showReplySkeletons) {
403 startTransition(() => {
404 setShowReplySkeletons(true)
405 })
406 }
407 }, [thread.state.isPlaceholderData, showReplySkeletons])
408
409 const deferredSlices = useMemo(() => {
410 if (showReplySkeletons) return slices
411 return slices.filter(
412 item => !(item.type === 'skeleton' && item.item === 'reply'),
413 )
414 }, [slices, showReplySkeletons])
415
416 const isTombstoneView = useMemo(() => {
417 if (deferredSlices.length > 1) return false
418 return deferredSlices.every(
419 s => s.type === 'threadPostBlocked' || s.type === 'threadPostNotFound',
420 )
421 }, [deferredSlices])
422
423 const renderItem = useCallback(
424 ({item, index}: {item: ThreadItem; index: number}) => {
425 if (item.type === 'threadPost') {
426 if (item.depth < 0) {
427 return (
428 <ThreadItemPost
429 item={item}
430 threadgateRecord={thread.data.threadgate?.record ?? undefined}
431 overrides={{
432 topBorder: index === 0,
433 }}
434 onPostSuccess={optimisticOnPostReply}
435 />
436 )
437 } else if (item.depth === 0) {
438 return (
439 /*
440 * Keep this view wrapped so that the anchor post is always index 0
441 * in the list and `maintainVisibleContentPosition` can do its
442 * thing.
443 */
444 <View collapsable={false}>
445 <View
446 /*
447 * IMPORTANT: this is a load-bearing key on all platforms. We
448 * want to force `onLayout` to fire any time the thread params
449 * change so that `deferParents` is always reset to `false` once
450 * the anchor post is rendered.
451 *
452 * If we ever add additional thread params to this screen, they
453 * will need to be added here.
454 */
455 key={item.uri + thread.state.view + thread.state.sort}
456 ref={anchorRef}
457 onLayout={() => setDeferParents(false)}
458 />
459 <ThreadItemAnchor
460 item={item}
461 threadgateRecord={thread.data.threadgate?.record ?? undefined}
462 onPostSuccess={optimisticOnPostReply}
463 postSource={anchorPostSource}
464 />
465 </View>
466 )
467 } else {
468 if (thread.state.view === 'tree') {
469 return (
470 <ThreadItemTreePost
471 item={item}
472 threadgateRecord={thread.data.threadgate?.record ?? undefined}
473 overrides={{
474 moderation: thread.state.otherItemsVisible && item.depth > 0,
475 }}
476 onPostSuccess={optimisticOnPostReply}
477 />
478 )
479 } else {
480 return (
481 <ThreadItemPost
482 item={item}
483 threadgateRecord={thread.data.threadgate?.record ?? undefined}
484 overrides={{
485 moderation: thread.state.otherItemsVisible && item.depth > 0,
486 }}
487 onPostSuccess={optimisticOnPostReply}
488 />
489 )
490 }
491 }
492 } else if (item.type === 'threadPostNoUnauthenticated') {
493 if (item.depth < 0) {
494 return <ThreadItemPostNoUnauthenticated item={item} />
495 } else if (item.depth === 0) {
496 return <ThreadItemAnchorNoUnauthenticated />
497 }
498 } else if (item.type === 'readMore') {
499 return (
500 <ThreadItemReadMore
501 item={item}
502 view={thread.state.view === 'tree' ? 'tree' : 'linear'}
503 />
504 )
505 } else if (item.type === 'readMoreUp') {
506 return <ThreadItemReadMoreUp item={item} />
507 } else if (item.type === 'threadPostBlocked') {
508 return <ThreadItemPostTombstone type="blocked" />
509 } else if (item.type === 'threadPostNotFound') {
510 return <ThreadItemPostTombstone type="not-found" />
511 } else if (item.type === 'replyComposer') {
512 return (
513 <View>
514 {gtMobile && (
515 <ThreadComposePrompt onPressCompose={onReplyToAnchor} />
516 )}
517 </View>
518 )
519 } else if (item.type === 'showOtherReplies') {
520 return <ThreadItemShowOtherReplies onPress={item.onPress} />
521 } else if (item.type === 'skeleton') {
522 if (item.item === 'anchor') {
523 return <ThreadItemAnchorSkeleton />
524 } else if (item.item === 'reply') {
525 if (thread.state.view === 'linear') {
526 return <ThreadItemPostSkeleton index={index} />
527 } else {
528 return <ThreadItemTreePostSkeleton index={index} />
529 }
530 } else if (item.item === 'replyComposer') {
531 return <ThreadItemReplyComposerSkeleton />
532 }
533 }
534 return null
535 },
536 [
537 thread,
538 optimisticOnPostReply,
539 onReplyToAnchor,
540 gtMobile,
541 anchorPostSource,
542 ],
543 )
544
545 const defaultListFooterHeight = hasParents ? windowHeight - 200 : undefined
546
547 return (
548 <PostThreadContextProvider context={thread.context}>
549 <Layout.Header.Outer headerRef={headerRef}>
550 <Layout.Header.BackButton />
551 <Layout.Header.Content>
552 <Layout.Header.TitleText>
553 <Trans context="description">Post</Trans>
554 </Layout.Header.TitleText>
555 </Layout.Header.Content>
556 <Layout.Header.Slot>
557 <HeaderDropdown
558 sort={thread.state.sort}
559 setSort={setSortWrapped}
560 view={thread.state.view}
561 setView={setViewWrapped}
562 />
563 </Layout.Header.Slot>
564 </Layout.Header.Outer>
565
566 {thread.state.error ? (
567 <ThreadError
568 error={thread.state.error}
569 onRetry={thread.actions.refetch}
570 />
571 ) : (
572 <List
573 ref={listRef}
574 data={deferredSlices}
575 renderItem={renderItem}
576 keyExtractor={keyExtractor}
577 onContentSizeChange={platform({
578 web: onContentSizeChangeWebOnly,
579 default: onContentSizeChangeNativeOnly,
580 })}
581 onStartReached={onStartReached}
582 onEndReached={onEndReached}
583 onEndReachedThreshold={4}
584 onStartReachedThreshold={1}
585 onItemSeen={item => {
586 // Track post:view for parent posts and replies (non-anchor posts)
587 if (item.type === 'threadPost' && item.depth !== 0) {
588 trackThreadItemView(item.value.post)
589 }
590 }}
591 /**
592 * NATIVE ONLY
593 * {@link https://reactnative.dev/docs/scrollview#maintainvisiblecontentposition}
594 */
595 maintainVisibleContentPosition={{minIndexForVisible: 0}}
596 desktopFixedHeight
597 sideBorders={false}
598 ListFooterComponent={
599 <ListFooter
600 /*
601 * On native, if `deferParents` is true, we need some extra buffer to
602 * account for the `on*ReachedThreshold` values.
603 *
604 * Otherwise, and on web, this value needs to be the height of
605 * the viewport _minus_ a sensible min-post height e.g. 200, so
606 * that there's enough scroll remaining to get the anchor post
607 * back to the top of the screen when handling scroll.
608 */
609 height={platform({
610 web: defaultListFooterHeight,
611 default: deferParents
612 ? windowHeight * 2
613 : defaultListFooterHeight,
614 })}
615 style={isTombstoneView ? {borderTopWidth: 0} : undefined}
616 />
617 }
618 initialNumToRender={initialNumToRender}
619 /**
620 * Default: 21
621 *
622 * Smaller for placeholder data so we don't waste time rendering skeletons
623 */
624 windowSize={thread.state.isPlaceholderData ? 1 : 7}
625 /**
626 * Default: 10
627 */
628 maxToRenderPerBatch={5}
629 /**
630 * Default: 50
631 */
632 updateCellsBatchingPeriod={100}
633 />
634 )}
635
636 {!gtMobile && canReply && hasSession && (
637 <MobileComposePrompt onPressReply={onReplyToAnchor} />
638 )}
639 </PostThreadContextProvider>
640 )
641}
642
643function MobileComposePrompt({onPressReply}: {onPressReply: () => unknown}) {
644 const {footerHeight} = useShellLayout()
645
646 const animatedStyle = useAnimatedStyle(() => {
647 return {
648 bottom: footerHeight.get(),
649 }
650 })
651
652 return (
653 <Animated.View style={[a.fixed, a.left_0, a.right_0, animatedStyle]}>
654 <ThreadComposePrompt onPressCompose={onPressReply} />
655 </Animated.View>
656 )
657}
658
659const keyExtractor = (item: ThreadItem) => {
660 return item.key
661}