diff options
Diffstat (limited to 'src/screens/PostThread/index.tsx')
-rw-r--r-- | src/screens/PostThread/index.tsx | 577 |
1 files changed, 577 insertions, 0 deletions
diff --git a/src/screens/PostThread/index.tsx b/src/screens/PostThread/index.tsx new file mode 100644 index 000000000..a4f94851a --- /dev/null +++ b/src/screens/PostThread/index.tsx @@ -0,0 +1,577 @@ +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 {PostThreadComposePrompt} from '#/view/com/post-thread/PostThreadComposePrompt' +import {List, type ListMethods} from '#/view/com/util/List' +import {HeaderDropdown} from '#/screens/PostThread/components/HeaderDropdown' +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' + +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() // TODO + const {height: windowHeight} = useWindowDimensions() + const anchorPostSource = useUnstablePostSource(uri) + const feedFeedback = useFeedFeedback(anchorPostSource?.feed, hasSession) + + /* + * One query to rule them all + */ + const thread = usePostThread({anchor: uri}) + const anchor = useMemo(() => { + for (const item of thread.data.items) { + if (item.type === 'threadPost' && item.depth === 0) { + return item + } + } + return + }, [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, + }, + 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<ListMethods>(null) + const anchorRef = useRef<View | null>(null) + const headerRef = useRef<View | null>(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 ( + <ThreadItemPost + item={item} + threadgateRecord={thread.data.threadgate?.record ?? undefined} + overrides={{ + topBorder: index === 0, + }} + onPostSuccess={optimisticOnPostReply} + /> + ) + } 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. + */ + <View collapsable={false}> + <View + /* + * IMPORTANT: this is a load-bearing key on all platforms. We + * want to force `onLayout` to fire any time the thread params + * change so that `deferParents` is always reset to `false` once + * the anchor post is rendered. + * + * If we ever add additional thread params to this screen, they + * will need to be added here. + */ + key={item.uri + thread.state.view + thread.state.sort} + ref={anchorRef} + onLayout={() => setDeferParents(false)} + /> + <ThreadItemAnchor + item={item} + threadgateRecord={thread.data.threadgate?.record ?? undefined} + onPostSuccess={optimisticOnPostReply} + postSource={anchorPostSource} + /> + </View> + ) + } else { + if (thread.state.view === 'tree') { + return ( + <ThreadItemTreePost + item={item} + threadgateRecord={thread.data.threadgate?.record ?? undefined} + overrides={{ + moderation: thread.state.otherItemsVisible && item.depth > 0, + }} + onPostSuccess={optimisticOnPostReply} + /> + ) + } else { + return ( + <ThreadItemPost + item={item} + threadgateRecord={thread.data.threadgate?.record ?? undefined} + overrides={{ + moderation: thread.state.otherItemsVisible && item.depth > 0, + }} + onPostSuccess={optimisticOnPostReply} + /> + ) + } + } + } else if (item.type === 'threadPostNoUnauthenticated') { + if (item.depth < 0) { + return <ThreadItemPostNoUnauthenticated item={item} /> + } else if (item.depth === 0) { + return <ThreadItemAnchorNoUnauthenticated /> + } + } else if (item.type === 'readMore') { + return ( + <ThreadItemReadMore + item={item} + view={thread.state.view === 'tree' ? 'tree' : 'linear'} + /> + ) + } else if (item.type === 'readMoreUp') { + return <ThreadItemReadMoreUp item={item} /> + } else if (item.type === 'threadPostBlocked') { + return <ThreadItemPostTombstone type="blocked" /> + } else if (item.type === 'threadPostNotFound') { + return <ThreadItemPostTombstone type="not-found" /> + } else if (item.type === 'replyComposer') { + return ( + <View> + {gtMobile && ( + <PostThreadComposePrompt onPressCompose={onReplyToAnchor} /> + )} + </View> + ) + } else if (item.type === 'showOtherReplies') { + return <ThreadItemShowOtherReplies onPress={item.onPress} /> + } else if (item.type === 'skeleton') { + if (item.item === 'anchor') { + return <ThreadItemAnchorSkeleton /> + } else if (item.item === 'reply') { + if (thread.state.view === 'linear') { + return <ThreadItemPostSkeleton index={index} /> + } else { + return <ThreadItemTreePostSkeleton index={index} /> + } + } else if (item.item === 'replyComposer') { + return <ThreadItemReplyComposerSkeleton /> + } + } + return null + }, + [ + thread, + optimisticOnPostReply, + onReplyToAnchor, + gtMobile, + anchorPostSource, + ], + ) + + return ( + <> + <Layout.Header.Outer headerRef={headerRef}> + <Layout.Header.BackButton /> + <Layout.Header.Content> + <Layout.Header.TitleText> + <Trans context="description">Post</Trans> + </Layout.Header.TitleText> + </Layout.Header.Content> + <Layout.Header.Slot> + <HeaderDropdown + sort={thread.state.sort} + setSort={setSortWrapped} + view={thread.state.view} + setView={setViewWrapped} + /> + </Layout.Header.Slot> + </Layout.Header.Outer> + + {thread.state.error ? ( + <ThreadError + error={thread.state.error} + onRetry={thread.actions.refetch} + /> + ) : ( + <List + ref={listRef} + data={slices} + renderItem={renderItem} + keyExtractor={keyExtractor} + onContentSizeChange={platform({ + web: onContentSizeChangeWebOnly, + default: onContentSizeChangeNativeOnly, + })} + onStartReached={onStartReached} + onEndReached={onEndReached} + onEndReachedThreshold={2} + onStartReachedThreshold={1} + /** + * NATIVE ONLY + * {@link https://reactnative.dev/docs/scrollview#maintainvisiblecontentposition} + */ + maintainVisibleContentPosition={{minIndexForVisible: 0}} + desktopFixedHeight + ListFooterComponent={ + <ListFooter + /* + * On native, if `deferParents` is true, we need some extra buffer to + * account for the `on*ReachedThreshold` values. + * + * Otherwise, and on web, this value needs to be the height of + * the viewport _minus_ a sensible min-post height e.g. 200, so + * that there's enough scroll remaining to get the anchor post + * back to the top of the screen when handling scroll. + */ + height={platform({ + web: windowHeight - 200, + default: deferParents ? windowHeight * 2 : windowHeight - 200, + })} + style={isTombstoneView ? {borderTopWidth: 0} : undefined} + /> + } + initialNumToRender={initialNumToRender} + windowSize={11} + sideBorders={false} + /> + )} + + {!gtMobile && canReply && hasSession && ( + <MobileComposePrompt onPressReply={onReplyToAnchor} /> + )} + </> + ) +} + +function MobileComposePrompt({onPressReply}: {onPressReply: () => unknown}) { + const {footerHeight} = useShellLayout() + + const animatedStyle = useAnimatedStyle(() => { + return { + bottom: footerHeight.get(), + } + }) + + return ( + <Animated.View style={[a.fixed, a.left_0, a.right_0, animatedStyle]}> + <PostThreadComposePrompt onPressCompose={onPressReply} /> + </Animated.View> + ) +} + +const keyExtractor = (item: ThreadItem) => { + return item.key +} |