Files
bsky-social-app/src/screens/PostThread/index.tsx
T
2025-06-11 11:40:34 -05:00

520 lines
18 KiB
TypeScript

import {useCallback, useMemo, useRef, useState} from 'react'
import {useWindowDimensions, View} from 'react-native'
import Animated from 'react-native-reanimated'
import {useSafeAreaInsets} from 'react-native-safe-area-context'
import {Trans} from '@lingui/macro'
import {useInitialNumToRender} from '#/lib/hooks/useInitialNumToRender'
import {useMinimalShellFabTransform} from '#/lib/hooks/useMinimalShellTransform'
import {useOpenComposer} from '#/lib/hooks/useOpenComposer'
import {clamp} from '#/lib/numbers'
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 {PostThreadComposePrompt} from '#/view/com/post-thread/PostThreadComposePrompt'
import {List, type ListMethods} from '#/view/com/util/List'
import {HeaderDropdown} from '#/screens/PostThread/components/HeaderDropdown'
import {
ThreadAnchor,
ThreadAnchorSkeleton,
} from '#/screens/PostThread/components/ThreadAnchor'
import {ThreadError} from '#/screens/PostThread/components/ThreadError'
import {
ThreadItemPost,
ThreadItemPostSkeleton,
} from '#/screens/PostThread/components/ThreadItemPost'
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 Inner({uri}: {uri: string | undefined}) {
const {gtMobile} = useBreakpoints()
const {hasSession} = useSession()
const initialNumToRender = useInitialNumToRender() // TODO
const {height: windowHeight} = useWindowDimensions()
/*
* One query to rule them all
*/
const thread = usePostThread({anchor: uri})
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(() => {
const anchorPost = thread.data.items.find(
slice => slice.type === 'threadPost' && slice.ui.isAnchor,
)
if (anchorPost?.type !== 'threadPost') {
return
}
const post = anchorPost.value.post
openComposer({
replyTo: {
uri: anchorPost.uri,
cid: post.cid,
text: post.record.text,
author: post.author,
embed: post.embed,
moderation: anchorPost.moderation,
},
onPostSuccess: optimisticOnPostReply,
})
}, [thread, openComposer, optimisticOnPostReply])
const canReply = useMemo(() => {
if (thread.state.error) return false
for (const item of thread.data.items) {
if (item.type === 'threadPost' && item.depth === 0) {
return !item.value.post?.viewer?.replyDisabled
}
}
return false
}, [thread.state.error, thread.data.items])
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)
const headerHeight = useRef(0)
/*
* 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
if (list && anchor && shouldHandleScroll.current) {
const anchorOffsetTop = anchor.getBoundingClientRect().top
/*
* `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.current,
})
/*
* 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`.
*/
if (!deferParents) 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
}
})
const onStartReached = () => {
if (thread.state.isFetching) 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
// 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 (
<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)}>
<ThreadAnchor
item={item}
threadgateRecord={thread.data.threadgate?.record ?? undefined}
onPostSuccess={optimisticOnPostReply}
/>
</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 === '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 <ThreadAnchorSkeleton />
} 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],
)
const prepareForParamsUpdate = useCallback(() => {
setDeferParents(true)
setMaxParentCount(PARENT_CHUNK_SIZE)
setMaxChildrenCount(CHILDREN_CHUNK_SIZE)
listRef.current?.scrollToOffset({
animated: false,
offset: 0,
})
shouldHandleScroll.current = true
}, [setDeferParents, setMaxParentCount, 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],
)
return (
<>
<Layout.Header.Outer
headerRef={headerRef}
onLayout={e => {
headerHeight.current = e.nativeEvent.layout.height
}}>
<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
/*
* 200 is based on the minimum height of an anchor post. This is
* enough extra height for the `maintainVisPos` to work without
* causing weird jumps on web or glitches on native
*/
height={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 safeAreaInsets = useSafeAreaInsets()
const fabMinimalShellTransform = useMinimalShellFabTransform()
return (
<Animated.View
style={[
a.fixed,
fabMinimalShellTransform,
{
left: 0,
right: 0,
bottom: clamp(safeAreaInsets.bottom, 13, 60),
},
]}>
<PostThreadComposePrompt onPressCompose={onPressReply} />
</Animated.View>
)
}
const keyExtractor = (item: ThreadItem) => {
return item.key
}