`PostThread` cleanup (#3183)

* cleanup PostThread

rm some more unnecessary code

cleanup some more pieces

fix `isLoading` logic

few fixes

organize

refactor `PostThread`

allow chaining of `postThreadQuery`

Update `Hashtag` screen with the component changes

Make some changes to the List components

adjust height and padding of bottom loader to account for bottom bar

* rm unnecessary chaining logic

* maxReplies logic

* adjust error logic

* use `<` instead of `<=`

* add back warning comment

* remove unused prop

* adjust order

* update prop name

* don't show error if `isLoading`
zio/stable
Hailey 2024-03-19 12:10:10 -07:00 committed by GitHub
parent 07e6d001a2
commit addd66b37f
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
4 changed files with 307 additions and 403 deletions

View File

@ -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<NavigationProp>()
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 (
<CenteredView
style={[
a.flex_1,
a.align_center,
!gtMobile ? a.justify_between : a.gap_5xl,
t.atoms.border_contrast_low,
{paddingTop: 175, paddingBottom: 110},
]}
sideBorders>
<View style={[a.w_full, a.align_center, a.gap_lg]}>
<Text style={[a.font_bold, a.text_3xl]}>{title}</Text>
<Text
style={[
a.text_md,
a.text_center,
t.atoms.text_contrast_high,
{lineHeight: 1.4},
gtMobile && {width: 450},
]}>
{message}
</Text>
</View>
<View style={[a.gap_md, gtMobile ? {width: 350} : [a.w_full, a.px_lg]]}>
{onRetry && (
<Button
variant="solid"
color="primary"
label="Click here"
onPress={onRetry}
size="large"
style={[a.rounded_sm, a.overflow_hidden, {paddingVertical: 10}]}>
Retry
</Button>
)}
<Button
variant="solid"
color={onRetry ? 'secondary' : 'primary'}
label="Click here"
onPress={onGoBack}
size="large"
style={[a.rounded_sm, a.overflow_hidden, {paddingVertical: 10}]}>
Go Back
</Button>
</View>
</CenteredView>
)
}

View File

@ -1,26 +1,28 @@
import React from 'react' import React from 'react'
import {atoms as a, useBreakpoints, useTheme} from '#/alf' import {atoms as a, useBreakpoints, useTheme} from '#/alf'
import {View} from 'react-native' import {View} from 'react-native'
import {useLingui} from '@lingui/react'
import {CenteredView} from 'view/com/util/Views' import {CenteredView} from 'view/com/util/Views'
import {Loader} from '#/components/Loader' import {Loader} from '#/components/Loader'
import {Trans} from '@lingui/macro' import {msg, Trans} from '@lingui/macro'
import {cleanError} from 'lib/strings/errors' import {cleanError} from 'lib/strings/errors'
import {Button} from '#/components/Button' import {Button} from '#/components/Button'
import {Text} from '#/components/Typography' import {Text} from '#/components/Typography'
import {StackActions} from '@react-navigation/native' import {Error} from '#/components/Error'
import {router} from '#/routes'
import {useNavigationDeduped} from 'lib/hooks/useNavigationDeduped'
export function ListFooter({ export function ListFooter({
isFetching, isFetching,
isError, isError,
error, error,
onRetry, onRetry,
height,
}: { }: {
isFetching: boolean isFetching?: boolean
isError: boolean isError?: boolean
error?: string error?: string
onRetry?: () => Promise<unknown> onRetry?: () => Promise<unknown>
height?: number
}) { }) {
const t = useTheme() const t = useTheme()
@ -29,11 +31,10 @@ export function ListFooter({
style={[ style={[
a.w_full, a.w_full,
a.align_center, a.align_center,
a.justify_center,
a.border_t, a.border_t,
a.pb_lg, a.pb_lg,
t.atoms.border_contrast_low, t.atoms.border_contrast_low,
{height: 180}, {height: height ?? 180, paddingTop: 30},
]}> ]}>
{isFetching ? ( {isFetching ? (
<Loader size="xl" /> <Loader size="xl" />
@ -53,7 +54,7 @@ function ListFooterMaybeError({
error, error,
onRetry, onRetry,
}: { }: {
isError: boolean isError?: boolean
error?: string error?: string
onRetry?: () => Promise<unknown> onRetry?: () => Promise<unknown>
}) { }) {
@ -128,121 +129,71 @@ export function ListMaybePlaceholder({
isLoading, isLoading,
isEmpty, isEmpty,
isError, isError,
empty, emptyTitle,
error, emptyMessage,
notFoundType = 'page', errorTitle,
errorMessage,
emptyType = 'page',
onRetry, onRetry,
}: { }: {
isLoading: boolean isLoading: boolean
isEmpty: boolean isEmpty?: boolean
isError: boolean isError?: boolean
empty?: string emptyTitle?: string
error?: string emptyMessage?: string
notFoundType?: 'page' | 'results' errorTitle?: string
errorMessage?: string
emptyType?: 'page' | 'results'
onRetry?: () => Promise<unknown> onRetry?: () => Promise<unknown>
}) { }) {
const navigation = useNavigationDeduped()
const t = useTheme() const t = useTheme()
const {_} = useLingui()
const {gtMobile, gtTablet} = useBreakpoints() const {gtMobile, gtTablet} = useBreakpoints()
const canGoBack = navigation.canGoBack() if (!isLoading && isError) {
const onGoBack = React.useCallback(() => { return (
if (canGoBack) { <Error
navigation.goBack() title={errorTitle ?? _(msg`Oops!`)}
} else { message={errorMessage ?? _(`Something went wrong!`)}
navigation.navigate('HomeTab') onRetry={onRetry}
/>
)
}
// Checking the state for routes ensures that web doesn't encounter errors while going back if (isLoading) {
if (navigation.getState()?.routes) { return (
navigation.dispatch(StackActions.push(...router.matchPath('/'))) <CenteredView
} else { style={[
navigation.navigate('HomeTab') a.flex_1,
navigation.dispatch(StackActions.popToTop()) a.align_center,
} !gtMobile ? a.justify_between : a.gap_5xl,
} t.atoms.border_contrast_low,
}, [navigation, canGoBack]) {paddingTop: 175, paddingBottom: 110},
]}
if (!isEmpty) return null sideBorders={gtMobile}
topBorder={!gtTablet}>
return (
<CenteredView
style={[
a.flex_1,
a.align_center,
!gtMobile ? a.justify_between : a.gap_5xl,
t.atoms.border_contrast_low,
{paddingTop: 175, paddingBottom: 110},
]}
sideBorders={gtMobile}
topBorder={!gtTablet}>
{isLoading ? (
<View style={[a.w_full, a.align_center, {top: 100}]}> <View style={[a.w_full, a.align_center, {top: 100}]}>
<Loader size="xl" /> <Loader size="xl" />
</View> </View>
) : ( </CenteredView>
<> )
<View style={[a.w_full, a.align_center, a.gap_lg]}> }
<Text style={[a.font_bold, a.text_3xl]}>
{isError ? (
<Trans>Oops!</Trans>
) : isEmpty ? (
<>
{notFoundType === 'results' ? (
<Trans>No results found</Trans>
) : (
<Trans>Page not found</Trans>
)}
</>
) : undefined}
</Text>
{isError ? ( if (isEmpty) {
<Text return (
style={[a.text_md, a.text_center, t.atoms.text_contrast_high]}> <Error
{error ? error : <Trans>Something went wrong!</Trans>} title={
</Text> emptyTitle ??
) : isEmpty ? ( (emptyType === 'results'
<Text ? _(msg`No results found`)
style={[a.text_md, a.text_center, t.atoms.text_contrast_high]}> : _(msg`Page not found`))
{empty ? ( }
empty message={
) : ( emptyMessage ??
<Trans> _(msg`We're sorry! We can't find the page you were looking for.`)
We're sorry! We can't find the page you were looking for. }
</Trans> onRetry={onRetry}
)} />
</Text> )
) : undefined} }
</View>
<View
style={[a.gap_md, !gtMobile ? [a.w_full, a.px_lg] : {width: 350}]}>
{isError && onRetry && (
<Button
variant="solid"
color="primary"
label="Click here"
onPress={onRetry}
size="large"
style={[
a.rounded_sm,
a.overflow_hidden,
{paddingVertical: 10},
]}>
Retry
</Button>
)}
<Button
variant="solid"
color={isError && onRetry ? 'secondary' : 'primary'}
label="Click here"
onPress={onGoBack}
size="large"
style={[a.rounded_sm, a.overflow_hidden, {paddingVertical: 10}]}>
Go Back
</Button>
</View>
</>
)}
</CenteredView>
)
} }

View File

@ -128,8 +128,8 @@ export default function HashtagScreen({
isError={isError} isError={isError}
isEmpty={posts.length < 1} isEmpty={posts.length < 1}
onRetry={refetch} onRetry={refetch}
notFoundType="results" emptyTitle="results"
empty={_(msg`We couldn't find any results for that hashtag.`)} emptyMessage={_(msg`We couldn't find any results for that hashtag.`)}
/> />
{!isLoading && posts.length > 0 && ( {!isLoading && posts.length > 0 && (
<List<PostView> <List<PostView>

View File

@ -1,25 +1,14 @@
import React, {useEffect, useRef} from 'react' import React, {useEffect, useRef} from 'react'
import { import {StyleSheet, useWindowDimensions, View} from 'react-native'
ActivityIndicator,
Pressable,
StyleSheet,
TouchableOpacity,
View,
} from 'react-native'
import {AppBskyFeedDefs} from '@atproto/api' import {AppBskyFeedDefs} from '@atproto/api'
import {CenteredView} from '../util/Views' import {Trans, msg} from '@lingui/macro'
import {LoadingScreen} from '../util/LoadingScreen' import {useLingui} from '@lingui/react'
import {List, ListMethods} from '../util/List' import {List, ListMethods} from '../util/List'
import {
FontAwesomeIcon,
FontAwesomeIconStyle,
} from '@fortawesome/react-native-fontawesome'
import {PostThreadItem} from './PostThreadItem' import {PostThreadItem} from './PostThreadItem'
import {ComposePrompt} from '../composer/Prompt' import {ComposePrompt} from '../composer/Prompt'
import {ViewHeader} from '../util/ViewHeader' import {ViewHeader} from '../util/ViewHeader'
import {ErrorMessage} from '../util/error/ErrorMessage'
import {Text} from '../util/text/Text' import {Text} from '../util/text/Text'
import {s} from 'lib/styles'
import {usePalette} from 'lib/hooks/usePalette' import {usePalette} from 'lib/hooks/usePalette'
import {useSetTitle} from 'lib/hooks/useSetTitle' import {useSetTitle} from 'lib/hooks/useSetTitle'
import { import {
@ -30,21 +19,18 @@ import {
usePostThreadQuery, usePostThreadQuery,
sortThread, sortThread,
} from '#/state/queries/post-thread' } from '#/state/queries/post-thread'
import {useNavigation} from '@react-navigation/native'
import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries' import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries'
import {NavigationProp} from 'lib/routes/types'
import {sanitizeDisplayName} from 'lib/strings/display-names' 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 { import {
UsePreferencesQueryResponse,
useModerationOpts, useModerationOpts,
usePreferencesQuery, usePreferencesQuery,
} from '#/state/queries/preferences' } from '#/state/queries/preferences'
import {useSession} from '#/state/session' import {useSession} from '#/state/session'
import {isAndroid, isNative, isWeb} from '#/platform/detection' import {isAndroid, isNative, isWeb} from '#/platform/detection'
import {moderatePost_wrapped as moderatePost} from '#/lib/moderatePost_wrapped' 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 // FlatList maintainVisibleContentPosition breaks if too many items
// are prepended. This seems to be an optimal number based on *shrug*. // 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 TOP_COMPONENT = {_reactKey: '__top_component__'}
const REPLY_PROMPT = {_reactKey: '__reply__'} const REPLY_PROMPT = {_reactKey: '__reply__'}
const CHILD_SPINNER = {_reactKey: '__child_spinner__'}
const LOAD_MORE = {_reactKey: '__load_more__'} const LOAD_MORE = {_reactKey: '__load_more__'}
const BOTTOM_COMPONENT = {_reactKey: '__bottom_component__'}
type YieldedItem = ThreadPost | ThreadBlocked | ThreadNotFound type YieldedItem = ThreadPost | ThreadBlocked | ThreadNotFound
type RowItem = type RowItem =
@ -68,9 +52,7 @@ type RowItem =
// TODO: TS doesn't actually enforce it's one of these, it only enforces matching shape. // TODO: TS doesn't actually enforce it's one of these, it only enforces matching shape.
| typeof TOP_COMPONENT | typeof TOP_COMPONENT
| typeof REPLY_PROMPT | typeof REPLY_PROMPT
| typeof CHILD_SPINNER
| typeof LOAD_MORE | typeof LOAD_MORE
| typeof BOTTOM_COMPONENT
type ThreadSkeletonParts = { type ThreadSkeletonParts = {
parents: YieldedItem[] parents: YieldedItem[]
@ -78,6 +60,10 @@ type ThreadSkeletonParts = {
replies: YieldedItem[] replies: YieldedItem[]
} }
const keyExtractor = (item: RowItem) => {
return item._reactKey
}
export function PostThread({ export function PostThread({
uri, uri,
onCanReply, onCanReply,
@ -85,17 +71,30 @@ export function PostThread({
}: { }: {
uri: string | undefined uri: string | undefined
onCanReply: (canReply: boolean) => void 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 { const {
isLoading, isFetching,
isError, isError: isThreadError,
error, error: threadError,
refetch, refetch,
data: thread, data: thread,
} = usePostThreadQuery(uri) } = 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 rootPost = thread?.type === 'post' ? thread.post : undefined
const rootPostRecord = thread?.type === 'post' ? thread.record : undefined const rootPostRecord = thread?.type === 'post' ? thread.record : undefined
@ -105,7 +104,6 @@ export function PostThread({
rootPost && moderationOpts rootPost && moderationOpts
? moderatePost(rootPost, moderationOpts) ? moderatePost(rootPost, moderationOpts)
: undefined : undefined
return !!mod return !!mod
?.ui('contentList') ?.ui('contentList')
.blurs.find( .blurs.find(
@ -114,6 +112,14 @@ export function PostThread({
) )
}, [rootPost, moderationOpts]) }, [rootPost, moderationOpts])
// Values used for proper rendering of parents
const ref = useRef<ListMethods>(null)
const highlightedPostRef = useRef<View | null>(null)
const [maxParents, setMaxParents] = React.useState(
isWeb ? Infinity : PARENTS_CHUNK_SIZE,
)
const [maxReplies, setMaxReplies] = React.useState(50)
useSetTitle( useSetTitle(
rootPost && !isNoPwi rootPost && !isNoPwi
? `${sanitizeDisplayName( ? `${sanitizeDisplayName(
@ -121,62 +127,6 @@ export function PostThread({
)}: "${rootPostRecord!.text}"` )}: "${rootPostRecord!.text}"`
: '', : '',
) )
useEffect(() => {
if (rootPost) {
onCanReply(!rootPost.viewer?.replyDisabled)
}
}, [rootPost, onCanReply])
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 <LoadingScreen />
}
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
}) {
const {hasSession} = useSession()
const {_} = useLingui()
const pal = usePalette('default')
const {isMobile, isTabletOrMobile} = useWebMediaQueries()
const ref = useRef<ListMethods>(null)
const highlightedPostRef = useRef<View | null>(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. // 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. // 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. // On the web this is not necessary because we can synchronously adjust the scroll in onContentSizeChange instead.
const [deferParents, setDeferParents] = React.useState(isNative) const [deferParents, setDeferParents] = React.useState(isNative)
const skeleton = React.useMemo( const skeleton = React.useMemo(() => {
() => const threadViewPrefs = preferences?.threadViewPrefs
createThreadSkeleton( if (!threadViewPrefs || !thread) return null
sortThread(thread, threadViewPrefs),
hasSession, return createThreadSkeleton(
treeView, sortThread(thread, threadViewPrefs),
), hasSession,
[thread, threadViewPrefs, hasSession, treeView], 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 // construct content
const posts = React.useMemo(() => { const posts = React.useMemo(() => {
if (!skeleton) return []
const {parents, highlightedPost, replies} = skeleton const {parents, highlightedPost, replies} = skeleton
let arr: RowItem[] = [] let arr: RowItem[] = []
if (highlightedPost.type === 'post') { if (highlightedPost.type === 'post') {
@ -231,17 +219,11 @@ function PostThreadLoaded({
if (!highlightedPost.post.viewer?.replyDisabled) { if (!highlightedPost.post.viewer?.replyDisabled) {
arr.push(REPLY_PROMPT) arr.push(REPLY_PROMPT)
} }
if (highlightedPost.ctx.isChildLoading) { for (let i = 0; i < replies.length; i++) {
arr.push(CHILD_SPINNER) arr.push(replies[i])
} else { if (i === maxReplies) {
for (let i = 0; i < replies.length; i++) { break
arr.push(replies[i])
if (i === maxReplies) {
arr.push(LOAD_MORE)
break
}
} }
arr.push(BOTTOM_COMPONENT)
} }
} }
return arr return arr
@ -256,7 +238,7 @@ function PostThreadLoaded({
return return
} }
// wait for loading to finish // wait for loading to finish
if (thread.type === 'post' && !!thread.parent) { if (thread?.type === 'post' && !!thread.parent) {
function onMeasure(pageY: number) { function onMeasure(pageY: number) {
ref.current?.scrollToOffset({ ref.current?.scrollToOffset({
animated: false, animated: false,
@ -280,10 +262,10 @@ function PostThreadLoaded({
// To work around this, we prepend rows after scroll bumps against the top and rests. // To work around this, we prepend rows after scroll bumps against the top and rests.
const needsBumpMaxParents = React.useRef(false) const needsBumpMaxParents = React.useRef(false)
const onStartReached = React.useCallback(() => { const onStartReached = React.useCallback(() => {
if (maxParents < skeleton.parents.length) { if (skeleton?.parents && maxParents < skeleton.parents.length) {
needsBumpMaxParents.current = true needsBumpMaxParents.current = true
} }
}, [maxParents, skeleton.parents.length]) }, [maxParents, skeleton?.parents])
const bumpMaxParentsIfNeeded = React.useCallback(() => { const bumpMaxParentsIfNeeded = React.useCallback(() => {
if (!isNative) { if (!isNative) {
return return
@ -296,6 +278,11 @@ function PostThreadLoaded({
const onMomentumScrollEnd = bumpMaxParentsIfNeeded const onMomentumScrollEnd = bumpMaxParentsIfNeeded
const onScrollToTop = 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( const renderItem = React.useCallback(
({item, index}: {item: RowItem; index: number}) => { ({item, index}: {item: RowItem; index: number}) => {
if (item === TOP_COMPONENT) { if (item === TOP_COMPONENT) {
@ -326,46 +313,6 @@ function PostThreadLoaded({
</Text> </Text>
</View> </View>
) )
} else if (item === LOAD_MORE) {
return (
<Pressable
onPress={() => setMaxReplies(n => n + 50)}
style={[pal.border, pal.view, styles.itemContainer]}
accessibilityLabel={_(msg`Load more posts`)}
accessibilityHint="">
<View
style={[
pal.viewLight,
{paddingHorizontal: 18, paddingVertical: 14, borderRadius: 6},
]}>
<Text type="lg-medium" style={pal.text}>
<Trans>Load more posts</Trans>
</Text>
</View>
</Pressable>
)
} 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 (
<View
// @ts-ignore web-only
style={{
// Leave enough space below that the scroll doesn't jump
height: isNative ? 600 : '100vh',
borderTopWidth: 1,
borderColor: pal.colors.border,
}}
/>
)
} else if (item === CHILD_SPINNER) {
return (
<View style={[pal.border, styles.childSpinner]}>
<ActivityIndicator />
</View>
)
} else if (isThreadPost(item)) { } else if (isThreadPost(item)) {
const prev = isThreadPost(posts[index - 1]) const prev = isThreadPost(posts[index - 1])
? (posts[index - 1] as ThreadPost) ? (posts[index - 1] as ThreadPost)
@ -374,7 +321,9 @@ function PostThreadLoaded({
? (posts[index - 1] as ThreadPost) ? (posts[index - 1] as ThreadPost)
: undefined : undefined
const hasUnrevealedParents = const hasUnrevealedParents =
index === 0 && maxParents < skeleton.parents.length index === 0 &&
skeleton?.parents &&
maxParents < skeleton.parents.length
return ( return (
<View <View
ref={item.ctx.isHighlightedPost ? highlightedPostRef : undefined} ref={item.ctx.isHighlightedPost ? highlightedPostRef : undefined}
@ -391,9 +340,9 @@ function PostThreadLoaded({
showChildReplyLine={item.ctx.showChildReplyLine} showChildReplyLine={item.ctx.showChildReplyLine}
showParentReplyLine={item.ctx.showParentReplyLine} showParentReplyLine={item.ctx.showParentReplyLine}
hasPrecedingItem={ hasPrecedingItem={
!!prev?.ctx.showChildReplyLine || hasUnrevealedParents !!prev?.ctx.showChildReplyLine || !!hasUnrevealedParents
} }
onPostReply={onRefresh} onPostReply={refetch}
/> />
</View> </View>
) )
@ -403,142 +352,62 @@ function PostThreadLoaded({
[ [
hasSession, hasSession,
isTabletOrMobile, isTabletOrMobile,
_,
isMobile, isMobile,
onPressReply, onPressReply,
pal.border, pal.border,
pal.viewLight, pal.viewLight,
pal.textLight, pal.textLight,
pal.view,
pal.text,
pal.colors.border,
posts, posts,
onRefresh, skeleton?.parents,
maxParents,
deferParents, deferParents,
treeView, treeView,
skeleton.parents.length, refetch,
maxParents,
_,
], ],
) )
return ( return (
<List <>
ref={ref} <ListMaybePlaceholder
data={posts} isLoading={!preferences || !thread}
keyExtractor={item => item._reactKey} isError={!!error}
renderItem={renderItem} onRetry={refetch}
onContentSizeChange={isNative ? undefined : onContentSizeChangeWeb} errorTitle={error?.title}
onStartReached={onStartReached} errorMessage={error?.message}
onMomentumScrollEnd={onMomentumScrollEnd} />
onScrollToTop={onScrollToTop} {!error && thread && (
maintainVisibleContentPosition={ <List
isNative ? MAINTAIN_VISIBLE_CONTENT_POSITION : undefined ref={ref}
} data={posts}
style={s.hContentRegion} renderItem={renderItem}
// @ts-ignore our .web version only -prf keyExtractor={keyExtractor}
desktopFixedHeight onContentSizeChange={isNative ? undefined : onContentSizeChangeWeb}
removeClippedSubviews={isAndroid ? false : undefined} onStartReached={onStartReached}
windowSize={11} onEndReached={onEndReached}
/> onEndReachedThreshold={2}
) onMomentumScrollEnd={onMomentumScrollEnd}
} onScrollToTop={onScrollToTop}
maintainVisibleContentPosition={
function PostThreadBlocked() { isNative ? MAINTAIN_VISIBLE_CONTENT_POSITION : undefined
const {_} = useLingui() }
const pal = usePalette('default') // @ts-ignore our .web version only -prf
const navigation = useNavigation<NavigationProp>() desktopFixedHeight
removeClippedSubviews={isAndroid ? false : undefined}
const onPressBack = React.useCallback(() => { ListFooterComponent={
if (navigation.canGoBack()) { <ListFooter
navigation.goBack() isFetching={isFetching}
} else { onRetry={refetch}
navigation.navigate('Home') // 300 is based on the minimum height of a post. This is enough extra height for the `maintainVisPos` to
} // work without causing weird jumps on web or glitches on native
}, [navigation]) height={windowHeight - 200}
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}
/> />
<Trans context="action">Back</Trans> }
</Text> initialNumToRender={initialNumToRender}
</TouchableOpacity> windowSize={11}
</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]}>
<Trans>Post not found</Trans>
</Text>
<Text type="md" style={[pal.text, s.mb10]}>
<Trans>The post may have been deleted.</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}
/>
<Trans>Back</Trans>
</Text>
</TouchableOpacity>
</View>
</CenteredView>
)
}
return (
<CenteredView>
<ErrorMessage message={cleanError(error)} onPressTryAgain={onRefresh} />
</CenteredView>
) )
} }
@ -558,7 +427,9 @@ function createThreadSkeleton(
node: ThreadNode, node: ThreadNode,
hasSession: boolean, hasSession: boolean,
treeView: boolean, treeView: boolean,
): ThreadSkeletonParts { ): ThreadSkeletonParts | null {
if (!node) return null
return { return {
parents: Array.from(flattenThreadParents(node, hasSession)), parents: Array.from(flattenThreadParents(node, hasSession)),
highlightedPost: node, highlightedPost: node,
@ -615,7 +486,10 @@ function hasPwiOptOut(node: ThreadPost) {
return !!node.post.author.labels?.find(l => l.val === '!no-unauthenticated') 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') { if (node.type !== 'post') {
return false return false
} }
@ -629,20 +503,9 @@ function hasBranchingReplies(node: ThreadNode) {
} }
const styles = StyleSheet.create({ const styles = StyleSheet.create({
notFoundContainer: {
margin: 10,
paddingHorizontal: 18,
paddingVertical: 14,
borderRadius: 6,
},
itemContainer: { itemContainer: {
borderTopWidth: 1, borderTopWidth: 1,
paddingHorizontal: 18, paddingHorizontal: 18,
paddingVertical: 18, paddingVertical: 18,
}, },
childSpinner: {
borderTopWidth: 1,
paddingTop: 40,
paddingBottom: 200,
},
}) })