Fix labeler header scroll and loading/error states (#8088)

* add forwardRef to Layout.Content

* lift scrollview up out of inner component

* fix scrolling on android (#8188)
This commit is contained in:
Samuel Newman
2025-04-14 19:00:39 +03:00
committed by GitHub
parent 238b00d193
commit f8bd850465
3 changed files with 196 additions and 194 deletions
+57 -51
View File
@@ -1,12 +1,12 @@
import React, {useContext, useMemo} from 'react'
import {StyleSheet, View, ViewProps, ViewStyle} from 'react-native'
import {StyleProp} from 'react-native'
import {forwardRef, memo, useContext, useMemo} from 'react'
import {StyleSheet, View, type ViewProps, type ViewStyle} from 'react-native'
import {type StyleProp} from 'react-native'
import {
KeyboardAwareScrollView,
KeyboardAwareScrollViewProps,
type KeyboardAwareScrollViewProps,
} from 'react-native-keyboard-controller'
import Animated, {
AnimatedScrollViewProps,
type AnimatedScrollViewProps,
useAnimatedProps,
} from 'react-native-reanimated'
import {useSafeAreaInsets} from 'react-native-safe-area-context'
@@ -35,7 +35,7 @@ export type ScreenProps = React.ComponentProps<typeof View> & {
/**
* Outermost component of every screen
*/
export const Screen = React.memo(function Screen({
export const Screen = memo(function Screen({
style,
noInsetTop,
...props
@@ -61,49 +61,55 @@ export type ContentProps = AnimatedScrollViewProps & {
/**
* Default scroll view for simple pages
*/
export const Content = React.memo(function Content({
children,
style,
contentContainerStyle,
ignoreTabletLayoutOffset,
...props
}: ContentProps) {
const t = useTheme()
const {footerHeight} = useShellLayout()
const animatedProps = useAnimatedProps(() => {
return {
scrollIndicatorInsets: {
bottom: footerHeight.get(),
top: 0,
right: 1,
},
} satisfies AnimatedScrollViewProps
})
export const Content = memo(
forwardRef<Animated.ScrollView, ContentProps>(function Content(
{
children,
style,
contentContainerStyle,
ignoreTabletLayoutOffset,
...props
},
ref,
) {
const t = useTheme()
const {footerHeight} = useShellLayout()
const animatedProps = useAnimatedProps(() => {
return {
scrollIndicatorInsets: {
bottom: footerHeight.get(),
top: 0,
right: 1,
},
} satisfies AnimatedScrollViewProps
})
return (
<Animated.ScrollView
id="content"
automaticallyAdjustsScrollIndicatorInsets={false}
indicatorStyle={t.scheme === 'dark' ? 'white' : 'black'}
// sets the scroll inset to the height of the footer
animatedProps={animatedProps}
style={[scrollViewStyles.common, style]}
contentContainerStyle={[
scrollViewStyles.contentContainer,
contentContainerStyle,
]}
{...props}>
{isWeb ? (
<Center ignoreTabletLayoutOffset={ignoreTabletLayoutOffset}>
{/* @ts-expect-error web only -esb */}
{children}
</Center>
) : (
children
)}
</Animated.ScrollView>
)
})
return (
<Animated.ScrollView
ref={ref}
id="content"
automaticallyAdjustsScrollIndicatorInsets={false}
indicatorStyle={t.scheme === 'dark' ? 'white' : 'black'}
// sets the scroll inset to the height of the footer
animatedProps={animatedProps}
style={[scrollViewStyles.common, style]}
contentContainerStyle={[
scrollViewStyles.contentContainer,
contentContainerStyle,
]}
{...props}>
{isWeb ? (
<Center ignoreTabletLayoutOffset={ignoreTabletLayoutOffset}>
{/* @ts-expect-error web only -esb */}
{children}
</Center>
) : (
children
)}
</Animated.ScrollView>
)
}),
)
const scrollViewStyles = StyleSheet.create({
common: {
@@ -124,7 +130,7 @@ export type KeyboardAwareContentProps = KeyboardAwareScrollViewProps & {
*
* BE SURE TO TEST THIS WHEN USING, it's untested as of writing this comment.
*/
export const KeyboardAwareContent = React.memo(function LayoutScrollView({
export const KeyboardAwareContent = memo(function LayoutKeyboardAwareContent({
children,
style,
contentContainerStyle,
@@ -147,7 +153,7 @@ export const KeyboardAwareContent = React.memo(function LayoutScrollView({
/**
* Utility component to center content within the screen
*/
export const Center = React.memo(function LayoutContent({
export const Center = memo(function LayoutCenter({
children,
style,
ignoreTabletLayoutOffset,
@@ -192,7 +198,7 @@ export const Center = React.memo(function LayoutContent({
/**
* Only used within `Layout.Screen`, not for reuse
*/
const WebCenterBorders = React.memo(function LayoutContent() {
const WebCenterBorders = memo(function LayoutWebCenterBorders() {
const t = useTheme()
const {gtMobile} = useBreakpoints()
const {centerColumnOffset} = useLayoutBreakpoints()
+4 -3
View File
@@ -7,16 +7,16 @@ import {useQueryClient} from '@tanstack/react-query'
import {useInitialNumToRender} from '#/lib/hooks/useInitialNumToRender'
import {usePalette} from '#/lib/hooks/usePalette'
import {isNative} from '#/platform/detection'
import {FeedDescriptor} from '#/state/queries/post-feed'
import {type FeedDescriptor} from '#/state/queries/post-feed'
import {RQKEY as FEED_RQKEY} from '#/state/queries/post-feed'
import {truncateAndInvalidate} from '#/state/queries/util'
import {PostFeed} from '#/view/com/posts/PostFeed'
import {EmptyState} from '#/view/com/util/EmptyState'
import {ListRef} from '#/view/com/util/List'
import {type ListRef} from '#/view/com/util/List'
import {LoadLatestBtn} from '#/view/com/util/load-latest/LoadLatestBtn'
import {Text} from '#/view/com/util/text/Text'
import {ios} from '#/alf'
import {SectionRef} from './types'
import {type SectionRef} from './types'
interface FeedSectionProps {
feed: FeedDescriptor
@@ -58,6 +58,7 @@ export const ProfileFeedSection = React.forwardRef<
truncateAndInvalidate(queryClient, FEED_RQKEY(feed))
setHasNew(false)
}, [scrollElRef, headerHeight, queryClient, feed, setHasNew])
React.useImperativeHandle(ref, () => ({
scrollToTop: onScrollToTop,
}))
+135 -140
View File
@@ -1,11 +1,12 @@
import React from 'react'
import {findNodeHandle, View} from 'react-native'
import type Animated from 'react-native-reanimated'
import {useSafeAreaFrame} from 'react-native-safe-area-context'
import {
AppBskyLabelerDefs,
InterpretedLabelValueDefinition,
type AppBskyLabelerDefs,
type InterpretedLabelValueDefinition,
interpretLabelValueDefinitions,
ModerationOpts,
type ModerationOpts,
} from '@atproto/api'
import {msg, Trans} from '@lingui/macro'
import {useLingui} from '@lingui/react'
@@ -14,7 +15,7 @@ import {useAnimatedScrollHandler} from '#/lib/hooks/useAnimatedScrollHandler_FIX
import {isLabelerSubscribed, lookupLabelValueDefinition} from '#/lib/moderation'
import {useScrollHandlers} from '#/lib/ScrollContext'
import {isNative} from '#/platform/detection'
import {ListRef} from '#/view/com/util/List'
import {type ListRef} from '#/view/com/util/List'
import {atoms as a, useTheme} from '#/alf'
import {Divider} from '#/components/Divider'
import {CircleInfo_Stroke2_Corner0_Rounded as CircleInfo} from '#/components/icons/CircleInfo'
@@ -23,7 +24,7 @@ import {Loader} from '#/components/Loader'
import {LabelerLabelPreference} from '#/components/moderation/LabelPreference'
import {Text} from '#/components/Typography'
import {ErrorState} from '../ErrorState'
import {SectionRef} from './types'
import {type SectionRef} from './types'
interface LabelsSectionProps {
isLabelerLoading: boolean
@@ -54,64 +55,6 @@ export const ProfileLabelsSection = React.forwardRef<
const {_} = useLingui()
const {height: minHeight} = useSafeAreaFrame()
const onScrollToTop = React.useCallback(() => {
// @ts-ignore TODO fix this
scrollElRef.current?.scrollTo({
animated: isNative,
x: 0,
y: -headerHeight,
})
}, [scrollElRef, headerHeight])
React.useImperativeHandle(ref, () => ({
scrollToTop: onScrollToTop,
}))
React.useEffect(() => {
if (isFocused && scrollElRef.current) {
const nativeTag = findNodeHandle(scrollElRef.current)
setScrollViewTag(nativeTag)
}
}, [isFocused, scrollElRef, setScrollViewTag])
return (
<Layout.Center style={{flex: 1, minHeight}}>
{isLabelerLoading ? (
<View style={[a.w_full, a.align_center]}>
<Loader size="xl" />
</View>
) : labelerError || !labelerInfo ? (
<ErrorState
error={
labelerError?.toString() ||
_(msg`Something went wrong, please try again.`)
}
/>
) : (
<ProfileLabelsSectionInner
moderationOpts={moderationOpts}
labelerInfo={labelerInfo}
scrollElRef={scrollElRef}
headerHeight={headerHeight}
/>
)}
</Layout.Center>
)
})
export function ProfileLabelsSectionInner({
moderationOpts,
labelerInfo,
scrollElRef,
headerHeight,
}: {
moderationOpts: ModerationOpts
labelerInfo: AppBskyLabelerDefs.LabelerViewDetailed
scrollElRef: ListRef
headerHeight: number
}) {
const t = useTheme()
// Intentionally destructured outside the main thread closure.
// See https://github.com/bluesky-social/social-app/pull/4108.
const {
@@ -135,6 +78,70 @@ export function ProfileLabelsSectionInner({
},
})
const onScrollToTop = React.useCallback(() => {
// @ts-ignore TODO fix this
scrollElRef.current?.scrollTo({
animated: isNative,
x: 0,
y: -headerHeight,
})
}, [scrollElRef, headerHeight])
React.useImperativeHandle(ref, () => ({
scrollToTop: onScrollToTop,
}))
React.useEffect(() => {
if (isFocused && scrollElRef.current) {
const nativeTag = findNodeHandle(scrollElRef.current)
setScrollViewTag(nativeTag)
}
}, [isFocused, scrollElRef, setScrollViewTag])
return (
<Layout.Center style={{minHeight}}>
<Layout.Content
ref={scrollElRef as React.Ref<Animated.ScrollView>}
scrollEventThrottle={1}
contentContainerStyle={{
paddingTop: headerHeight,
borderWidth: 0,
}}
contentOffset={{x: 0, y: headerHeight * -1}}
onScroll={scrollHandler}>
{isLabelerLoading ? (
<View style={[a.w_full, a.align_center, a.py_4xl]}>
<Loader size="xl" />
</View>
) : labelerError || !labelerInfo ? (
<View style={[a.w_full, a.align_center, a.py_4xl]}>
<ErrorState
error={
labelerError?.toString() ||
_(msg`Something went wrong, please try again.`)
}
/>
</View>
) : (
<ProfileLabelsSectionInner
moderationOpts={moderationOpts}
labelerInfo={labelerInfo}
/>
)}
</Layout.Content>
</Layout.Center>
)
})
export function ProfileLabelsSectionInner({
moderationOpts,
labelerInfo,
}: {
moderationOpts: ModerationOpts
labelerInfo: AppBskyLabelerDefs.LabelerViewDetailed
}) {
const t = useTheme()
const {labelValues} = labelerInfo.policies
const isSubscribed = isLabelerSubscribed(labelerInfo, moderationOpts)
const labelDefs = React.useMemo(() => {
@@ -147,88 +154,76 @@ export function ProfileLabelsSectionInner({
}, [labelerInfo, labelValues])
return (
<Layout.Content
// @ts-expect-error TODO fix this
ref={scrollElRef}
scrollEventThrottle={1}
contentContainerStyle={{
paddingTop: headerHeight,
borderWidth: 0,
}}
contentOffset={{x: 0, y: headerHeight * -1}}
onScroll={scrollHandler}>
<View style={[a.pt_xl, a.px_lg, a.border_t, t.atoms.border_contrast_low]}>
<View>
<Text style={[t.atoms.text_contrast_high, a.leading_snug, a.text_sm]}>
<View style={[a.pt_xl, a.px_lg, a.border_t, t.atoms.border_contrast_low]}>
<View>
<Text style={[t.atoms.text_contrast_high, a.leading_snug, a.text_sm]}>
<Trans>
Labels are annotations on users and content. They can be used to
hide, warn, and categorize the network.
</Trans>
</Text>
{labelerInfo.creator.viewer?.blocking ? (
<View style={[a.flex_row, a.gap_sm, a.align_center, a.mt_md]}>
<CircleInfo size="sm" fill={t.atoms.text_contrast_medium.color} />
<Text
style={[t.atoms.text_contrast_high, a.leading_snug, a.text_sm]}>
<Trans>
Blocking does not prevent this labeler from placing labels on
your account.
</Trans>
</Text>
</View>
) : null}
{labelValues.length === 0 ? (
<Text
style={[
a.pt_xl,
t.atoms.text_contrast_high,
a.leading_snug,
a.text_sm,
]}>
<Trans>
Labels are annotations on users and content. They can be used to
hide, warn, and categorize the network.
This labeler hasn't declared what labels it publishes, and may not
be active.
</Trans>
</Text>
{labelerInfo.creator.viewer?.blocking ? (
<View style={[a.flex_row, a.gap_sm, a.align_center, a.mt_md]}>
<CircleInfo size="sm" fill={t.atoms.text_contrast_medium.color} />
<Text
style={[t.atoms.text_contrast_high, a.leading_snug, a.text_sm]}>
<Trans>
Blocking does not prevent this labeler from placing labels on
your account.
</Trans>
</Text>
</View>
) : null}
{labelValues.length === 0 ? (
<Text
style={[
a.pt_xl,
t.atoms.text_contrast_high,
a.leading_snug,
a.text_sm,
]}>
<Trans>
This labeler hasn't declared what labels it publishes, and may
not be active.
</Trans>
</Text>
) : !isSubscribed ? (
<Text
style={[
a.pt_xl,
t.atoms.text_contrast_high,
a.leading_snug,
a.text_sm,
]}>
<Trans>
Subscribe to @{labelerInfo.creator.handle} to use these labels:
</Trans>
</Text>
) : null}
</View>
{labelDefs.length > 0 && (
<View
) : !isSubscribed ? (
<Text
style={[
a.mt_xl,
a.w_full,
a.rounded_md,
a.overflow_hidden,
t.atoms.bg_contrast_25,
a.pt_xl,
t.atoms.text_contrast_high,
a.leading_snug,
a.text_sm,
]}>
{labelDefs.map((labelDef, i) => {
return (
<React.Fragment key={labelDef.identifier}>
{i !== 0 && <Divider />}
<LabelerLabelPreference
disabled={isSubscribed ? undefined : true}
labelDefinition={labelDef}
labelerDid={labelerInfo.creator.did}
/>
</React.Fragment>
)
})}
</View>
)}
<View style={{height: 100}} />
<Trans>
Subscribe to @{labelerInfo.creator.handle} to use these labels:
</Trans>
</Text>
) : null}
</View>
</Layout.Content>
{labelDefs.length > 0 && (
<View
style={[
a.mt_xl,
a.w_full,
a.rounded_md,
a.overflow_hidden,
t.atoms.bg_contrast_25,
]}>
{labelDefs.map((labelDef, i) => {
return (
<React.Fragment key={labelDef.identifier}>
{i !== 0 && <Divider />}
<LabelerLabelPreference
disabled={isSubscribed ? undefined : true}
labelDefinition={labelDef}
labelerDid={labelerInfo.creator.did}
/>
</React.Fragment>
)
})}
</View>
)}
</View>
)
}