import {useCallback, useMemo, useRef, useState} from 'react' import {useWindowDimensions, View} from 'react-native' import Animated, {useAnimatedStyle} from 'react-native-reanimated' import {Trans} from '@lingui/macro' import {useInitialNumToRender} from '#/lib/hooks/useInitialNumToRender' import {useOpenComposer} from '#/lib/hooks/useOpenComposer' import {useFeedFeedback} from '#/state/feed-feedback' import {type ThreadViewOption} from '#/state/queries/preferences/useThreadPreferences' import {type ThreadItem, usePostThread} from '#/state/queries/usePostThread' import {useSession} from '#/state/session' import {type OnPostSuccessData} from '#/state/shell/composer' import {useShellLayout} from '#/state/shell/shell-layout' import {useUnstablePostSource} from '#/state/unstable-post-source' import {List, type ListMethods} from '#/view/com/util/List' import {HeaderDropdown} from '#/screens/PostThread/components/HeaderDropdown' import {ThreadComposePrompt} from '#/screens/PostThread/components/ThreadComposePrompt' import {ThreadError} from '#/screens/PostThread/components/ThreadError' import { ThreadItemAnchor, ThreadItemAnchorSkeleton, } from '#/screens/PostThread/components/ThreadItemAnchor' import {ThreadItemAnchorNoUnauthenticated} from '#/screens/PostThread/components/ThreadItemAnchorNoUnauthenticated' import { ThreadItemPost, ThreadItemPostSkeleton, } from '#/screens/PostThread/components/ThreadItemPost' import {ThreadItemPostNoUnauthenticated} from '#/screens/PostThread/components/ThreadItemPostNoUnauthenticated' import {ThreadItemPostTombstone} from '#/screens/PostThread/components/ThreadItemPostTombstone' import {ThreadItemReadMore} from '#/screens/PostThread/components/ThreadItemReadMore' import {ThreadItemReadMoreUp} from '#/screens/PostThread/components/ThreadItemReadMoreUp' import {ThreadItemReplyComposerSkeleton} from '#/screens/PostThread/components/ThreadItemReplyComposer' import {ThreadItemShowOtherReplies} from '#/screens/PostThread/components/ThreadItemShowOtherReplies' import { ThreadItemTreePost, ThreadItemTreePostSkeleton, } from '#/screens/PostThread/components/ThreadItemTreePost' import {atoms as a, native, platform, useBreakpoints, web} from '#/alf' import * as Layout from '#/components/Layout' import {ListFooter} from '#/components/Lists' import {LoggedOutCTA} from '#/components/LoggedOutCTA' const PARENT_CHUNK_SIZE = 5 const CHILDREN_CHUNK_SIZE = 50 export function PostThread({uri}: {uri: string}) { const {gtMobile} = useBreakpoints() const {hasSession} = useSession() const initialNumToRender = useInitialNumToRender() const {height: windowHeight} = useWindowDimensions() const anchorPostSource = useUnstablePostSource(uri) const feedFeedback = useFeedFeedback( anchorPostSource?.feedSourceInfo, hasSession, ) /* * One query to rule them all */ const thread = usePostThread({anchor: uri}) const {anchor, hasParents} = useMemo(() => { // eslint-disable-next-line @typescript-eslint/no-shadow let hasParents = false for (const item of thread.data.items) { if (item.type === 'threadPost' && item.depth === 0) { return {anchor: item, hasParents} } hasParents = true } return {hasParents} }, [thread.data.items]) const {openComposer} = useOpenComposer() const optimisticOnPostReply = useCallback( (payload: OnPostSuccessData) => { if (payload) { const {replyToUri, posts} = payload if (replyToUri && posts.length) { thread.actions.insertReplies(replyToUri, posts) } } }, [thread], ) const onReplyToAnchor = useCallback(() => { if (anchor?.type !== 'threadPost') { return } const post = anchor.value.post openComposer({ replyTo: { uri: anchor.uri, cid: post.cid, text: post.record.text, author: post.author, embed: post.embed, moderation: anchor.moderation, langs: post.record.langs, }, onPostSuccess: optimisticOnPostReply, }) if (anchorPostSource) { feedFeedback.sendInteraction({ item: post.uri, event: 'app.bsky.feed.defs#interactionReply', feedContext: anchorPostSource.post.feedContext, reqId: anchorPostSource.post.reqId, }) } }, [ anchor, openComposer, optimisticOnPostReply, anchorPostSource, feedFeedback, ]) const isRoot = !!anchor && anchor.value.post.record.reply === undefined const canReply = !anchor?.value.post?.viewer?.replyDisabled const [maxParentCount, setMaxParentCount] = useState(PARENT_CHUNK_SIZE) const [maxChildrenCount, setMaxChildrenCount] = useState(CHILDREN_CHUNK_SIZE) const totalParentCount = useRef(0) // recomputed below const totalChildrenCount = useRef(thread.data.items.length) // recomputed below const listRef = useRef(null) const anchorRef = useRef(null) const headerRef = useRef(null) /* * On a cold load, parents are not prepended until the anchor post has * rendered as the first item in the list. This gives us a consistent * reference point for which to pin the anchor post to the top of the screen. * * We simulate a cold load any time the user changes the view or sort params * so that this handling is consistent. * * On native, `maintainVisibleContentPosition={{minIndexForVisible: 0}}` gives * us this for free, since the anchor post is the first item in the list. * * On web, `onContentSizeChange` is used to get ahead of next paint and handle * this scrolling. */ const [deferParents, setDeferParents] = useState(true) /** * Used to flag whether we should scroll to the anchor post. On a cold load, * this is always true. And when a user changes thread parameters, we also * manually set this to true. */ const shouldHandleScroll = useRef(true) /** * Called any time the content size of the list changes, _just_ before paint. * * We want this to fire every time we change params (which will reset * `deferParents` via `onLayout` on the anchor post, due to the key change), * or click into a new post (which will result in a fresh `deferParents` * hook). * * The result being: any intentional change in view by the user will result * in the anchor being pinned as the first item. */ const onContentSizeChangeWebOnly = web(() => { const list = listRef.current const anchor = anchorRef.current as any as Element const header = headerRef.current as any as Element if (list && anchor && header && shouldHandleScroll.current) { const anchorOffsetTop = anchor.getBoundingClientRect().top const headerHeight = header.getBoundingClientRect().height /* * `deferParents` is `true` on a cold load, and always reset to * `true` when params change via `prepareForParamsUpdate`. * * On a cold load or a push to a new post, on the first pass of this * logic, the anchor post is the first item in the list. Therefore * `anchorOffsetTop - headerHeight` will be 0. * * When a user changes thread params, on the first pass of this logic, * the anchor post may not move (if there are no parents above it), or it * may have gone off the screen above, because of the sudden lack of * parents due to `deferParents === true`. This negative value (minus * `headerHeight`) will result in a _negative_ `offset` value, which will * scroll the anchor post _down_ to the top of the screen. * * However, `prepareForParamsUpdate` also resets scroll to `0`, so when a user * changes params, the anchor post's offset will actually be equivalent * to the `headerHeight` because of how the DOM is stacked on web. * Therefore, `anchorOffsetTop - headerHeight` will once again be 0, * which means the first pass in this case will result in no scroll. * * Then, once parents are prepended, this will fire again. Now, the * `anchorOffsetTop` will be positive, which minus the header height, * will give us a _positive_ offset, which will scroll the anchor post * back _up_ to the top of the screen. */ list.scrollToOffset({ offset: anchorOffsetTop - headerHeight, }) /* * After the second pass, `deferParents` will be `false`, and we need * to ensure this doesn't run again until scroll handling is requested * again via `shouldHandleScroll.current === true` and a params * change via `prepareForParamsUpdate`. * * The `isRoot` here is needed because if we're looking at the anchor * post, this handler will not fire after `deferParents` is set to * `false`, since there are no parents to render above it. In this case, * we want to make sure `shouldHandleScroll` is set to `false` so that * subsequent size changes unrelated to a params change (like pagination) * do not affect scroll. */ if (!deferParents || isRoot) shouldHandleScroll.current = false } }) /** * Ditto the above, but for native. */ const onContentSizeChangeNativeOnly = native(() => { const list = listRef.current const anchor = anchorRef.current if (list && anchor && shouldHandleScroll.current) { /* * `prepareForParamsUpdate` is called any time the user changes thread params like * `view` or `sort`, which sets `deferParents(true)` and resets the * scroll to the top of the list. However, there is a split second * where the top of the list is wherever the parents _just were_. So if * there were parents, the anchor is not at the top of the list just * prior to this handler being called. * * Once this handler is called, the anchor post is the first item in * the list (because of `deferParents` being `true`), and so we can * synchronously scroll the list back to the top of the list (which is * 0 on native, no need to handle `headerHeight`). */ list.scrollToOffset({ animated: false, offset: 0, }) /* * After this first pass, `deferParents` will be `false`, and those * will render in. However, the anchor post will retain its position * because of `maintainVisibleContentPosition` handling on native. So we * don't need to let this handler run again, like we do on web. */ shouldHandleScroll.current = false } }) /** * Called any time the user changes thread params, such as `view` or `sort`. * Prepares the UI for repositioning of the scroll so that the anchor post is * always at the top after a params change. * * No need to handle max parents here, deferParents will handle that and we * want it to re-render with the same items above the anchor. */ const prepareForParamsUpdate = useCallback(() => { /** * Truncate list so that anchor post is the first item in the list. Manual * scroll handling on web is predicated on this, and on native, this allows * `maintainVisibleContentPosition` to do its thing. */ setDeferParents(true) // reset this to a lower value for faster re-render setMaxChildrenCount(CHILDREN_CHUNK_SIZE) // set flag shouldHandleScroll.current = true }, [setDeferParents, setMaxChildrenCount]) const setSortWrapped = useCallback( (sort: string) => { prepareForParamsUpdate() thread.actions.setSort(sort) }, [thread, prepareForParamsUpdate], ) const setViewWrapped = useCallback( (view: ThreadViewOption) => { prepareForParamsUpdate() thread.actions.setView(view) }, [thread, prepareForParamsUpdate], ) const onStartReached = () => { if (thread.state.isFetching) return // can be true after `prepareForParamsUpdate` is called if (deferParents) return // prevent any state mutations if we know we're done if (maxParentCount >= totalParentCount.current) return setMaxParentCount(n => n + PARENT_CHUNK_SIZE) } const onEndReached = () => { if (thread.state.isFetching) return // can be true after `prepareForParamsUpdate` is called if (deferParents) return // prevent any state mutations if we know we're done if (maxChildrenCount >= totalChildrenCount.current) return setMaxChildrenCount(prev => prev + CHILDREN_CHUNK_SIZE) } const slices = useMemo(() => { const results: ThreadItem[] = [] if (!thread.data.items.length) return results /* * Pagination hack, tracks the # of items below the anchor post. */ let childrenCount = 0 for (let i = 0; i < thread.data.items.length; i++) { const item = thread.data.items[i] /* * Need to check `depth`, since not found or blocked posts are not * `threadPost`s, but still have `depth`. */ const hasDepth = 'depth' in item /* * Handle anchor post. */ if (hasDepth && item.depth === 0) { results.push(item) // Recalculate total parents current index. totalParentCount.current = i // Recalculate total children using (length - 1) - current index. totalChildrenCount.current = thread.data.items.length - 1 - i /* * Walk up the parents, limiting by `maxParentCount` */ if (!deferParents) { const start = i - 1 if (start >= 0) { const limit = Math.max(0, start - maxParentCount) for (let pi = start; pi >= limit; pi--) { results.unshift(thread.data.items[pi]) } } } } else { // ignore any parent items if (item.type === 'readMoreUp' || (hasDepth && item.depth < 0)) continue // can exit early if we've reached the max children count if (childrenCount > maxChildrenCount) break results.push(item) childrenCount++ } } return results }, [thread, deferParents, maxParentCount, maxChildrenCount]) const isTombstoneView = useMemo(() => { if (slices.length > 1) return false return slices.every( s => s.type === 'threadPostBlocked' || s.type === 'threadPostNotFound', ) }, [slices]) const renderItem = useCallback( ({item, index}: {item: ThreadItem; index: number}) => { if (item.type === 'threadPost') { if (item.depth < 0) { return ( ) } else if (item.depth === 0) { return ( /* * Keep this view wrapped so that the anchor post is always index 0 * in the list and `maintainVisibleContentPosition` can do its * thing. */ setDeferParents(false)} /> {/* Show CTA for logged-out visitors */} ) } else { if (thread.state.view === 'tree') { return ( 0, }} onPostSuccess={optimisticOnPostReply} /> ) } else { return ( 0, }} onPostSuccess={optimisticOnPostReply} /> ) } } } else if (item.type === 'threadPostNoUnauthenticated') { if (item.depth < 0) { return } else if (item.depth === 0) { return } } else if (item.type === 'readMore') { return ( ) } else if (item.type === 'readMoreUp') { return } else if (item.type === 'threadPostBlocked') { return } else if (item.type === 'threadPostNotFound') { return } else if (item.type === 'replyComposer') { return ( {gtMobile && ( )} ) } else if (item.type === 'showOtherReplies') { return } else if (item.type === 'skeleton') { if (item.item === 'anchor') { return } else if (item.item === 'reply') { if (thread.state.view === 'linear') { return } else { return } } else if (item.item === 'replyComposer') { return } } return null }, [ thread, optimisticOnPostReply, onReplyToAnchor, gtMobile, anchorPostSource, ], ) const defaultListFooterHeight = hasParents ? windowHeight - 200 : undefined return ( <> Post {thread.state.error ? ( ) : ( } initialNumToRender={initialNumToRender} /** * Default: 21 */ windowSize={7} /** * Default: 10 */ maxToRenderPerBatch={5} /** * Default: 50 */ updateCellsBatchingPeriod={100} /> )} {!gtMobile && canReply && hasSession && ( )} ) } function MobileComposePrompt({onPressReply}: {onPressReply: () => unknown}) { const {footerHeight} = useShellLayout() const animatedStyle = useAnimatedStyle(() => { return { bottom: footerHeight.get(), } }) return ( ) } const keyExtractor = (item: ThreadItem) => { return item.key }