forked from
jollywhoppers.com/witchsky.app
Bluesky app fork with some witchin' additions 馃挮
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 {useAlsoLikedCollapseByDefault} from '#/state/preferences/also-liked-collapse-by-default'
19import {useAlsoLikedFeedEnabled} from '#/state/preferences/also-liked-feed-enabled'
20import {usePostAlsoLikedQuery} from '#/state/queries/post-also-liked'
21import {type ThreadViewOption} from '#/state/queries/preferences/useThreadPreferences'
22import {
23 PostThreadContextProvider,
24 type ThreadItem,
25 usePostThread,
26} from '#/state/queries/usePostThread'
27import {useSession} from '#/state/session'
28import {type OnPostSuccessData} from '#/state/shell/composer'
29import {useShellLayout} from '#/state/shell/shell-layout'
30import {useUnstablePostSource} from '#/state/unstable-post-source'
31import {List, type ListMethods} from '#/view/com/util/List'
32import {HeaderDropdown} from '#/screens/PostThread/components/HeaderDropdown'
33import {ThreadAlsoLiked} from '#/screens/PostThread/components/ThreadAlsoLiked'
34import {ThreadComposePrompt} from '#/screens/PostThread/components/ThreadComposePrompt'
35import {ThreadError} from '#/screens/PostThread/components/ThreadError'
36import {
37 ThreadItemAnchor,
38 ThreadItemAnchorSkeleton,
39} from '#/screens/PostThread/components/ThreadItemAnchor'
40import {ThreadItemAnchorNoUnauthenticated} from '#/screens/PostThread/components/ThreadItemAnchorNoUnauthenticated'
41import {
42 ThreadItemPost,
43 ThreadItemPostSkeleton,
44} from '#/screens/PostThread/components/ThreadItemPost'
45import {ThreadItemPostNoUnauthenticated} from '#/screens/PostThread/components/ThreadItemPostNoUnauthenticated'
46import {ThreadItemPostTombstone} from '#/screens/PostThread/components/ThreadItemPostTombstone'
47import {ThreadItemReadMore} from '#/screens/PostThread/components/ThreadItemReadMore'
48import {ThreadItemReadMoreUp} from '#/screens/PostThread/components/ThreadItemReadMoreUp'
49import {ThreadItemReplyComposerSkeleton} from '#/screens/PostThread/components/ThreadItemReplyComposer'
50import {ThreadItemShowOtherReplies} from '#/screens/PostThread/components/ThreadItemShowOtherReplies'
51import {
52 ThreadItemTreePost,
53 ThreadItemTreePostSkeleton,
54} from '#/screens/PostThread/components/ThreadItemTreePost'
55import {atoms as a, native, platform, useBreakpoints, web} from '#/alf'
56import * as Layout from '#/components/Layout'
57import {useAnalytics} from '#/analytics'
58import {IS_NATIVE} from '#/env'
59
60const PARENT_CHUNK_SIZE = IS_NATIVE ? 5 : 20
61const CHILDREN_CHUNK_SIZE = 50
62
63export function PostThread({uri}: {uri: string}) {
64 const ax = useAnalytics()
65 const {gtMobile} = useBreakpoints()
66 const {hasSession} = useSession()
67 const initialNumToRender = useInitialNumToRender()
68 const {height: windowHeight} = useWindowDimensions()
69 const anchorPostSource = useUnstablePostSource(uri)
70 const feedFeedback = useFeedFeedback(
71 anchorPostSource?.feedSourceInfo,
72 hasSession,
73 )
74
75 /*
76 * One query to rule them all
77 */
78 const thread = usePostThread({anchor: uri})
79 const {anchor, hasParents} = (() => {
80 let hasParents = false
81 for (const item of thread.data.items) {
82 if (item.type === 'threadPost' && item.depth === 0) {
83 return {anchor: item, hasParents}
84 }
85 hasParents = true
86 }
87 return {hasParents}
88 })()
89
90 // Track post:view event when anchor post is viewed
91 const seenPostUriRef = useRef<string | null>(null)
92 useEffect(() => {
93 if (
94 anchor?.type === 'threadPost' &&
95 anchor.value.post.uri !== seenPostUriRef.current
96 ) {
97 const post = anchor.value.post
98 seenPostUriRef.current = post.uri
99
100 ax.metric('post:view', {
101 uri: post.uri,
102 authorDid: post.author.did,
103 logContext: 'Post',
104 feedDescriptor: feedFeedback.feedDescriptor,
105 })
106 }
107 }, [ax, anchor, feedFeedback.feedDescriptor])
108
109 // Track post:view events for parent posts and replies (non-anchor posts)
110 const trackThreadItemView = usePostViewTracking('PostThreadItem')
111
112 const {openComposer} = useOpenComposer()
113 const optimisticOnPostReply = useNonReactiveCallback(
114 (payload: OnPostSuccessData) => {
115 if (payload) {
116 const {replyToUri, posts} = payload
117 if (replyToUri && posts.length) {
118 thread.actions.insertReplies(replyToUri, posts)
119 }
120 }
121 },
122 )
123 const onReplyToAnchor = useNonReactiveCallback(() => {
124 if (anchor?.type !== 'threadPost') {
125 return
126 }
127 const post = anchor.value.post
128 openComposer({
129 replyTo: {
130 uri: anchor.uri,
131 cid: post.cid,
132 text: post.record.text,
133 author: post.author,
134 embed: post.embed,
135 moderation: anchor.moderation,
136 langs: post.record.langs,
137 },
138 onPostSuccess: optimisticOnPostReply,
139 logContext: 'PostReply',
140 })
141
142 if (anchorPostSource) {
143 feedFeedback.sendInteraction({
144 item: post.uri,
145 event: 'app.bsky.feed.defs#interactionReply',
146 feedContext: anchorPostSource.post.feedContext,
147 reqId: anchorPostSource.post.reqId,
148 })
149 }
150 })
151
152 const isRoot = !!anchor && anchor.value.post.record.reply === undefined
153 const canReply = !anchor?.value.post?.viewer?.replyDisabled
154 const alsoLikedFeedEnabled = useAlsoLikedFeedEnabled()
155 const alsoLikedCollapseByDefault = useAlsoLikedCollapseByDefault()
156 const alsoLikedAnchorUri =
157 anchor?.type === 'threadPost' && isRoot ? anchor.value.post.uri : undefined
158 const [deferParents, setDeferParents] = useState(true)
159 const [alsoLikedCollapsed, setAlsoLikedCollapsed] = useState(
160 alsoLikedCollapseByDefault,
161 )
162 useEffect(() => {
163 setAlsoLikedCollapsed(alsoLikedCollapseByDefault)
164 }, [alsoLikedAnchorUri, alsoLikedCollapseByDefault])
165 const alsoLikedVisible =
166 Boolean(alsoLikedAnchorUri) &&
167 alsoLikedFeedEnabled &&
168 !thread.state.isPlaceholderData &&
169 !deferParents
170 const alsoLikedEnabled = alsoLikedVisible && !alsoLikedCollapsed
171 const alsoLiked = usePostAlsoLikedQuery(alsoLikedAnchorUri, {
172 enabled: alsoLikedEnabled,
173 })
174 const alsoLikedPosts = useMemo(() => {
175 const seen = new Set<string>()
176 return (alsoLiked.data?.pages ?? [])
177 .flatMap(page => page.posts)
178 .filter(post => {
179 if (seen.has(post.uri)) return false
180 seen.add(post.uri)
181 return true
182 })
183 }, [alsoLiked.data])
184 const [maxParentCount, setMaxParentCount] = useState(PARENT_CHUNK_SIZE)
185 const [maxChildrenCount, setMaxChildrenCount] = useState(CHILDREN_CHUNK_SIZE)
186 const totalParentCount = useRef(0) // recomputed below
187 const totalChildrenCount = useRef(thread.data.items.length) // recomputed below
188 const listRef = useRef<ListMethods>(null)
189 const anchorRef = useRef<View | null>(null)
190 const headerRef = useRef<View | null>(null)
191 const alsoLikedHeaderRef = useRef<View | null>(null)
192 const currentScrollOffsetRef = useRef(0)
193 const scrollStateRequestIdRef = useRef(0)
194 const scrollStateAnimationFrameRef = useRef<number | null>(null)
195 const contentSizeAnimationFrameRef = useRef<number | null>(null)
196 const [isAlsoLikedFocused, setIsAlsoLikedFocused] = useState(false)
197
198 useEffect(() => {
199 if (!alsoLikedVisible || alsoLikedCollapsed) {
200 setIsAlsoLikedFocused(false)
201 }
202 }, [alsoLikedCollapsed, alsoLikedVisible])
203
204 /*
205 * On a cold load, parents are not prepended until the anchor post has
206 * rendered as the first item in the list. This gives us a consistent
207 * reference point for which to pin the anchor post to the top of the screen.
208 *
209 * We simulate a cold load any time the user changes the view or sort params
210 * so that this handling is consistent.
211 *
212 * On native, `maintainVisibleContentPosition={{minIndexForVisible: 0}}` gives
213 * us this for free, since the anchor post is the first item in the list.
214 *
215 * On web, `onContentSizeChange` is used to get ahead of next paint and handle
216 * this scrolling.
217 */
218 /**
219 * Used to flag whether we should scroll to the anchor post. On a cold load,
220 * this is always true. And when a user changes thread parameters, we also
221 * manually set this to true.
222 */
223 const shouldHandleScroll = useRef(true)
224 /**
225 * Called any time the content size of the list changes. Could be a fresh
226 * render, items being added to the list, or any resize that changes the
227 * scrollable size of the content.
228 *
229 * We want this to fire every time we change params (which will reset
230 * `deferParents` via `onLayout` on the anchor post, due to the key change),
231 * or click into a new post (which will result in a fresh `deferParents`
232 * hook).
233 *
234 * The result being: any intentional change in view by the user will result
235 * in the anchor being pinned as the first item.
236 */
237 const onContentSizeChangeWebOnly = web(
238 useNonReactiveCallback(() => {
239 if (contentSizeAnimationFrameRef.current !== null) {
240 cancelAnimationFrame(contentSizeAnimationFrameRef.current)
241 }
242
243 contentSizeAnimationFrameRef.current = requestAnimationFrame(() => {
244 contentSizeAnimationFrameRef.current = null
245 const list = listRef.current
246 const anchorElement = anchorRef.current as any as Element
247 const header = headerRef.current as any as Element
248
249 if (list && anchorElement && header && shouldHandleScroll.current) {
250 const anchorOffsetTop = anchorElement.getBoundingClientRect().top
251 const headerHeight = header.getBoundingClientRect().height
252
253 /*
254 * `deferParents` is `true` on a cold load, and always reset to
255 * `true` when params change via `prepareForParamsUpdate`.
256 *
257 * On a cold load or a push to a new post, on the first pass of this
258 * logic, the anchor post is the first item in the list. Therefore
259 * `anchorOffsetTop - headerHeight` will be 0.
260 *
261 * When a user changes thread params, on the first pass of this logic,
262 * the anchor post may not move (if there are no parents above it), or it
263 * may have gone off the screen above, because of the sudden lack of
264 * parents due to `deferParents === true`. This negative value (minus
265 * `headerHeight`) will result in a _negative_ `offset` value, which will
266 * scroll the anchor post _down_ to the top of the screen.
267 *
268 * However, `prepareForParamsUpdate` also resets scroll to `0`, so when a user
269 * changes params, the anchor post's offset will actually be equivalent
270 * to the `headerHeight` because of how the DOM is stacked on web.
271 * Therefore, `anchorOffsetTop - headerHeight` will once again be 0,
272 * which means the first pass in this case will result in no scroll.
273 *
274 * Then, once parents are prepended, this will fire again. Now, the
275 * `anchorOffsetTop` will be positive, which minus the header height,
276 * will give us a _positive_ offset, which will scroll the anchor post
277 * back _up_ to the top of the screen.
278 */
279 const offset = anchorOffsetTop - headerHeight
280 list.scrollToOffset({offset})
281
282 /*
283 * After we manage to do a positive adjustment, we need to ensure this
284 * doesn't run again until scroll handling is requested again via
285 * `shouldHandleScroll.current === true` and a params change via
286 * `prepareForParamsUpdate`.
287 *
288 * The `isRoot` here is needed because if we're looking at the anchor
289 * post, this handler will not fire after `deferParents` is set to
290 * `false`, since there are no parents to render above it. In this case,
291 * we want to make sure `shouldHandleScroll` is set to `false` right away
292 * so that subsequent size changes unrelated to a params change (like
293 * pagination) do not affect scroll.
294 */
295 if (offset > 0 || isRoot) shouldHandleScroll.current = false
296 }
297 })
298 }),
299 )
300
301 /**
302 * Ditto the above, but for native.
303 */
304 const onContentSizeChangeNativeOnly = native(() => {
305 const list = listRef.current
306 const anchorElement = anchorRef.current
307
308 if (list && anchorElement && shouldHandleScroll.current) {
309 /*
310 * `prepareForParamsUpdate` is called any time the user changes thread params like
311 * `view` or `sort`, which sets `deferParents(true)` and resets the
312 * scroll to the top of the list. However, there is a split second
313 * where the top of the list is wherever the parents _just were_. So if
314 * there were parents, the anchor is not at the top of the list just
315 * prior to this handler being called.
316 *
317 * Once this handler is called, the anchor post is the first item in
318 * the list (because of `deferParents` being `true`), and so we can
319 * synchronously scroll the list back to the top of the list (which is
320 * 0 on native, no need to handle `headerHeight`).
321 */
322 list.scrollToOffset({
323 animated: false,
324 offset: 0,
325 })
326
327 /*
328 * After this first pass, `deferParents` will be `false`, and those
329 * will render in. However, the anchor post will retain its position
330 * because of `maintainVisibleContentPosition` handling on native. So we
331 * don't need to let this handler run again, like we do on web.
332 */
333 shouldHandleScroll.current = false
334 }
335 })
336
337 /**
338 * Called any time the user changes thread params, such as `view` or `sort`.
339 * Prepares the UI for repositioning of the scroll so that the anchor post is
340 * always at the top after a params change.
341 *
342 * No need to handle max parents here, deferParents will handle that and we
343 * want it to re-render with the same items above the anchor.
344 */
345 const prepareForParamsUpdate = useCallback(() => {
346 /**
347 * Truncate list so that anchor post is the first item in the list. Manual
348 * scroll handling on web is predicated on this, and on native, this allows
349 * `maintainVisibleContentPosition` to do its thing.
350 */
351 setDeferParents(true)
352 // reset this to a lower value for faster re-render
353 setMaxChildrenCount(CHILDREN_CHUNK_SIZE)
354 // set flag
355 shouldHandleScroll.current = true
356 }, [setDeferParents, setMaxChildrenCount])
357
358 const setSortWrapped = useCallback(
359 (sort: string) => {
360 prepareForParamsUpdate()
361 thread.actions.setSort(sort)
362 },
363 [thread, prepareForParamsUpdate],
364 )
365
366 const setViewWrapped = useCallback(
367 (view: ThreadViewOption) => {
368 prepareForParamsUpdate()
369 thread.actions.setView(view)
370 },
371 [thread, prepareForParamsUpdate],
372 )
373
374 const onStartReached = () => {
375 if (thread.state.isFetching) return
376 // can be true after `prepareForParamsUpdate` is called
377 if (deferParents) return
378 // prevent any state mutations if we know we're done
379 if (maxParentCount >= totalParentCount.current) return
380 setMaxParentCount(n => n + PARENT_CHUNK_SIZE)
381 }
382
383 const onEndReached = () => {
384 if (thread.state.isFetching) return
385 // can be true after `prepareForParamsUpdate` is called
386 if (deferParents) return
387 if (maxChildrenCount < totalChildrenCount.current) {
388 setMaxChildrenCount(prev => prev + CHILDREN_CHUNK_SIZE)
389 return
390 }
391 if (
392 alsoLikedAnchorUri &&
393 alsoLikedEnabled &&
394 !alsoLiked.isLoading &&
395 !alsoLiked.isFetchingNextPage &&
396 alsoLiked.hasNextPage
397 ) {
398 void alsoLiked.fetchNextPage()
399 }
400 }
401
402 const slices = useMemo(() => {
403 const results: ThreadItem[] = []
404
405 if (!thread.data.items.length) return results
406
407 /*
408 * Pagination hack, tracks the # of items below the anchor post.
409 */
410 let childrenCount = 0
411
412 for (let i = 0; i < thread.data.items.length; i++) {
413 const item = thread.data.items[i]
414 /*
415 * Need to check `depth`, since not found or blocked posts are not
416 * `threadPost`s, but still have `depth`.
417 */
418 const hasDepth = 'depth' in item
419
420 /*
421 * Handle anchor post.
422 */
423 if (hasDepth && item.depth === 0) {
424 results.push(item)
425
426 // Recalculate total parents current index.
427 totalParentCount.current = i
428 // Recalculate total children using (length - 1) - current index.
429 totalChildrenCount.current = thread.data.items.length - 1 - i
430
431 /*
432 * Walk up the parents, limiting by `maxParentCount`
433 */
434 if (!deferParents) {
435 const start = i - 1
436 if (start >= 0) {
437 const limit = Math.max(0, start - maxParentCount)
438 for (let pi = start; pi >= limit; pi--) {
439 results.unshift(thread.data.items[pi])
440 }
441 }
442 }
443 } else {
444 // ignore any parent items
445 if (item.type === 'readMoreUp' || (hasDepth && item.depth < 0)) continue
446 // can exit early if we've reached the max children count
447 if (childrenCount > maxChildrenCount) break
448
449 results.push(item)
450 childrenCount++
451 }
452 }
453
454 return results
455 }, [thread, deferParents, maxParentCount, maxChildrenCount])
456
457 /**
458 * Defer rendering reply skeletons so that the anchor post (from cache)
459 * can paint without being blocked by skeleton layout work. On mount,
460 * skeletons are filtered out. After the first render, they're added
461 * back via a low-priority transition.
462 */
463 const [showReplySkeletons, setShowReplySkeletons] = useState(false)
464 useEffect(() => {
465 if (thread.state.isPlaceholderData && !showReplySkeletons) {
466 startTransition(() => {
467 setShowReplySkeletons(true)
468 })
469 }
470 }, [thread.state.isPlaceholderData, showReplySkeletons])
471
472 const deferredSlices = useMemo(() => {
473 if (showReplySkeletons) return slices
474 return slices.filter(
475 item => !(item.type === 'skeleton' && item.item === 'reply'),
476 )
477 }, [slices, showReplySkeletons])
478
479 const isTombstoneView = useMemo(() => {
480 if (deferredSlices.length > 1) return false
481 return deferredSlices.every(
482 s => s.type === 'threadPostBlocked' || s.type === 'threadPostNotFound',
483 )
484 }, [deferredSlices])
485
486 const renderItem = useCallback(
487 ({item, index}: {item: ThreadItem; index: number}) => {
488 if (item.type === 'threadPost') {
489 if (item.depth < 0) {
490 return (
491 <ThreadItemPost
492 item={item}
493 threadgateRecord={thread.data.threadgate?.record ?? undefined}
494 overrides={{
495 topBorder: index === 0,
496 }}
497 onPostSuccess={optimisticOnPostReply}
498 />
499 )
500 } else if (item.depth === 0) {
501 return (
502 /*
503 * Keep this view wrapped so that the anchor post is always index 0
504 * in the list and `maintainVisibleContentPosition` can do its
505 * thing.
506 */
507 <View collapsable={false}>
508 <View
509 /*
510 * IMPORTANT: this is a load-bearing key on all platforms. We
511 * want to force `onLayout` to fire any time the thread params
512 * change so that `deferParents` is always reset to `false` once
513 * the anchor post is rendered.
514 *
515 * If we ever add additional thread params to this screen, they
516 * will need to be added here.
517 */
518 key={item.uri + thread.state.view + thread.state.sort}
519 ref={anchorRef}
520 onLayout={() => setDeferParents(false)}
521 />
522 <ThreadItemAnchor
523 item={item}
524 threadgateRecord={thread.data.threadgate?.record ?? undefined}
525 onPostSuccess={optimisticOnPostReply}
526 postSource={anchorPostSource}
527 />
528 </View>
529 )
530 } else {
531 if (thread.state.view === 'tree') {
532 return (
533 <ThreadItemTreePost
534 item={item}
535 threadgateRecord={thread.data.threadgate?.record ?? undefined}
536 overrides={{
537 moderation: thread.state.otherItemsVisible && item.depth > 0,
538 }}
539 onPostSuccess={optimisticOnPostReply}
540 />
541 )
542 } else {
543 return (
544 <ThreadItemPost
545 item={item}
546 threadgateRecord={thread.data.threadgate?.record ?? undefined}
547 overrides={{
548 moderation: thread.state.otherItemsVisible && item.depth > 0,
549 }}
550 onPostSuccess={optimisticOnPostReply}
551 />
552 )
553 }
554 }
555 } else if (item.type === 'threadPostNoUnauthenticated') {
556 if (item.depth < 0) {
557 return <ThreadItemPostNoUnauthenticated item={item} />
558 } else if (item.depth === 0) {
559 return <ThreadItemAnchorNoUnauthenticated />
560 }
561 } else if (item.type === 'readMore') {
562 return (
563 <ThreadItemReadMore
564 item={item}
565 view={thread.state.view === 'tree' ? 'tree' : 'linear'}
566 />
567 )
568 } else if (item.type === 'readMoreUp') {
569 return <ThreadItemReadMoreUp item={item} />
570 } else if (item.type === 'threadPostBlocked') {
571 return <ThreadItemPostTombstone type="blocked" />
572 } else if (item.type === 'threadPostNotFound') {
573 return <ThreadItemPostTombstone type="not-found" />
574 } else if (item.type === 'replyComposer') {
575 return (
576 <View>
577 {gtMobile && (
578 <ThreadComposePrompt onPressCompose={onReplyToAnchor} />
579 )}
580 </View>
581 )
582 } else if (item.type === 'showOtherReplies') {
583 return <ThreadItemShowOtherReplies onPress={item.onPress} />
584 } else if (item.type === 'skeleton') {
585 if (item.item === 'anchor') {
586 return <ThreadItemAnchorSkeleton />
587 } else if (item.item === 'reply') {
588 if (thread.state.view === 'linear') {
589 return <ThreadItemPostSkeleton index={index} />
590 } else {
591 return <ThreadItemTreePostSkeleton index={index} />
592 }
593 } else if (item.item === 'replyComposer') {
594 return <ThreadItemReplyComposerSkeleton />
595 }
596 }
597 return null
598 },
599 [
600 thread,
601 optimisticOnPostReply,
602 onReplyToAnchor,
603 gtMobile,
604 anchorPostSource,
605 ],
606 )
607
608 const defaultListFooterHeight = hasParents ? windowHeight - 200 : undefined
609 const retryAlsoLiked = useCallback(() => {
610 if (alsoLikedPosts.length > 0) {
611 void alsoLiked.fetchNextPage()
612 } else {
613 void alsoLiked.refetch()
614 }
615 }, [alsoLiked, alsoLikedPosts.length])
616 const toggleAlsoLikedCollapsed = useCallback(() => {
617 setAlsoLikedCollapsed(current => !current)
618 }, [])
619 const runAlsoLikedScrollStateUpdate = useNonReactiveCallback(
620 (requestId: number) => {
621 scrollStateAnimationFrameRef.current = null
622
623 if (
624 !alsoLikedVisible ||
625 alsoLikedCollapsed ||
626 !alsoLikedHeaderRef.current ||
627 !headerRef.current
628 ) {
629 setIsAlsoLikedFocused(false)
630 return
631 }
632
633 measureViewRect(headerRef.current, headerRect => {
634 if (requestId !== scrollStateRequestIdRef.current) return
635 if (!headerRect) {
636 setIsAlsoLikedFocused(false)
637 return
638 }
639
640 measureViewRect(alsoLikedHeaderRef.current, alsoLikedRect => {
641 if (requestId !== scrollStateRequestIdRef.current) return
642 if (!alsoLikedRect) {
643 setIsAlsoLikedFocused(false)
644 return
645 }
646
647 const headerBottom = headerRect.y + headerRect.height
648 const focused = alsoLikedRect.y <= headerBottom
649
650 setIsAlsoLikedFocused(current =>
651 current === focused ? current : focused,
652 )
653 })
654 })
655 },
656 )
657 const scheduleAlsoLikedScrollStateUpdate = useNonReactiveCallback(() => {
658 if (scrollStateAnimationFrameRef.current !== null) {
659 cancelAnimationFrame(scrollStateAnimationFrameRef.current)
660 }
661
662 const requestId = scrollStateRequestIdRef.current
663 scrollStateAnimationFrameRef.current = requestAnimationFrame(() => {
664 runAlsoLikedScrollStateUpdate(requestId)
665 })
666 })
667 const handleScrollOffsetChange = useNonReactiveCallback((offsetY: number) => {
668 currentScrollOffsetRef.current = offsetY
669 if (offsetY <= 1) {
670 scrollStateRequestIdRef.current += 1
671 setIsAlsoLikedFocused(false)
672 return
673 }
674 if (!alsoLikedVisible || alsoLikedCollapsed) {
675 return
676 }
677 scrollStateRequestIdRef.current += 1
678 scheduleAlsoLikedScrollStateUpdate()
679 })
680
681 useEffect(() => {
682 return () => {
683 if (scrollStateAnimationFrameRef.current !== null) {
684 cancelAnimationFrame(scrollStateAnimationFrameRef.current)
685 }
686 if (contentSizeAnimationFrameRef.current !== null) {
687 cancelAnimationFrame(contentSizeAnimationFrameRef.current)
688 }
689 }
690 }, [])
691
692 useEffect(() => {
693 scrollStateRequestIdRef.current += 1
694 scheduleAlsoLikedScrollStateUpdate()
695 }, [
696 alsoLikedCollapsed,
697 alsoLikedPosts.length,
698 alsoLikedVisible,
699 scheduleAlsoLikedScrollStateUpdate,
700 ])
701
702 return (
703 <PostThreadContextProvider context={thread.context}>
704 <Layout.Header.Outer headerRef={headerRef}>
705 <Layout.Header.BackButton />
706 <Layout.Header.Content>
707 <Layout.Header.TitleText>
708 {isAlsoLikedFocused ? (
709 <Trans>Posts also liked</Trans>
710 ) : (
711 <Trans context="description">Post</Trans>
712 )}
713 </Layout.Header.TitleText>
714 </Layout.Header.Content>
715 <Layout.Header.Slot>
716 <HeaderDropdown
717 sort={thread.state.sort}
718 setSort={setSortWrapped}
719 view={thread.state.view}
720 setView={setViewWrapped}
721 />
722 </Layout.Header.Slot>
723 </Layout.Header.Outer>
724
725 {thread.state.error ? (
726 <ThreadError
727 error={thread.state.error}
728 onRetry={thread.actions.refetch}
729 />
730 ) : (
731 <List
732 ref={listRef}
733 data={deferredSlices}
734 renderItem={renderItem}
735 keyExtractor={keyExtractor}
736 onContentSizeChange={platform({
737 web: onContentSizeChangeWebOnly,
738 default: onContentSizeChangeNativeOnly,
739 })}
740 onStartReached={onStartReached}
741 onEndReached={onEndReached}
742 onEndReachedThreshold={4}
743 onStartReachedThreshold={1}
744 onScrollOffsetChange={handleScrollOffsetChange}
745 onItemSeen={item => {
746 // Track post:view for parent posts and replies (non-anchor posts)
747 if (item.type === 'threadPost' && item.depth !== 0) {
748 trackThreadItemView(item.value.post)
749 }
750 }}
751 /**
752 * NATIVE ONLY
753 * {@link https://reactnative.dev/docs/scrollview#maintainvisiblecontentposition}
754 */
755 maintainVisibleContentPosition={{minIndexForVisible: 0}}
756 desktopFixedHeight
757 sideBorders={false}
758 ListFooterComponent={
759 <ThreadAlsoLiked
760 posts={alsoLikedPosts}
761 visible={alsoLikedVisible}
762 collapsed={alsoLikedCollapsed}
763 isLoading={alsoLiked.isLoading}
764 showLoadingState={
765 !alsoLikedCollapsed &&
766 !alsoLiked.error &&
767 alsoLikedPosts.length === 0 &&
768 (!alsoLiked.isFetched || alsoLiked.isLoading)
769 }
770 isFetchingNextPage={alsoLiked.isFetchingNextPage}
771 error={alsoLiked.error}
772 onRetry={retryAlsoLiked}
773 headerRef={alsoLikedHeaderRef}
774 onToggleCollapsed={toggleAlsoLikedCollapsed}
775 spacerHeight={platform({
776 web: defaultListFooterHeight,
777 default: deferParents
778 ? windowHeight * 2
779 : defaultListFooterHeight,
780 })}
781 isTombstoneView={isTombstoneView}
782 />
783 }
784 initialNumToRender={initialNumToRender}
785 /**
786 * Default: 21
787 *
788 * Smaller for placeholder data so we don't waste time rendering skeletons
789 */
790 windowSize={thread.state.isPlaceholderData ? 1 : 7}
791 /**
792 * Default: 10
793 */
794 maxToRenderPerBatch={5}
795 /**
796 * Default: 50
797 */
798 updateCellsBatchingPeriod={100}
799 />
800 )}
801
802 {!gtMobile && canReply && hasSession && (
803 <MobileComposePrompt onPressReply={onReplyToAnchor} />
804 )}
805 </PostThreadContextProvider>
806 )
807}
808
809function MobileComposePrompt({onPressReply}: {onPressReply: () => unknown}) {
810 const {footerHeight} = useShellLayout()
811
812 const animatedStyle = useAnimatedStyle(() => {
813 return {
814 bottom: footerHeight.get(),
815 }
816 })
817
818 return (
819 <Animated.View style={[a.fixed, a.left_0, a.right_0, animatedStyle]}>
820 <ThreadComposePrompt onPressCompose={onPressReply} />
821 </Animated.View>
822 )
823}
824
825const keyExtractor = (item: ThreadItem) => {
826 return item.key
827}
828
829type ViewRect = {
830 x: number
831 y: number
832 width: number
833 height: number
834}
835
836function measureViewRect(
837 view: View | null,
838 cb: (rect: ViewRect | null) => void,
839) {
840 const target = view as any
841 if (!target) {
842 cb(null)
843 return
844 }
845
846 if (typeof target.measureInWindow === 'function') {
847 target.measureInWindow(
848 (x: number, y: number, width: number, height: number) => {
849 cb({x, y, width, height})
850 },
851 )
852 return
853 }
854
855 if (typeof target.getBoundingClientRect === 'function') {
856 const rect = target.getBoundingClientRect()
857 cb({
858 x: rect.left,
859 y: rect.top,
860 width: rect.width,
861 height: rect.height,
862 })
863 return
864 }
865
866 if (typeof target.measure === 'function') {
867 target.measure(
868 (
869 _x: number,
870 _y: number,
871 width: number,
872 height: number,
873 pageX: number,
874 pageY: number,
875 ) => {
876 cb({x: pageX, y: pageY, width, height})
877 },
878 )
879 return
880 }
881
882 cb(null)
883}