import React, {useEffect, useRef} from 'react'
import {
ActivityIndicator,
Pressable,
RefreshControl,
StyleSheet,
TouchableOpacity,
View,
} from 'react-native'
import {AppBskyFeedDefs} from '@atproto/api'
import {CenteredView, FlatList} from '../util/Views'
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,
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,
usePreferencesQuery,
} from '#/state/queries/preferences'
import {useSession} from '#/state/session'
import {isNative} from '#/platform/detection'
import {logger} from '#/logger'
const MAINTAIN_VISIBLE_CONTENT_POSITION = {minIndexForVisible: 2}
const TOP_COMPONENT = {_reactKey: '__top_component__'}
const PARENT_SPINNER = {_reactKey: '__parent_spinner__'}
const REPLY_PROMPT = {_reactKey: '__reply__'}
const DELETED = {_reactKey: '__deleted__'}
const BLOCKED = {_reactKey: '__blocked__'}
const CHILD_SPINNER = {_reactKey: '__child_spinner__'}
const LOAD_MORE = {_reactKey: '__load_more__'}
const BOTTOM_COMPONENT = {_reactKey: '__bottom_component__'}
type YieldedItem =
| ThreadPost
| typeof TOP_COMPONENT
| typeof PARENT_SPINNER
| typeof REPLY_PROMPT
| typeof DELETED
| typeof BLOCKED
| typeof PARENT_SPINNER
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
useSetTitle(
rootPost &&
`${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 {isTablet, isDesktop} = useWebMediaQueries()
const ref = useRef(null)
const highlightedPostRef = useRef(null)
const needsScrollAdjustment = useRef(
!isNative || // web always uses scroll adjustment
(thread.type === 'post' && !thread.ctx.isParentLoading), // native only does it when not loading from placeholder
)
const [maxVisible, setMaxVisible] = React.useState(100)
const [isPTRing, setIsPTRing] = React.useState(false)
const treeView = React.useMemo(
() => !!threadViewPrefs.lab_treeViewEnabled && hasBranchingReplies(thread),
[threadViewPrefs, thread],
)
// construct content
const posts = React.useMemo(() => {
let arr = [TOP_COMPONENT].concat(
Array.from(flattenThreadSkeleton(sortThread(thread, threadViewPrefs))),
)
if (arr.length > maxVisible) {
arr = arr.slice(0, maxVisible).concat([LOAD_MORE])
}
if (arr.indexOf(CHILD_SPINNER) === -1) {
arr.push(BOTTOM_COMPONENT)
}
return arr
}, [thread, maxVisible, threadViewPrefs])
/**
* NOTE
* Scroll positioning
*
* This callback is run if needsScrollAdjustment.current == true, which is...
* - On web: always
* - On native: when the placeholder cache is not being used
*
* It then only runs when viewing a reply, and the goal is to scroll the
* reply into view.
*
* On native, if the placeholder cache is being used then maintainVisibleContentPosition
* is a more effective solution, so we use that. Otherwise, typically we're loading from
* the react-query cache, so we just need to immediately scroll down to the post.
*
* On desktop, maintainVisibleContentPosition isn't supported so we just always use
* this technique.
*
* -prf
*/
const onContentSizeChange = React.useCallback(() => {
// only run once
if (!needsScrollAdjustment.current) {
return
}
// wait for loading to finish
if (thread.type === 'post' && !!thread.parent) {
highlightedPostRef.current?.measure(
(_x, _y, _width, _height, _pageX, pageY) => {
ref.current?.scrollToOffset({
animated: false,
offset: pageY - (isDesktop ? 0 : 50),
})
},
)
needsScrollAdjustment.current = false
}
}, [thread, isDesktop])
const onPTR = React.useCallback(async () => {
setIsPTRing(true)
try {
await onRefresh()
} catch (err) {
logger.error('Failed to refresh posts thread', {error: err})
}
setIsPTRing(false)
}, [setIsPTRing, onRefresh])
const renderItem = React.useCallback(
({item, index}: {item: YieldedItem; index: number}) => {
if (item === TOP_COMPONENT) {
return isTablet ? : null
} else if (item === PARENT_SPINNER) {
return (
)
} else if (item === REPLY_PROMPT && hasSession) {
return (
{isDesktop && }
)
} else if (item === DELETED) {
return (
Deleted post.
)
} else if (item === BLOCKED) {
return (
Blocked post.
)
} else if (item === LOAD_MORE) {
return (
setMaxVisible(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
return (
)
}
return null
},
[
hasSession,
isTablet,
isDesktop,
onPressReply,
pal.border,
pal.viewLight,
pal.textLight,
pal.view,
pal.text,
pal.colors.border,
posts,
onRefresh,
treeView,
_,
],
)
return (
item._reactKey}
renderItem={renderItem}
refreshControl={
}
onContentSizeChange={onContentSizeChange}
style={s.hContentRegion}
// @ts-ignore our .web version only -prf
desktopFixedHeight
/>
)
}
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* flattenThreadSkeleton(
node: ThreadNode,
): Generator {
if (node.type === 'post') {
if (node.parent) {
yield* flattenThreadSkeleton(node.parent)
} else if (node.ctx.isParentLoading) {
yield PARENT_SPINNER
}
yield node
if (node.ctx.isHighlightedPost && !node.post.viewer?.replyDisabled) {
yield REPLY_PROMPT
}
if (node.replies?.length) {
for (const reply of node.replies) {
yield* flattenThreadSkeleton(reply)
}
} else if (node.ctx.isChildLoading) {
yield CHILD_SPINNER
}
} else if (node.type === 'not-found') {
yield DELETED
} else if (node.type === 'blocked') {
yield BLOCKED
}
}
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,
},
parentSpinner: {
paddingVertical: 10,
},
childSpinner: {
paddingBottom: 200,
},
})