Clean up callbacks, better error state

This commit is contained in:
Eric Bailey
2025-06-06 16:35:38 -05:00
parent 8861f51a97
commit b9d7c5a5c4
2 changed files with 221 additions and 133 deletions
@@ -0,0 +1,90 @@
import {useMemo} from 'react'
import {View} from 'react-native'
import {msg, Trans} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {useCleanError} from '#/lib/hooks/useCleanError'
import {OUTER_SPACE} from '#/screens/PostThread/const'
import {atoms as a, useTheme} from '#/alf'
import {Button, ButtonIcon, ButtonText} from '#/components/Button'
import {ArrowRotateCounterClockwise_Stroke2_Corner0_Rounded as RetryIcon} from '#/components/icons/ArrowRotateCounterClockwise'
import * as Layout from '#/components/Layout'
import {Text} from '#/components/Typography'
export function ThreadError({
error,
onRetry,
}: {
error: Error
onRetry: () => void
}) {
const t = useTheme()
const {_} = useLingui()
const cleanError = useCleanError()
// TODO use new cleanError hook
const {title, message} = useMemo(() => {
let title = _(msg`Error loading post`)
let message = _(msg`Something went wrong. Please try again in a moment.`)
const {raw, clean} = cleanError(error)
if (error.message.startsWith('Post not found')) {
title = _(msg`Post not found`)
message = clean || raw || message
}
return {title, message}
}, [_, error, cleanError])
return (
<Layout.Center>
<View
style={[
a.flex_1,
a.align_center,
{
padding: OUTER_SPACE,
paddingTop: OUTER_SPACE * 2,
},
]}>
<View
style={[
a.w_full,
a.align_center,
a.gap_xl,
{
maxWidth: 260,
},
]}>
<View style={[a.gap_xs]}>
<Text
style={[a.text_center, a.text_lg, a.font_bold, a.leading_snug]}>
{title}
</Text>
<Text
style={[
a.text_center,
a.text_sm,
a.leading_snug,
t.atoms.text_contrast_medium,
]}>
{message}
</Text>
</View>
<Button
label={_(msg`Retry`)}
size="small"
variant="solid"
color="secondary_inverted"
onPress={onRetry}>
<ButtonText>
<Trans>Retry</Trans>
</ButtonText>
<ButtonIcon icon={RetryIcon} position="right" />
</Button>
</View>
</View>
</Layout.Center>
)
}
+131 -133
View File
@@ -1,11 +1,10 @@
import {useMemo, useRef, useState} from 'react' import {useCallback, useMemo, useRef, useState} from 'react'
import {useWindowDimensions, View} from 'react-native' import {useWindowDimensions, View} from 'react-native'
import {msg, Trans} from '@lingui/macro' import {Trans} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {useInitialNumToRender} from '#/lib/hooks/useInitialNumToRender' import {useInitialNumToRender} from '#/lib/hooks/useInitialNumToRender'
import {useOpenComposer} from '#/lib/hooks/useOpenComposer' import {useOpenComposer} from '#/lib/hooks/useOpenComposer'
import {cleanError} from '#/lib/strings/errors' import {type ThreadViewOption} from '#/state/queries/preferences/useThreadPreferences'
import {type ThreadItem, usePostThread} from '#/state/queries/usePostThread' import {type ThreadItem, usePostThread} from '#/state/queries/usePostThread'
import {type OnPostSuccessData} from '#/state/shell/composer' import {type OnPostSuccessData} from '#/state/shell/composer'
import {PostThreadComposePrompt} from '#/view/com/post-thread/PostThreadComposePrompt' import {PostThreadComposePrompt} from '#/view/com/post-thread/PostThreadComposePrompt'
@@ -15,6 +14,7 @@ import {
ThreadAnchor, ThreadAnchor,
ThreadAnchorSkeleton, ThreadAnchorSkeleton,
} from '#/screens/PostThread/components/ThreadAnchor' } from '#/screens/PostThread/components/ThreadAnchor'
import {ThreadError} from '#/screens/PostThread/components/ThreadError'
import { import {
ThreadItemPost, ThreadItemPost,
ThreadItemPostSkeleton, ThreadItemPostSkeleton,
@@ -46,17 +46,19 @@ export function Inner({uri}: {uri: string | undefined}) {
*/ */
const thread = usePostThread({anchor: uri}) const thread = usePostThread({anchor: uri})
const optimisticOnPostReply = (payload: OnPostSuccessData) => {
if (payload) {
const {replyToUri, posts} = payload
if (replyToUri && posts.length) {
thread.actions.insertReplies(replyToUri, posts)
}
}
}
const {openComposer} = useOpenComposer() const {openComposer} = useOpenComposer()
const onReplyToAnchor = () => { const optimisticOnPostReply = useCallback(
(payload: OnPostSuccessData) => {
if (payload) {
const {replyToUri, posts} = payload
if (replyToUri && posts.length) {
thread.actions.insertReplies(replyToUri, posts)
}
}
},
[thread],
)
const onReplyToAnchor = useCallback(() => {
const anchorPost = thread.data.items.find( const anchorPost = thread.data.items.find(
slice => slice.type === 'threadPost' && slice.ui.isAnchor, slice => slice.type === 'threadPost' && slice.ui.isAnchor,
) )
@@ -75,7 +77,7 @@ export function Inner({uri}: {uri: string | undefined}) {
}, },
onPostSuccess: optimisticOnPostReply, onPostSuccess: optimisticOnPostReply,
}) })
} }, [thread, openComposer, optimisticOnPostReply])
const [maxParentCount, setMaxParentCount] = useState(PARENT_CHUNK_SIZE) const [maxParentCount, setMaxParentCount] = useState(PARENT_CHUNK_SIZE)
const [maxChildrenCount, setMaxChildrenCount] = useState(CHILDREN_CHUNK_SIZE) const [maxChildrenCount, setMaxChildrenCount] = useState(CHILDREN_CHUNK_SIZE)
@@ -239,105 +241,125 @@ export function Inner({uri}: {uri: string | undefined}) {
) )
}, [slices]) }, [slices])
const renderItem = ({item, index}: {item: ThreadItem; index: number}) => { const renderItem = useCallback(
if (item.type === 'threadPost') { ({item, index}: {item: ThreadItem; index: number}) => {
if (item.depth < 0) { if (item.type === 'threadPost') {
if (deferParents) return null if (item.depth < 0) {
return (
<ThreadItemPost
item={item}
threadgateRecord={thread.data.threadgate?.record ?? undefined}
overrides={{
topBorder: index === 0,
}}
onPostSuccess={optimisticOnPostReply}
/>
)
} else if (item.depth === 0) {
return (
<View
/*
* IMPORTANT: this is a load-bearing key. We want to force
* `onLayout` to fire any time the thread params change so that
* `deferParents` is always reset to `false` once the anchor post is
* rendered.
*
* If we ever add additional thread params to this screen, they
* will need to be added here.
*/
key={item.uri + thread.state.view + thread.state.sort}
ref={anchorRef}
onLayout={() => setDeferParents(false)}>
<ThreadAnchor
item={item}
threadgateRecord={thread.data.threadgate?.record ?? undefined}
onPostSuccess={optimisticOnPostReply}
/>
</View>
)
} else {
if (thread.state.view === 'tree') {
return (
<ThreadItemTreePost
item={item}
threadgateRecord={thread.data.threadgate?.record ?? undefined}
overrides={{
moderation: thread.state.otherItemsVisible && item.depth > 0,
}}
onPostSuccess={optimisticOnPostReply}
/>
)
} else {
return ( return (
<ThreadItemPost <ThreadItemPost
item={item} item={item}
threadgateRecord={thread.data.threadgate?.record ?? undefined} threadgateRecord={thread.data.threadgate?.record ?? undefined}
overrides={{ overrides={{
moderation: thread.state.otherItemsVisible && item.depth > 0, topBorder: index === 0,
}} }}
onPostSuccess={optimisticOnPostReply} onPostSuccess={optimisticOnPostReply}
/> />
) )
} } else if (item.depth === 0) {
} return (
} else if (item.type === 'readMore') { <View
return ( /*
<ThreadItemReadMore * IMPORTANT: this is a load-bearing key. We want to force
item={item} * `onLayout` to fire any time the thread params change so that
view={thread.state.view === 'tree' ? 'tree' : 'linear'} * `deferParents` is always reset to `false` once the anchor post is
/> * rendered.
) *
} else if (item.type === 'readMoreUp') { * If we ever add additional thread params to this screen, they
return <ThreadItemReadMoreUp item={item} /> * will need to be added here.
} else if (item.type === 'threadPostBlocked') { */
return <ThreadItemPostTombstone type="blocked" /> key={item.uri + thread.state.view + thread.state.sort}
} else if (item.type === 'threadPostNotFound') { ref={anchorRef}
return <ThreadItemPostTombstone type="not-found" /> onLayout={() => setDeferParents(false)}>
} else if (item.type === 'replyComposer') { <ThreadAnchor
return ( item={item}
<View> threadgateRecord={thread.data.threadgate?.record ?? undefined}
{gtPhone && ( onPostSuccess={optimisticOnPostReply}
<PostThreadComposePrompt onPressCompose={onReplyToAnchor} /> />
)} </View>
</View> )
)
} else if (item.type === 'showOtherReplies') {
return <ThreadItemShowOtherReplies onPress={item.onPress} />
} else if (item.type === 'skeleton') {
if (item.item === 'anchor') {
return <ThreadAnchorSkeleton />
} else if (item.item === 'reply') {
if (thread.state.view === 'linear') {
return <ThreadItemPostSkeleton index={index} />
} else { } else {
return <ThreadItemTreePostSkeleton index={index} /> if (thread.state.view === 'tree') {
return (
<ThreadItemTreePost
item={item}
threadgateRecord={thread.data.threadgate?.record ?? undefined}
overrides={{
moderation: thread.state.otherItemsVisible && item.depth > 0,
}}
onPostSuccess={optimisticOnPostReply}
/>
)
} else {
return (
<ThreadItemPost
item={item}
threadgateRecord={thread.data.threadgate?.record ?? undefined}
overrides={{
moderation: thread.state.otherItemsVisible && item.depth > 0,
}}
onPostSuccess={optimisticOnPostReply}
/>
)
}
}
} else if (item.type === 'readMore') {
return (
<ThreadItemReadMore
item={item}
view={thread.state.view === 'tree' ? 'tree' : 'linear'}
/>
)
} else if (item.type === 'readMoreUp') {
return <ThreadItemReadMoreUp item={item} />
} else if (item.type === 'threadPostBlocked') {
return <ThreadItemPostTombstone type="blocked" />
} else if (item.type === 'threadPostNotFound') {
return <ThreadItemPostTombstone type="not-found" />
} else if (item.type === 'replyComposer') {
return (
<View>
{gtPhone && (
<PostThreadComposePrompt onPressCompose={onReplyToAnchor} />
)}
</View>
)
} else if (item.type === 'showOtherReplies') {
return <ThreadItemShowOtherReplies onPress={item.onPress} />
} else if (item.type === 'skeleton') {
if (item.item === 'anchor') {
return <ThreadAnchorSkeleton />
} else if (item.item === 'reply') {
if (thread.state.view === 'linear') {
return <ThreadItemPostSkeleton index={index} />
} else {
return <ThreadItemTreePostSkeleton index={index} />
}
} else if (item.item === 'replyComposer') {
return <ThreadItemReplyComposerSkeleton />
} }
} else if (item.item === 'replyComposer') {
return <ThreadItemReplyComposerSkeleton />
} }
} return null
return null },
} [thread, optimisticOnPostReply, onReplyToAnchor, gtPhone],
)
const setSortWrapped = useCallback(
(sort: string) => {
setDeferParents(true)
shouldScrollToAnchor.current = true
thread.actions.setSort(sort)
},
[thread, setDeferParents],
)
const setViewWrapped = useCallback(
(view: ThreadViewOption) => {
thread.actions.setView(view)
setDeferParents(true)
shouldScrollToAnchor.current = true
},
[thread, setDeferParents],
)
return ( return (
<> <>
@@ -351,23 +373,18 @@ export function Inner({uri}: {uri: string | undefined}) {
<Layout.Header.Slot> <Layout.Header.Slot>
<HeaderDropdown <HeaderDropdown
sort={thread.state.sort} sort={thread.state.sort}
setSort={val => { setSort={setSortWrapped}
thread.actions.setSort(val)
setDeferParents(true)
shouldScrollToAnchor.current = true
}}
view={thread.state.view} view={thread.state.view}
setView={val => { setView={setViewWrapped}
thread.actions.setView(val)
setDeferParents(true)
shouldScrollToAnchor.current = true
}}
/> />
</Layout.Header.Slot> </Layout.Header.Slot>
</Layout.Header.Outer> </Layout.Header.Outer>
{thread.state.error ? ( {thread.state.error ? (
<PostThreadError error={thread.state.error} /> <ThreadError
error={thread.state.error}
onRetry={thread.actions.refetch}
/>
) : ( ) : (
<List <List
ref={listRef} ref={listRef}
@@ -385,11 +402,10 @@ export function Inner({uri}: {uri: string | undefined}) {
*/ */
maintainVisibleContentPosition={{minIndexForVisible: 0}} maintainVisibleContentPosition={{minIndexForVisible: 0}}
desktopFixedHeight desktopFixedHeight
// TODO
// removeClippedSubviews={isAndroid ? false : undefined} // removeClippedSubviews={isAndroid ? false : undefined}
ListFooterComponent={ ListFooterComponent={
<ListFooter <ListFooter
error={cleanError(thread.state.error)}
onRetry={thread.actions.refetch}
/* /*
* 200 is based on the minimum height of a post. This is enough * 200 is based on the minimum height of a post. This is enough
* extra height for the `maintainVisPos` to work without * extra height for the `maintainVisPos` to work without
@@ -408,24 +424,6 @@ export function Inner({uri}: {uri: string | undefined}) {
) )
} }
function PostThreadError({error}: {error: Error}) {
const {_} = useLingui()
// TODO use new cleanError hook
const {title: _title, message: _message} = useMemo(() => {
let title = _(msg`An error occurred`)
let message = cleanError(error)
if (error.message.startsWith('Post not found')) {
title = _(msg`Post not found`)
message = _(msg`The post may have been deleted.`)
}
return {title, message}
}, [_, error])
return <View />
}
const keyExtractor = (item: ThreadItem) => { const keyExtractor = (item: ThreadItem) => {
return item.key return item.key
} }