Virtualise labeler list (#8566)

* use flatlist for labeler list

* use key extractor

* dedupe `labelValues`

* add comment
This commit is contained in:
Samuel Newman
2025-06-26 09:39:34 +03:00
committed by GitHub
parent 954e7d2a89
commit e5f9377a69
2 changed files with 178 additions and 169 deletions
+130 -121
View File
@@ -1,7 +1,5 @@
import React from 'react' import {useCallback, useEffect, useImperativeHandle, useMemo} from 'react'
import {findNodeHandle, View} from 'react-native' import {findNodeHandle, type ListRenderItemInfo, View} from 'react-native'
import type Animated from 'react-native-reanimated'
import {useSafeAreaFrame} from 'react-native-safe-area-context'
import { import {
type AppBskyLabelerDefs, type AppBskyLabelerDefs,
type InterpretedLabelValueDefinition, type InterpretedLabelValueDefinition,
@@ -11,15 +9,13 @@ import {
import {msg, Trans} from '@lingui/macro' import {msg, Trans} from '@lingui/macro'
import {useLingui} from '@lingui/react' import {useLingui} from '@lingui/react'
import {useAnimatedScrollHandler} from '#/lib/hooks/useAnimatedScrollHandler_FIXED'
import {isLabelerSubscribed, lookupLabelValueDefinition} from '#/lib/moderation' import {isLabelerSubscribed, lookupLabelValueDefinition} from '#/lib/moderation'
import {useScrollHandlers} from '#/lib/ScrollContext'
import {isIOS, isNative} from '#/platform/detection' import {isIOS, isNative} from '#/platform/detection'
import {type ListRef} from '#/view/com/util/List' import {List, type ListRef} from '#/view/com/util/List'
import {atoms as a, useTheme} from '#/alf' import {atoms as a, ios, tokens, useTheme} from '#/alf'
import {Divider} from '#/components/Divider' import {Divider} from '#/components/Divider'
import {CircleInfo_Stroke2_Corner0_Rounded as CircleInfo} from '#/components/icons/CircleInfo' import {CircleInfo_Stroke2_Corner0_Rounded as CircleInfo} from '#/components/icons/CircleInfo'
import * as Layout from '#/components/Layout' import {ListFooter} from '#/components/Lists'
import {Loader} from '#/components/Loader' import {Loader} from '#/components/Loader'
import {LabelerLabelPreference} from '#/components/moderation/LabelPreference' import {LabelerLabelPreference} from '#/components/moderation/LabelPreference'
import {Text} from '#/components/Typography' import {Text} from '#/components/Typography'
@@ -27,6 +23,7 @@ import {ErrorState} from '../ErrorState'
import {type SectionRef} from './types' import {type SectionRef} from './types'
interface LabelsSectionProps { interface LabelsSectionProps {
ref: React.Ref<SectionRef>
isLabelerLoading: boolean isLabelerLoading: boolean
labelerInfo: AppBskyLabelerDefs.LabelerViewDetailed | undefined labelerInfo: AppBskyLabelerDefs.LabelerViewDetailed | undefined
labelerError: Error | null labelerError: Error | null
@@ -36,11 +33,8 @@ interface LabelsSectionProps {
isFocused: boolean isFocused: boolean
setScrollViewTag: (tag: number | null) => void setScrollViewTag: (tag: number | null) => void
} }
export const ProfileLabelsSection = React.forwardRef< export function ProfileLabelsSection({
SectionRef, ref,
LabelsSectionProps
>(function LabelsSectionImpl(
{
isLabelerLoading, isLabelerLoading,
labelerInfo, labelerInfo,
labelerError, labelerError,
@@ -49,37 +43,11 @@ export const ProfileLabelsSection = React.forwardRef<
headerHeight, headerHeight,
isFocused, isFocused,
setScrollViewTag, setScrollViewTag,
}, }: LabelsSectionProps) {
ref, const t = useTheme()
) {
const {_} = useLingui()
const {height: minHeight} = useSafeAreaFrame()
// Intentionally destructured outside the main thread closure. const onScrollToTop = useCallback(() => {
// See https://github.com/bluesky-social/social-app/pull/4108. // @ts-expect-error TODO fix this
const {
onBeginDrag: onBeginDragFromContext,
onEndDrag: onEndDragFromContext,
onScroll: onScrollFromContext,
onMomentumEnd: onMomentumEndFromContext,
} = useScrollHandlers()
const scrollHandler = useAnimatedScrollHandler({
onBeginDrag(e, ctx) {
onBeginDragFromContext?.(e, ctx)
},
onEndDrag(e, ctx) {
onEndDragFromContext?.(e, ctx)
},
onScroll(e, ctx) {
onScrollFromContext?.(e, ctx)
},
onMomentumEnd(e, ctx) {
onMomentumEndFromContext?.(e, ctx)
},
})
const onScrollToTop = React.useCallback(() => {
// @ts-ignore TODO fix this
scrollElRef.current?.scrollTo({ scrollElRef.current?.scrollTo({
animated: isNative, animated: isNative,
x: 0, x: 0,
@@ -87,33 +55,128 @@ export const ProfileLabelsSection = React.forwardRef<
}) })
}, [scrollElRef, headerHeight]) }, [scrollElRef, headerHeight])
React.useImperativeHandle(ref, () => ({ useImperativeHandle(ref, () => ({
scrollToTop: onScrollToTop, scrollToTop: onScrollToTop,
})) }))
React.useEffect(() => { useEffect(() => {
if (isIOS && isFocused && scrollElRef.current) { if (isIOS && isFocused && scrollElRef.current) {
const nativeTag = findNodeHandle(scrollElRef.current) const nativeTag = findNodeHandle(scrollElRef.current)
setScrollViewTag(nativeTag) setScrollViewTag(nativeTag)
} }
}, [isFocused, scrollElRef, setScrollViewTag]) }, [isFocused, scrollElRef, setScrollViewTag])
const isSubscribed = labelerInfo
? !!isLabelerSubscribed(labelerInfo, moderationOpts)
: false
const labelValues = useMemo(() => {
if (isLabelerLoading || !labelerInfo || labelerError) return []
const customDefs = interpretLabelValueDefinitions(labelerInfo)
return labelerInfo.policies.labelValues
.filter((val, i, arr) => arr.indexOf(val) === i) // dedupe
.map(val => lookupLabelValueDefinition(val, customDefs))
.filter(
def => def && def?.configurable,
) as InterpretedLabelValueDefinition[]
}, [labelerInfo, labelerError, isLabelerLoading])
const numItems = labelValues.length
const renderItem = useCallback(
({item, index}: ListRenderItemInfo<InterpretedLabelValueDefinition>) => {
if (!labelerInfo) return null
return (
<View
style={[
t.atoms.bg_contrast_25,
index === 0 && [
a.overflow_hidden,
{
borderTopLeftRadius: tokens.borderRadius.md,
borderTopRightRadius: tokens.borderRadius.md,
},
],
index === numItems - 1 && [
a.overflow_hidden,
{
borderBottomLeftRadius: tokens.borderRadius.md,
borderBottomRightRadius: tokens.borderRadius.md,
},
],
]}>
{index !== 0 && <Divider />}
<LabelerLabelPreference
disabled={isSubscribed ? undefined : true}
labelDefinition={item}
labelerDid={labelerInfo.creator.did}
/>
</View>
)
},
[labelerInfo, isSubscribed, numItems, t],
)
return (
<View>
<List
ref={scrollElRef}
data={labelValues}
renderItem={renderItem}
keyExtractor={keyExtractor}
contentContainerStyle={a.px_xl}
headerOffset={headerHeight}
progressViewOffset={ios(0)}
ListHeaderComponent={
<LabelerListHeader
isLabelerLoading={isLabelerLoading}
labelerInfo={labelerInfo}
labelerError={labelerError}
hasValues={labelValues.length !== 0}
isSubscribed={isSubscribed}
/>
}
ListFooterComponent={
<ListFooter
height={headerHeight + 180}
style={a.border_transparent}
/>
}
/>
</View>
)
}
function keyExtractor(item: InterpretedLabelValueDefinition) {
return item.identifier
}
export function LabelerListHeader({
isLabelerLoading,
labelerError,
labelerInfo,
hasValues,
isSubscribed,
}: {
isLabelerLoading: boolean
labelerError?: Error | null
labelerInfo?: AppBskyLabelerDefs.LabelerViewDetailed
hasValues: boolean
isSubscribed: boolean
}) {
const t = useTheme()
const {_} = useLingui()
if (isLabelerLoading) {
return ( 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]}> <View style={[a.w_full, a.align_center, a.py_4xl]}>
<Loader size="xl" /> <Loader size="xl" />
</View> </View>
) : labelerError || !labelerInfo ? ( )
}
if (labelerError || !labelerInfo) {
return (
<View style={[a.w_full, a.align_center, a.py_4xl]}> <View style={[a.w_full, a.align_center, a.py_4xl]}>
<ErrorState <ErrorState
error={ error={
@@ -122,59 +185,29 @@ export const ProfileLabelsSection = React.forwardRef<
} }
/> />
</View> </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(() => {
const customDefs = interpretLabelValueDefinitions(labelerInfo)
return labelValues
.map(val => lookupLabelValueDefinition(val, customDefs))
.filter(
def => def && def?.configurable,
) as InterpretedLabelValueDefinition[]
}, [labelerInfo, labelValues])
return ( return (
<View style={[a.pt_xl, a.px_lg, a.border_t, t.atoms.border_contrast_low]}> <View style={[a.py_xl]}>
<View>
<Text style={[t.atoms.text_contrast_high, a.leading_snug, a.text_sm]}> <Text style={[t.atoms.text_contrast_high, a.leading_snug, a.text_sm]}>
<Trans> <Trans>
Labels are annotations on users and content. They can be used to Labels are annotations on users and content. They can be used to hide,
hide, warn, and categorize the network. warn, and categorize the network.
</Trans> </Trans>
</Text> </Text>
{labelerInfo.creator.viewer?.blocking ? ( {labelerInfo?.creator.viewer?.blocking ? (
<View style={[a.flex_row, a.gap_sm, a.align_center, a.mt_md]}> <View style={[a.flex_row, a.gap_sm, a.align_center, a.mt_md]}>
<CircleInfo size="sm" fill={t.atoms.text_contrast_medium.color} /> <CircleInfo size="sm" fill={t.atoms.text_contrast_medium.color} />
<Text <Text style={[t.atoms.text_contrast_high, a.leading_snug, a.text_sm]}>
style={[t.atoms.text_contrast_high, a.leading_snug, a.text_sm]}>
<Trans> <Trans>
Blocking does not prevent this labeler from placing labels on Blocking does not prevent this labeler from placing labels on your
your account. account.
</Trans> </Trans>
</Text> </Text>
</View> </View>
) : null} ) : null}
{labelValues.length === 0 ? ( {!hasValues ? (
<Text <Text
style={[ style={[
a.pt_xl, a.pt_xl,
@@ -201,29 +234,5 @@ export function ProfileLabelsSectionInner({
</Text> </Text>
) : null} ) : null}
</View> </View>
{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>
) )
} }
+2 -2
View File
@@ -1,4 +1,4 @@
import {AppBskyLabelerDefs} from '@atproto/api' import {type AppBskyLabelerDefs} from '@atproto/api'
import {useMutation, useQuery, useQueryClient} from '@tanstack/react-query' import {useMutation, useQuery, useQueryClient} from '@tanstack/react-query'
import {z} from 'zod' import {z} from 'zod'
@@ -41,7 +41,7 @@ export function useLabelerInfoQuery({
queryKey: labelerInfoQueryKey(did as string), queryKey: labelerInfoQueryKey(did as string),
queryFn: async () => { queryFn: async () => {
const res = await agent.app.bsky.labeler.getServices({ const res = await agent.app.bsky.labeler.getServices({
dids: [did as string], dids: [did!],
detailed: true, detailed: true,
}) })
return res.data.views[0] as AppBskyLabelerDefs.LabelerViewDetailed return res.data.views[0] as AppBskyLabelerDefs.LabelerViewDetailed