`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
parent
07e6d001a2
commit
addd66b37f
|
@ -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>
|
||||||
|
)
|
||||||
|
}
|
|
@ -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>
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
|
@ -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>
|
||||||
|
|
|
@ -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,
|
|
||||||
},
|
|
||||||
})
|
})
|
||||||
|
|
Loading…
Reference in New Issue