diff --git a/src/components/Error.tsx b/src/components/Error.tsx new file mode 100644 index 00000000..1dbf6828 --- /dev/null +++ b/src/components/Error.tsx @@ -0,0 +1,90 @@ +import React from 'react' + +import {CenteredView} from 'view/com/util/Views' +import {atoms as a, useBreakpoints, useTheme} from '#/alf' +import {Text} from '#/components/Typography' +import {View} from 'react-native' +import {Button} from '#/components/Button' +import {useNavigation} from '@react-navigation/core' +import {NavigationProp} from 'lib/routes/types' +import {StackActions} from '@react-navigation/native' +import {router} from '#/routes' + +export function Error({ + title, + message, + onRetry, +}: { + title?: string + message?: string + onRetry?: () => unknown +}) { + const navigation = useNavigation() + const t = useTheme() + const {gtMobile} = useBreakpoints() + + const canGoBack = navigation.canGoBack() + const onGoBack = React.useCallback(() => { + if (canGoBack) { + navigation.goBack() + } else { + navigation.navigate('HomeTab') + + // Checking the state for routes ensures that web doesn't encounter errors while going back + if (navigation.getState()?.routes) { + navigation.dispatch(StackActions.push(...router.matchPath('/'))) + } else { + navigation.navigate('HomeTab') + navigation.dispatch(StackActions.popToTop()) + } + } + }, [navigation, canGoBack]) + + return ( + + + {title} + + {message} + + + + {onRetry && ( + + )} + + + + ) +} diff --git a/src/components/Lists.tsx b/src/components/Lists.tsx index 8a889c15..a74484b7 100644 --- a/src/components/Lists.tsx +++ b/src/components/Lists.tsx @@ -1,26 +1,28 @@ import React from 'react' import {atoms as a, useBreakpoints, useTheme} from '#/alf' import {View} from 'react-native' +import {useLingui} from '@lingui/react' + import {CenteredView} from 'view/com/util/Views' import {Loader} from '#/components/Loader' -import {Trans} from '@lingui/macro' +import {msg, Trans} from '@lingui/macro' import {cleanError} from 'lib/strings/errors' import {Button} from '#/components/Button' import {Text} from '#/components/Typography' -import {StackActions} from '@react-navigation/native' -import {router} from '#/routes' -import {useNavigationDeduped} from 'lib/hooks/useNavigationDeduped' +import {Error} from '#/components/Error' export function ListFooter({ isFetching, isError, error, onRetry, + height, }: { - isFetching: boolean - isError: boolean + isFetching?: boolean + isError?: boolean error?: string onRetry?: () => Promise + height?: number }) { const t = useTheme() @@ -29,11 +31,10 @@ export function ListFooter({ style={[ a.w_full, a.align_center, - a.justify_center, a.border_t, a.pb_lg, t.atoms.border_contrast_low, - {height: 180}, + {height: height ?? 180, paddingTop: 30}, ]}> {isFetching ? ( @@ -53,7 +54,7 @@ function ListFooterMaybeError({ error, onRetry, }: { - isError: boolean + isError?: boolean error?: string onRetry?: () => Promise }) { @@ -128,121 +129,71 @@ export function ListMaybePlaceholder({ isLoading, isEmpty, isError, - empty, - error, - notFoundType = 'page', + emptyTitle, + emptyMessage, + errorTitle, + errorMessage, + emptyType = 'page', onRetry, }: { isLoading: boolean - isEmpty: boolean - isError: boolean - empty?: string - error?: string - notFoundType?: 'page' | 'results' + isEmpty?: boolean + isError?: boolean + emptyTitle?: string + emptyMessage?: string + errorTitle?: string + errorMessage?: string + emptyType?: 'page' | 'results' onRetry?: () => Promise }) { - const navigation = useNavigationDeduped() const t = useTheme() + const {_} = useLingui() const {gtMobile, gtTablet} = useBreakpoints() - const canGoBack = navigation.canGoBack() - const onGoBack = React.useCallback(() => { - if (canGoBack) { - navigation.goBack() - } else { - navigation.navigate('HomeTab') + if (!isLoading && isError) { + return ( + + ) + } - // Checking the state for routes ensures that web doesn't encounter errors while going back - if (navigation.getState()?.routes) { - navigation.dispatch(StackActions.push(...router.matchPath('/'))) - } else { - navigation.navigate('HomeTab') - navigation.dispatch(StackActions.popToTop()) - } - } - }, [navigation, canGoBack]) - - if (!isEmpty) return null - - return ( - - {isLoading ? ( + if (isLoading) { + return ( + - ) : ( - <> - - - {isError ? ( - Oops! - ) : isEmpty ? ( - <> - {notFoundType === 'results' ? ( - No results found - ) : ( - Page not found - )} - - ) : undefined} - + + ) + } - {isError ? ( - - {error ? error : Something went wrong!} - - ) : isEmpty ? ( - - {empty ? ( - empty - ) : ( - - We're sorry! We can't find the page you were looking for. - - )} - - ) : undefined} - - - {isError && onRetry && ( - - )} - - - - )} - - ) + if (isEmpty) { + return ( + + ) + } } diff --git a/src/screens/Hashtag.tsx b/src/screens/Hashtag.tsx index 776cc585..46452f08 100644 --- a/src/screens/Hashtag.tsx +++ b/src/screens/Hashtag.tsx @@ -128,8 +128,8 @@ export default function HashtagScreen({ isError={isError} isEmpty={posts.length < 1} onRetry={refetch} - notFoundType="results" - empty={_(msg`We couldn't find any results for that hashtag.`)} + emptyTitle="results" + emptyMessage={_(msg`We couldn't find any results for that hashtag.`)} /> {!isLoading && posts.length > 0 && ( diff --git a/src/view/com/post-thread/PostThread.tsx b/src/view/com/post-thread/PostThread.tsx index bac7018c..8042e7bd 100644 --- a/src/view/com/post-thread/PostThread.tsx +++ b/src/view/com/post-thread/PostThread.tsx @@ -1,25 +1,14 @@ import React, {useEffect, useRef} from 'react' -import { - ActivityIndicator, - Pressable, - StyleSheet, - TouchableOpacity, - View, -} from 'react-native' +import {StyleSheet, useWindowDimensions, View} from 'react-native' import {AppBskyFeedDefs} from '@atproto/api' -import {CenteredView} from '../util/Views' -import {LoadingScreen} from '../util/LoadingScreen' +import {Trans, msg} from '@lingui/macro' +import {useLingui} from '@lingui/react' + 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 { @@ -30,21 +19,18 @@ import { 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' +import {useInitialNumToRender} from 'lib/hooks/useInitialNumToRender' +import {ListFooter, ListMaybePlaceholder} from '#/components/Lists' +import {cleanError} from 'lib/strings/errors' // FlatList maintainVisibleContentPosition breaks if too many items // are prepended. This seems to be an optimal number based on *shrug*. @@ -58,9 +44,7 @@ const MAINTAIN_VISIBLE_CONTENT_POSITION = { 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 = @@ -68,9 +52,7 @@ type RowItem = // 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[] @@ -78,6 +60,10 @@ type ThreadSkeletonParts = { replies: YieldedItem[] } +const keyExtractor = (item: RowItem) => { + return item._reactKey +} + export function PostThread({ uri, onCanReply, @@ -85,17 +71,30 @@ export function PostThread({ }: { uri: string | undefined onCanReply: (canReply: boolean) => void - onPressReply: () => void + onPressReply: () => unknown }) { + const {hasSession} = useSession() + const {_} = useLingui() + const pal = usePalette('default') + const {isMobile, isTabletOrMobile} = useWebMediaQueries() + const initialNumToRender = useInitialNumToRender() + const {height: windowHeight} = useWindowDimensions() + + const {data: preferences} = usePreferencesQuery() const { - isLoading, - isError, - error, + isFetching, + isError: isThreadError, + error: threadError, refetch, data: thread, } = usePostThreadQuery(uri) - const {data: preferences} = usePreferencesQuery() + const treeView = React.useMemo( + () => + !!preferences?.threadViewPrefs?.lab_treeViewEnabled && + hasBranchingReplies(thread), + [preferences?.threadViewPrefs, thread], + ) const rootPost = thread?.type === 'post' ? thread.post : undefined const rootPostRecord = thread?.type === 'post' ? thread.record : undefined @@ -105,7 +104,6 @@ export function PostThread({ rootPost && moderationOpts ? moderatePost(rootPost, moderationOpts) : undefined - return !!mod ?.ui('contentList') .blurs.find( @@ -114,6 +112,14 @@ export function PostThread({ ) }, [rootPost, moderationOpts]) + // Values used for proper rendering of parents + const ref = useRef(null) + const highlightedPostRef = useRef(null) + const [maxParents, setMaxParents] = React.useState( + isWeb ? Infinity : PARENTS_CHUNK_SIZE, + ) + const [maxReplies, setMaxReplies] = React.useState(50) + useSetTitle( rootPost && !isNoPwi ? `${sanitizeDisplayName( @@ -121,62 +127,6 @@ export function PostThread({ )}: "${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. @@ -184,18 +134,56 @@ function PostThreadLoaded({ // 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], - ) + const skeleton = React.useMemo(() => { + const threadViewPrefs = preferences?.threadViewPrefs + if (!threadViewPrefs || !thread) return null + + return createThreadSkeleton( + sortThread(thread, threadViewPrefs), + hasSession, + treeView, + ) + }, [thread, preferences?.threadViewPrefs, hasSession, treeView]) + + const error = React.useMemo(() => { + if (AppBskyFeedDefs.isNotFoundPost(thread)) { + return { + title: _(msg`Post not found`), + message: _(msg`The post may have been deleted.`), + } + } else if (skeleton?.highlightedPost.type === 'blocked') { + return { + title: _(msg`Post hidden`), + message: _( + msg`You have blocked the author or you have been blocked by the author.`, + ), + } + } else if (threadError?.message.startsWith('Post not found')) { + return { + title: _(msg`Post not found`), + message: _(msg`The post may have been deleted.`), + } + } else if (isThreadError) { + return { + message: threadError ? cleanError(threadError) : undefined, + } + } + + return null + }, [thread, skeleton?.highlightedPost, isThreadError, _, threadError]) + + useEffect(() => { + if (error) { + onCanReply(false) + } else if (rootPost) { + onCanReply(!rootPost.viewer?.replyDisabled) + } + }, [rootPost, onCanReply, error]) // construct content const posts = React.useMemo(() => { + if (!skeleton) return [] + const {parents, highlightedPost, replies} = skeleton let arr: RowItem[] = [] if (highlightedPost.type === 'post') { @@ -231,17 +219,11 @@ function PostThreadLoaded({ 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 - } + for (let i = 0; i < replies.length; i++) { + arr.push(replies[i]) + if (i === maxReplies) { + break } - arr.push(BOTTOM_COMPONENT) } } return arr @@ -256,7 +238,7 @@ function PostThreadLoaded({ return } // wait for loading to finish - if (thread.type === 'post' && !!thread.parent) { + if (thread?.type === 'post' && !!thread.parent) { function onMeasure(pageY: number) { ref.current?.scrollToOffset({ animated: false, @@ -280,10 +262,10 @@ function PostThreadLoaded({ // 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) { + if (skeleton?.parents && maxParents < skeleton.parents.length) { needsBumpMaxParents.current = true } - }, [maxParents, skeleton.parents.length]) + }, [maxParents, skeleton?.parents]) const bumpMaxParentsIfNeeded = React.useCallback(() => { if (!isNative) { return @@ -296,6 +278,11 @@ function PostThreadLoaded({ const onMomentumScrollEnd = bumpMaxParentsIfNeeded const onScrollToTop = bumpMaxParentsIfNeeded + const onEndReached = React.useCallback(() => { + if (isFetching || posts.length < maxReplies) return + setMaxReplies(prev => prev + 50) + }, [isFetching, maxReplies, posts.length]) + const renderItem = React.useCallback( ({item, index}: {item: RowItem; index: number}) => { if (item === TOP_COMPONENT) { @@ -326,46 +313,6 @@ function PostThreadLoaded({ ) - } 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) @@ -374,7 +321,9 @@ function PostThreadLoaded({ ? (posts[index - 1] as ThreadPost) : undefined const hasUnrevealedParents = - index === 0 && maxParents < skeleton.parents.length + index === 0 && + skeleton?.parents && + maxParents < skeleton.parents.length return ( ) @@ -403,142 +352,62 @@ function PostThreadLoaded({ [ hasSession, isTabletOrMobile, + _, isMobile, onPressReply, pal.border, pal.viewLight, pal.textLight, - pal.view, - pal.text, - pal.colors.border, posts, - onRefresh, + skeleton?.parents, + maxParents, deferParents, treeView, - skeleton.parents.length, - maxParents, - _, + refetch, ], ) 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. - - - - - + + {!error && thread && ( + - 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 ( - - - + } + initialNumToRender={initialNumToRender} + windowSize={11} + /> + )} + ) } @@ -558,7 +427,9 @@ function createThreadSkeleton( node: ThreadNode, hasSession: boolean, treeView: boolean, -): ThreadSkeletonParts { +): ThreadSkeletonParts | null { + if (!node) return null + return { parents: Array.from(flattenThreadParents(node, hasSession)), highlightedPost: node, @@ -615,7 +486,10 @@ function hasPwiOptOut(node: ThreadPost) { return !!node.post.author.labels?.find(l => l.val === '!no-unauthenticated') } -function hasBranchingReplies(node: ThreadNode) { +function hasBranchingReplies(node?: ThreadNode) { + if (!node) { + return false + } if (node.type !== 'post') { return false } @@ -629,20 +503,9 @@ function hasBranchingReplies(node: ThreadNode) { } 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, - }, })