import React, {useRef} from 'react' import {runInAction} from 'mobx' import {observer} from 'mobx-react-lite' import { ActivityIndicator, Pressable, RefreshControl, StyleSheet, TouchableOpacity, View, } from 'react-native' import {AppBskyFeedDefs} from '@atproto/api' import {CenteredView, FlatList} from '../util/Views' import {PostThreadModel} from 'state/models/content/post-thread' import {PostThreadItemModel} from 'state/models/content/post-thread-item' 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 {isNative} from 'platform/detection' import {usePalette} from 'lib/hooks/usePalette' import {useSetTitle} from 'lib/hooks/useSetTitle' 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 {logger} from '#/logger' const MAINTAIN_VISIBLE_CONTENT_POSITION = {minIndexForVisible: 2} const TOP_COMPONENT = { _reactKey: '__top_component__', _isHighlightedPost: false, } const PARENT_SPINNER = { _reactKey: '__parent_spinner__', _isHighlightedPost: false, } const REPLY_PROMPT = {_reactKey: '__reply__', _isHighlightedPost: false} const DELETED = {_reactKey: '__deleted__', _isHighlightedPost: false} const BLOCKED = {_reactKey: '__blocked__', _isHighlightedPost: false} const CHILD_SPINNER = { _reactKey: '__child_spinner__', _isHighlightedPost: false, } const LOAD_MORE = { _reactKey: '__load_more__', _isHighlightedPost: false, } const BOTTOM_COMPONENT = { _reactKey: '__bottom_component__', _isHighlightedPost: false, _showBorder: true, } type YieldedItem = | PostThreadItemModel | typeof TOP_COMPONENT | typeof PARENT_SPINNER | typeof REPLY_PROMPT | typeof DELETED | typeof BLOCKED | typeof PARENT_SPINNER export const PostThread = observer(function PostThread({ uri, view, onPressReply, treeView, }: { uri: string view: PostThreadModel onPressReply: () => void treeView: boolean }) { const pal = usePalette('default') const {isTablet, isDesktop} = useWebMediaQueries() const ref = useRef(null) const hasScrolledIntoView = useRef(false) const [isRefreshing, setIsRefreshing] = React.useState(false) const [maxVisible, setMaxVisible] = React.useState(100) const navigation = useNavigation() const posts = React.useMemo(() => { if (view.thread) { let arr = [TOP_COMPONENT].concat(Array.from(flattenThread(view.thread))) if (arr.length > maxVisible) { arr = arr.slice(0, maxVisible).concat([LOAD_MORE]) } if (view.isLoadingFromCache) { if (view.thread?.postRecord?.reply) { arr.unshift(PARENT_SPINNER) } arr.push(CHILD_SPINNER) } else { arr.push(BOTTOM_COMPONENT) } return arr } return [] }, [view.isLoadingFromCache, view.thread, maxVisible]) const highlightedPostIndex = posts.findIndex(post => post._isHighlightedPost) useSetTitle( view.thread?.postRecord && `${sanitizeDisplayName( view.thread.post.author.displayName || `@${view.thread.post.author.handle}`, )}: "${view.thread?.postRecord?.text}"`, ) // events // = const onRefresh = React.useCallback(async () => { setIsRefreshing(true) try { view?.refresh() } catch (err) { logger.error('Failed to refresh posts thread', {error: err}) } setIsRefreshing(false) }, [view, setIsRefreshing]) const onContentSizeChange = React.useCallback(() => { // only run once if (hasScrolledIntoView.current) { return } // wait for loading to finish if ( !view.hasContent || (view.isFromCache && view.isLoadingFromCache) || view.isLoading ) { return } if (highlightedPostIndex !== -1) { ref.current?.scrollToIndex({ index: highlightedPostIndex, animated: false, viewPosition: 0, }) hasScrolledIntoView.current = true } }, [ highlightedPostIndex, view.hasContent, view.isFromCache, view.isLoadingFromCache, view.isLoading, ]) const onScrollToIndexFailed = React.useCallback( (info: { index: number highestMeasuredFrameIndex: number averageItemLength: number }) => { ref.current?.scrollToOffset({ animated: false, offset: info.averageItemLength * info.index, }) }, [ref], ) const onPressBack = React.useCallback(() => { if (navigation.canGoBack()) { navigation.goBack() } else { navigation.navigate('Home') } }, [navigation]) 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) { 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="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 (item instanceof PostThreadItemModel) { const prev = ( index - 1 >= 0 ? posts[index - 1] : undefined ) as PostThreadItemModel return ( ) } return <> }, [ isTablet, isDesktop, onPressReply, pal.border, pal.viewLight, pal.textLight, pal.view, pal.text, pal.colors.border, posts, onRefresh, treeView, ], ) // loading // = if ( !view.hasLoaded || (view.isLoading && !view.isRefreshing) || view.params.uri !== uri ) { return ( ) } // error // = if (view.hasError) { if (view.notFound) { return ( Post not found The post may have been deleted. Back ) } return ( ) } if (view.isBlocked) { return ( Post hidden You have blocked the author or you have been blocked by the author. Back ) } // loaded // = return ( item._reactKey} renderItem={renderItem} refreshControl={ } onContentSizeChange={ isNative && view.isFromCache ? undefined : onContentSizeChange } onScrollToIndexFailed={onScrollToIndexFailed} style={s.hContentRegion} // @ts-ignore our .web version only -prf desktopFixedHeight /> ) }) function* flattenThread( post: PostThreadItemModel, isAscending = false, ): Generator { if (post.parent) { if (AppBskyFeedDefs.isNotFoundPost(post.parent)) { yield DELETED } else if (AppBskyFeedDefs.isBlockedPost(post.parent)) { yield BLOCKED } else { yield* flattenThread(post.parent as PostThreadItemModel, true) } } yield post if (post._isHighlightedPost) { yield REPLY_PROMPT } if (post.replies?.length) { for (const reply of post.replies) { if (AppBskyFeedDefs.isNotFoundPost(reply)) { yield DELETED } else { yield* flattenThread(reply as PostThreadItemModel) } } } else if (!isAscending && !post.parent && post.post.replyCount) { runInAction(() => { post._hasMore = 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, }, })