Files
bsky-social-app/src/screens/PostThread/index.tsx
T
2025-08-26 16:54:26 +01:00

599 lines
22 KiB
TypeScript

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'
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?.feed, 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,
},
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}
/>
{/* Show CTA for logged-out visitors */}
<LoggedOutCTA style={a.px_lg} gateName="cta_above_post_replies" />
</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,
],
)
const defaultListFooterHeight = hasParents ? windowHeight - 200 : undefined
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={4}
onStartReachedThreshold={1}
/**
* NATIVE ONLY
* {@link https://reactnative.dev/docs/scrollview#maintainvisiblecontentposition}
*/
maintainVisibleContentPosition={{minIndexForVisible: 0}}
desktopFixedHeight
sideBorders={false}
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: defaultListFooterHeight,
default: deferParents
? windowHeight * 2
: defaultListFooterHeight,
})}
style={isTombstoneView ? {borderTopWidth: 0} : undefined}
/>
}
initialNumToRender={initialNumToRender}
/**
* Default: 21
*/
windowSize={7}
/**
* Default: 10
*/
maxToRenderPerBatch={5}
/**
* Default: 50
*/
updateCellsBatchingPeriod={100}
/>
)}
{!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
}