diff options
Diffstat (limited to 'src/view/com/post-thread/PostThread.tsx')
-rw-r--r-- | src/view/com/post-thread/PostThread.tsx | 545 |
1 files changed, 298 insertions, 247 deletions
diff --git a/src/view/com/post-thread/PostThread.tsx b/src/view/com/post-thread/PostThread.tsx index 4eb47b0a3..edf02e9c5 100644 --- a/src/view/com/post-thread/PostThread.tsx +++ b/src/view/com/post-thread/PostThread.tsx @@ -1,6 +1,4 @@ import React, {useRef} from 'react' -import {runInAction} from 'mobx' -import {observer} from 'mobx-react-lite' import { ActivityIndicator, Pressable, @@ -11,8 +9,6 @@ import { } 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, @@ -23,43 +19,42 @@ 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 { + 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__', - _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, -} +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 = - | PostThreadItemModel + | ThreadPost | typeof TOP_COMPONENT | typeof PARENT_SPINNER | typeof REPLY_PROMPT @@ -67,127 +62,161 @@ type YieldedItem = | typeof BLOCKED | typeof PARENT_SPINNER -export const PostThread = observer(function PostThread({ +export function PostThread({ uri, - view, onPressReply, - treeView, }: { - uri: string - view: PostThreadModel + uri: string | undefined + 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}"`, + ) + + if (isError || AppBskyFeedDefs.isNotFoundPost(thread)) { + return ( + <PostThreadError + error={error} + notFound={AppBskyFeedDefs.isNotFoundPost(thread)} + onRefresh={refetch} + /> + ) + } + if (AppBskyFeedDefs.isBlockedPost(thread)) { + return <PostThreadBlocked /> + } + if (!thread || isLoading || !preferences) { + return ( + <CenteredView> + <View style={s.p20}> + <ActivityIndicator size="large" /> + </View> + </CenteredView> + ) + } + return ( + <PostThreadLoaded + thread={thread} + threadViewPrefs={preferences.threadViewPrefs} + onRefresh={refetch} + onPressReply={onPressReply} + /> + ) +} + +function PostThreadLoaded({ + thread, + threadViewPrefs, + onRefresh, + onPressReply, +}: { + thread: ThreadNode + threadViewPrefs: UsePreferencesQueryResponse['threadViewPrefs'] + onRefresh: () => void onPressReply: () => void - treeView: boolean }) { + const {hasSession} = useSession() + const {_} = useLingui() const pal = usePalette('default') const {isTablet, isDesktop} = useWebMediaQueries() const ref = useRef<FlatList>(null) - const hasScrolledIntoView = useRef<boolean>(false) - const [isRefreshing, setIsRefreshing] = React.useState(false) + const highlightedPostRef = useRef<View | null>(null) + const needsScrollAdjustment = useRef<boolean>( + !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 navigation = useNavigation<NavigationProp>() + const [isPTRing, setIsPTRing] = React.useState(false) + + // construct content 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 + let arr = [TOP_COMPONENT].concat( + Array.from(flattenThreadSkeleton(sortThread(thread, threadViewPrefs))), + ) + if (arr.length > maxVisible) { + arr = arr.slice(0, maxVisible).concat([LOAD_MORE]) } - 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}) + if (arr.indexOf(CHILD_SPINNER) === -1) { + arr.push(BOTTOM_COMPONENT) } - setIsRefreshing(false) - }, [view, setIsRefreshing]) + 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 (hasScrolledIntoView.current) { + if (!needsScrollAdjustment.current) { return } // wait for loading to finish - if ( - !view.hasContent || - (view.isFromCache && view.isLoadingFromCache) || - view.isLoading - ) { - return + 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]) - 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') + const onPTR = React.useCallback(async () => { + setIsPTRing(true) + try { + await onRefresh() + } catch (err) { + logger.error('Failed to refresh posts thread', {error: err}) } - }, [navigation]) + setIsPTRing(false) + }, [setIsPTRing, onRefresh]) const renderItem = React.useCallback( ({item, index}: {item: YieldedItem; index: number}) => { if (item === TOP_COMPONENT) { - return isTablet ? <ViewHeader title="Post" /> : null + return isTablet ? <ViewHeader title={_(msg`Post`)} /> : null } else if (item === PARENT_SPINNER) { return ( <View style={styles.parentSpinner}> <ActivityIndicator /> </View> ) - } else if (item === REPLY_PROMPT) { + } else if (item === REPLY_PROMPT && hasSession) { return ( <View> {isDesktop && <ComposePrompt onPressCompose={onPressReply} />} @@ -197,7 +226,7 @@ export const PostThread = observer(function PostThread({ return ( <View style={[pal.border, pal.viewLight, styles.itemContainer]}> <Text type="lg-bold" style={pal.textLight}> - Deleted post. + <Trans>Deleted post.</Trans> </Text> </View> ) @@ -205,7 +234,7 @@ export const PostThread = observer(function PostThread({ return ( <View style={[pal.border, pal.viewLight, styles.itemContainer]}> <Text type="lg-bold" style={pal.textLight}> - Blocked post. + <Trans>Blocked post.</Trans> </Text> </View> ) @@ -214,7 +243,7 @@ export const PostThread = observer(function PostThread({ <Pressable onPress={() => setMaxVisible(n => n + 50)} style={[pal.border, pal.view, styles.itemContainer]} - accessibilityLabel="Load more posts" + accessibilityLabel={_(msg`Load more posts`)} accessibilityHint=""> <View style={[ @@ -222,7 +251,7 @@ export const PostThread = observer(function PostThread({ {paddingHorizontal: 18, paddingVertical: 14, borderRadius: 6}, ]}> <Text type="lg-medium" style={pal.text}> - Load more posts + <Trans>Load more posts</Trans> </Text> </View> </Pressable> @@ -247,22 +276,32 @@ export const PostThread = observer(function PostThread({ <ActivityIndicator /> </View> ) - } else if (item instanceof PostThreadItemModel) { - const prev = ( - index - 1 >= 0 ? posts[index - 1] : undefined - ) as PostThreadItemModel + } else if (isThreadPost(item)) { + const prev = isThreadPost(posts[index - 1]) + ? (posts[index - 1] as ThreadPost) + : undefined return ( - <PostThreadItem - item={item} - onPostReply={onRefresh} - hasPrecedingItem={prev?._showChildReplyLine} - treeView={treeView} - /> + <View + ref={item.ctx.isHighlightedPost ? highlightedPostRef : undefined}> + <PostThreadItem + post={item.post} + record={item.record} + treeView={threadViewPrefs.lab_treeViewEnabled || false} + depth={item.ctx.depth} + isHighlightedPost={item.ctx.isHighlightedPost} + hasMore={item.ctx.hasMore} + showChildReplyLine={item.ctx.showChildReplyLine} + showParentReplyLine={item.ctx.showParentReplyLine} + hasPrecedingItem={!!prev?.ctx.showChildReplyLine} + onPostReply={onRefresh} + /> + </View> ) } - return <></> + return null }, [ + hasSession, isTablet, isDesktop, onPressReply, @@ -274,77 +313,117 @@ export const PostThread = observer(function PostThread({ pal.colors.border, posts, onRefresh, - treeView, + threadViewPrefs.lab_treeViewEnabled, + _, ], ) - // loading - // = - if ( - !view.hasLoaded || - (view.isLoading && !view.isRefreshing) || - view.params.uri !== uri - ) { - return ( - <CenteredView> - <View style={s.p20}> - <ActivityIndicator size="large" /> - </View> - </CenteredView> - ) - } + return ( + <FlatList + ref={ref} + data={posts} + initialNumToRender={posts.length} + maintainVisibleContentPosition={ + !needsScrollAdjustment.current + ? MAINTAIN_VISIBLE_CONTENT_POSITION + : undefined + } + keyExtractor={item => item._reactKey} + renderItem={renderItem} + refreshControl={ + <RefreshControl + refreshing={isPTRing} + onRefresh={onPTR} + tintColor={pal.colors.text} + titleColor={pal.colors.text} + /> + } + onContentSizeChange={onContentSizeChange} + style={s.hContentRegion} + // @ts-ignore our .web version only -prf + desktopFixedHeight + /> + ) +} - // error - // = - if (view.hasError) { - if (view.notFound) { - return ( - <CenteredView> - <View style={[pal.view, pal.border, styles.notFoundContainer]}> - <Text type="title-lg" style={[pal.text, s.mb5]}> - Post not found - </Text> - <Text type="md" style={[pal.text, s.mb10]}> - The post may have been deleted. - </Text> - <TouchableOpacity - onPress={onPressBack} - accessibilityRole="button" - accessibilityLabel="Back" - accessibilityHint=""> - <Text type="2xl" style={pal.link}> - <FontAwesomeIcon - icon="angle-left" - style={[pal.link as FontAwesomeIconStyle, s.mr5]} - size={14} - /> - Back - </Text> - </TouchableOpacity> - </View> - </CenteredView> - ) +function PostThreadBlocked() { + const {_} = useLingui() + const pal = usePalette('default') + const navigation = useNavigation<NavigationProp>() + + const onPressBack = React.useCallback(() => { + if (navigation.canGoBack()) { + navigation.goBack() + } else { + navigation.navigate('Home') } - return ( - <CenteredView> - <ErrorMessage message={view.error} onPressTryAgain={onRefresh} /> - </CenteredView> - ) - } - if (view.isBlocked) { + }, [navigation]) + + return ( + <CenteredView> + <View style={[pal.view, pal.border, styles.notFoundContainer]}> + <Text type="title-lg" style={[pal.text, s.mb5]}> + <Trans>Post hidden</Trans> + </Text> + <Text type="md" style={[pal.text, s.mb10]}> + <Trans> + You have blocked the author or you have been blocked by the author. + </Trans> + </Text> + <TouchableOpacity + onPress={onPressBack} + accessibilityRole="button" + accessibilityLabel={_(msg`Back`)} + accessibilityHint=""> + <Text type="2xl" style={pal.link}> + <FontAwesomeIcon + icon="angle-left" + style={[pal.link as FontAwesomeIconStyle, s.mr5]} + size={14} + /> + Back + </Text> + </TouchableOpacity> + </View> + </CenteredView> + ) +} + +function PostThreadError({ + onRefresh, + notFound, + error, +}: { + onRefresh: () => void + notFound: boolean + error: Error | null +}) { + const {_} = useLingui() + const pal = usePalette('default') + const navigation = useNavigation<NavigationProp>() + + const onPressBack = React.useCallback(() => { + if (navigation.canGoBack()) { + navigation.goBack() + } else { + navigation.navigate('Home') + } + }, [navigation]) + + if (notFound) { return ( <CenteredView> <View style={[pal.view, pal.border, styles.notFoundContainer]}> <Text type="title-lg" style={[pal.text, s.mb5]}> - Post hidden + <Trans>Post not found</Trans> </Text> <Text type="md" style={[pal.text, s.mb10]}> - You have blocked the author or you have been blocked by the author. + <Trans>The post may have been deleted.</Trans> </Text> <TouchableOpacity onPress={onPressBack} accessibilityRole="button" - accessibilityLabel="Back" + accessibilityLabel={_(msg`Back`)} accessibilityHint=""> <Text type="2xl" style={pal.link}> <FontAwesomeIcon @@ -352,76 +431,48 @@ export const PostThread = observer(function PostThread({ style={[pal.link as FontAwesomeIconStyle, s.mr5]} size={14} /> - Back + <Trans>Back</Trans> </Text> </TouchableOpacity> </View> </CenteredView> ) } - - // loaded - // = return ( - <FlatList - ref={ref} - data={posts} - initialNumToRender={posts.length} - maintainVisibleContentPosition={ - isNative && view.isFromCache && view.isCachedPostAReply - ? MAINTAIN_VISIBLE_CONTENT_POSITION - : undefined - } - keyExtractor={item => item._reactKey} - renderItem={renderItem} - refreshControl={ - <RefreshControl - refreshing={isRefreshing} - onRefresh={onRefresh} - tintColor={pal.colors.text} - titleColor={pal.colors.text} - /> - } - onContentSizeChange={ - isNative && view.isFromCache ? undefined : onContentSizeChange - } - onScrollToIndexFailed={onScrollToIndexFailed} - style={s.hContentRegion} - // @ts-ignore our .web version only -prf - desktopFixedHeight - /> + <CenteredView> + <ErrorMessage message={cleanError(error)} onPressTryAgain={onRefresh} /> + </CenteredView> ) -}) +} + +function isThreadPost(v: unknown): v is ThreadPost { + return !!v && typeof v === 'object' && 'type' in v && v.type === 'post' +} -function* flattenThread( - post: PostThreadItemModel, - isAscending = false, +function* flattenThreadSkeleton( + node: ThreadNode, ): Generator<YieldedItem, void> { - 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) + if (node.type === 'post') { + if (node.parent) { + yield* flattenThreadSkeleton(node.parent) + } else if (node.ctx.isParentLoading) { + yield PARENT_SPINNER } - } - 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) + yield node + if (node.ctx.isHighlightedPost) { + 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 (!isAscending && !post.parent && post.post.replyCount) { - runInAction(() => { - post._hasMore = true - }) + } else if (node.type === 'not-found') { + yield DELETED + } else if (node.type === 'blocked') { + yield BLOCKED } } |