import React, {useEffect, useRef} from 'react'
import {
ActivityIndicator,
Pressable,
StyleSheet,
TouchableOpacity,
View,
} from 'react-native'
import {AppBskyFeedDefs} from '@atproto/api'
import {CenteredView} from '../util/Views'
import {LoadingScreen} from '../util/LoadingScreen'
import {List, ListMethods} from '../util/List'
import {
FontAwesomeIcon,
FontAwesomeIconStyle,
} from '@fortawesome/react-native-fontawesome'
import {PostThreadItem} from './PostThreadItem'
import {ComposePrompt} from '../composer/Prompt'
import {ViewHeader} from '../util/ViewHeader'
import {ErrorMessage} from '../util/error/ErrorMessage'
import {Text} from '../util/text/Text'
import {s} from 'lib/styles'
import {usePalette} from 'lib/hooks/usePalette'
import {useSetTitle} from 'lib/hooks/useSetTitle'
import {
ThreadNode,
ThreadPost,
ThreadNotFound,
ThreadBlocked,
usePostThreadQuery,
sortThread,
} from '#/state/queries/post-thread'
import {useNavigation} from '@react-navigation/native'
import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries'
import {NavigationProp} from 'lib/routes/types'
import {sanitizeDisplayName} from 'lib/strings/display-names'
import {cleanError} from '#/lib/strings/errors'
import {Trans, msg} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {
UsePreferencesQueryResponse,
useModerationOpts,
usePreferencesQuery,
} from '#/state/queries/preferences'
import {useSession} from '#/state/session'
import {isAndroid, isNative, isWeb} from '#/platform/detection'
import {moderatePost_wrapped as moderatePost} from '#/lib/moderatePost_wrapped'
// FlatList maintainVisibleContentPosition breaks if too many items
// are prepended. This seems to be an optimal number based on *shrug*.
const PARENTS_CHUNK_SIZE = 15
const MAINTAIN_VISIBLE_CONTENT_POSITION = {
// We don't insert any elements before the root row while loading.
// So the row we want to use as the scroll anchor is the first row.
minIndexForVisible: 0,
}
const TOP_COMPONENT = {_reactKey: '__top_component__'}
const REPLY_PROMPT = {_reactKey: '__reply__'}
const CHILD_SPINNER = {_reactKey: '__child_spinner__'}
const LOAD_MORE = {_reactKey: '__load_more__'}
const BOTTOM_COMPONENT = {_reactKey: '__bottom_component__'}
type YieldedItem = ThreadPost | ThreadBlocked | ThreadNotFound
type RowItem =
| YieldedItem
// TODO: TS doesn't actually enforce it's one of these, it only enforces matching shape.
| typeof TOP_COMPONENT
| typeof REPLY_PROMPT
| typeof CHILD_SPINNER
| typeof LOAD_MORE
| typeof BOTTOM_COMPONENT
type ThreadSkeletonParts = {
parents: YieldedItem[]
highlightedPost: ThreadNode
replies: YieldedItem[]
}
export function PostThread({
uri,
onCanReply,
onPressReply,
}: {
uri: string | undefined
onCanReply: (canReply: boolean) => void
onPressReply: () => void
}) {
const {
isLoading,
isError,
error,
refetch,
data: thread,
} = usePostThreadQuery(uri)
const {data: preferences} = usePreferencesQuery()
const rootPost = thread?.type === 'post' ? thread.post : undefined
const rootPostRecord = thread?.type === 'post' ? thread.record : undefined
const moderationOpts = useModerationOpts()
const isNoPwi = React.useMemo(() => {
const mod =
rootPost && moderationOpts
? moderatePost(rootPost, moderationOpts)
: undefined
return !!mod
?.ui('contentList')
.blurs.find(
cause =>
cause.type === 'label' && cause.labelDef.id === '!no-unauthenticated',
)
}, [rootPost, moderationOpts])
useSetTitle(
rootPost && !isNoPwi
? `${sanitizeDisplayName(
rootPost.author.displayName || `@${rootPost.author.handle}`,
)}: "${rootPostRecord!.text}"`
: '',
)
useEffect(() => {
if (rootPost) {
onCanReply(!rootPost.viewer?.replyDisabled)
}
}, [rootPost, onCanReply])
if (isError || AppBskyFeedDefs.isNotFoundPost(thread)) {
return (
)
}
if (AppBskyFeedDefs.isBlockedPost(thread)) {
return
}
if (!thread || isLoading || !preferences) {
return
}
return (
)
}
function PostThreadLoaded({
thread,
threadViewPrefs,
onRefresh,
onPressReply,
}: {
thread: ThreadNode
threadViewPrefs: UsePreferencesQueryResponse['threadViewPrefs']
onRefresh: () => void
onPressReply: () => void
}) {
const {hasSession} = useSession()
const {_} = useLingui()
const pal = usePalette('default')
const {isMobile, isTabletOrMobile} = useWebMediaQueries()
const ref = useRef(null)
const highlightedPostRef = useRef(null)
const [maxParents, setMaxParents] = React.useState(
isWeb ? Infinity : PARENTS_CHUNK_SIZE,
)
const [maxReplies, setMaxReplies] = React.useState(100)
const treeView = React.useMemo(
() => !!threadViewPrefs.lab_treeViewEnabled && hasBranchingReplies(thread),
[threadViewPrefs, thread],
)
// On native, this is going to start out `true`. We'll toggle it to `false` after the initial render if flushed.
// This ensures that the first render contains no parents--even if they are already available in the cache.
// We need to delay showing them so that we can use maintainVisibleContentPosition to keep the main post on screen.
// On the web this is not necessary because we can synchronously adjust the scroll in onContentSizeChange instead.
const [deferParents, setDeferParents] = React.useState(isNative)
const skeleton = React.useMemo(
() =>
createThreadSkeleton(
sortThread(thread, threadViewPrefs),
hasSession,
treeView,
),
[thread, threadViewPrefs, hasSession, treeView],
)
// construct content
const posts = React.useMemo(() => {
const {parents, highlightedPost, replies} = skeleton
let arr: RowItem[] = []
if (highlightedPost.type === 'post') {
const isRoot =
!highlightedPost.parent && !highlightedPost.ctx.isParentLoading
if (isRoot) {
// No parents to load.
arr.push(TOP_COMPONENT)
} else {
if (highlightedPost.ctx.isParentLoading || deferParents) {
// We're loading parents of the highlighted post.
// In this case, we don't render anything above the post.
// If you add something here, you'll need to update both
// maintainVisibleContentPosition and onContentSizeChange
// to "hold onto" the correct row instead of the first one.
} else {
// Everything is loaded
let startIndex = Math.max(0, parents.length - maxParents)
if (startIndex === 0) {
arr.push(TOP_COMPONENT)
} else {
// When progressively revealing parents, rendering a placeholder
// here will cause scrolling jumps. Don't add it unless you test it.
// QT'ing this thread is a great way to test all the scrolling hacks:
// https://bsky.app/profile/www.mozzius.dev/post/3kjqhblh6qk2o
}
for (let i = startIndex; i < parents.length; i++) {
arr.push(parents[i])
}
}
}
arr.push(highlightedPost)
if (!highlightedPost.post.viewer?.replyDisabled) {
arr.push(REPLY_PROMPT)
}
if (highlightedPost.ctx.isChildLoading) {
arr.push(CHILD_SPINNER)
} else {
for (let i = 0; i < replies.length; i++) {
arr.push(replies[i])
if (i === maxReplies) {
arr.push(LOAD_MORE)
break
}
}
arr.push(BOTTOM_COMPONENT)
}
}
return arr
}, [skeleton, deferParents, maxParents, maxReplies])
// This is only used on the web to keep the post in view when its parents load.
// On native, we rely on `maintainVisibleContentPosition` instead.
const didAdjustScrollWeb = useRef(false)
const onContentSizeChangeWeb = React.useCallback(() => {
// only run once
if (didAdjustScrollWeb.current) {
return
}
// wait for loading to finish
if (thread.type === 'post' && !!thread.parent) {
function onMeasure(pageY: number) {
ref.current?.scrollToOffset({
animated: false,
offset: pageY,
})
}
// Measure synchronously to avoid a layout jump.
const domNode = highlightedPostRef.current
if (domNode) {
const pageY = (domNode as any as Element).getBoundingClientRect().top
onMeasure(pageY)
}
didAdjustScrollWeb.current = true
}
}, [thread])
// On native, we reveal parents in chunks. Although they're all already
// loaded and FlatList already has its own virtualization, unfortunately FlatList
// has a bug that causes the content to jump around if too many items are getting
// prepended at once. It also jumps around if items get prepended during scroll.
// To work around this, we prepend rows after scroll bumps against the top and rests.
const needsBumpMaxParents = React.useRef(false)
const onStartReached = React.useCallback(() => {
if (maxParents < skeleton.parents.length) {
needsBumpMaxParents.current = true
}
}, [maxParents, skeleton.parents.length])
const bumpMaxParentsIfNeeded = React.useCallback(() => {
if (!isNative) {
return
}
if (needsBumpMaxParents.current) {
needsBumpMaxParents.current = false
setMaxParents(n => n + PARENTS_CHUNK_SIZE)
}
}, [])
const onMomentumScrollEnd = bumpMaxParentsIfNeeded
const onScrollToTop = bumpMaxParentsIfNeeded
const renderItem = React.useCallback(
({item, index}: {item: RowItem; index: number}) => {
if (item === TOP_COMPONENT) {
return isTabletOrMobile ? (
) : null
} else if (item === REPLY_PROMPT && hasSession) {
return (
{!isMobile && }
)
} else if (isThreadNotFound(item)) {
return (
Deleted post.
)
} else if (isThreadBlocked(item)) {
return (
Blocked post.
)
} else if (item === LOAD_MORE) {
return (
setMaxReplies(n => n + 50)}
style={[pal.border, pal.view, styles.itemContainer]}
accessibilityLabel={_(msg`Load more posts`)}
accessibilityHint="">
Load more posts
)
} else if (item === BOTTOM_COMPONENT) {
// HACK
// due to some complexities with how flatlist works, this is the easiest way
// I could find to get a border positioned directly under the last item
// -prf
return (
)
} else if (item === CHILD_SPINNER) {
return (
)
} else if (isThreadPost(item)) {
const prev = isThreadPost(posts[index - 1])
? (posts[index - 1] as ThreadPost)
: undefined
const next = isThreadPost(posts[index - 1])
? (posts[index - 1] as ThreadPost)
: undefined
const hasUnrevealedParents =
index === 0 && maxParents < skeleton.parents.length
return (
setDeferParents(false) : undefined}>
)
}
return null
},
[
hasSession,
isTabletOrMobile,
isMobile,
onPressReply,
pal.border,
pal.viewLight,
pal.textLight,
pal.view,
pal.text,
pal.colors.border,
posts,
onRefresh,
deferParents,
treeView,
skeleton.parents.length,
maxParents,
_,
],
)
return (
item._reactKey}
renderItem={renderItem}
onContentSizeChange={isNative ? undefined : onContentSizeChangeWeb}
onStartReached={onStartReached}
onMomentumScrollEnd={onMomentumScrollEnd}
onScrollToTop={onScrollToTop}
maintainVisibleContentPosition={
isNative ? MAINTAIN_VISIBLE_CONTENT_POSITION : undefined
}
style={s.hContentRegion}
// @ts-ignore our .web version only -prf
desktopFixedHeight
removeClippedSubviews={isAndroid ? false : undefined}
windowSize={11}
/>
)
}
function PostThreadBlocked() {
const {_} = useLingui()
const pal = usePalette('default')
const navigation = useNavigation()
const onPressBack = React.useCallback(() => {
if (navigation.canGoBack()) {
navigation.goBack()
} else {
navigation.navigate('Home')
}
}, [navigation])
return (
Post hidden
You have blocked the author or you have been blocked by the author.
Back
)
}
function PostThreadError({
onRefresh,
notFound,
error,
}: {
onRefresh: () => void
notFound: boolean
error: Error | null
}) {
const {_} = useLingui()
const pal = usePalette('default')
const navigation = useNavigation()
const onPressBack = React.useCallback(() => {
if (navigation.canGoBack()) {
navigation.goBack()
} else {
navigation.navigate('Home')
}
}, [navigation])
if (notFound) {
return (
Post not found
The post may have been deleted.
Back
)
}
return (
)
}
function isThreadPost(v: unknown): v is ThreadPost {
return !!v && typeof v === 'object' && 'type' in v && v.type === 'post'
}
function isThreadNotFound(v: unknown): v is ThreadNotFound {
return !!v && typeof v === 'object' && 'type' in v && v.type === 'not-found'
}
function isThreadBlocked(v: unknown): v is ThreadBlocked {
return !!v && typeof v === 'object' && 'type' in v && v.type === 'blocked'
}
function createThreadSkeleton(
node: ThreadNode,
hasSession: boolean,
treeView: boolean,
): ThreadSkeletonParts {
return {
parents: Array.from(flattenThreadParents(node, hasSession)),
highlightedPost: node,
replies: Array.from(flattenThreadReplies(node, hasSession, treeView)),
}
}
function* flattenThreadParents(
node: ThreadNode,
hasSession: boolean,
): Generator {
if (node.type === 'post') {
if (node.parent) {
yield* flattenThreadParents(node.parent, hasSession)
}
if (!node.ctx.isHighlightedPost) {
yield node
}
} else if (node.type === 'not-found') {
yield node
} else if (node.type === 'blocked') {
yield node
}
}
function* flattenThreadReplies(
node: ThreadNode,
hasSession: boolean,
treeView: boolean,
): Generator {
if (node.type === 'post') {
if (!hasSession && hasPwiOptOut(node)) {
return
}
if (!node.ctx.isHighlightedPost) {
yield node
}
if (node.replies?.length) {
for (const reply of node.replies) {
yield* flattenThreadReplies(reply, hasSession, treeView)
if (!treeView && !node.ctx.isHighlightedPost) {
break
}
}
}
} else if (node.type === 'not-found') {
yield node
} else if (node.type === 'blocked') {
yield node
}
}
function hasPwiOptOut(node: ThreadPost) {
return !!node.post.author.labels?.find(l => l.val === '!no-unauthenticated')
}
function hasBranchingReplies(node: ThreadNode) {
if (node.type !== 'post') {
return false
}
if (!node.replies) {
return false
}
if (node.replies.length === 1) {
return hasBranchingReplies(node.replies[0])
}
return true
}
const styles = StyleSheet.create({
notFoundContainer: {
margin: 10,
paddingHorizontal: 18,
paddingVertical: 14,
borderRadius: 6,
},
itemContainer: {
borderTopWidth: 1,
paddingHorizontal: 18,
paddingVertical: 18,
},
childSpinner: {
borderTopWidth: 1,
paddingTop: 40,
paddingBottom: 200,
},
})