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 {findNodeHandle, View} from 'react-native'
import type Animated from 'react-native-reanimated'
import {useSafeAreaFrame} from 'react-native-safe-area-context'
import {useCallback, useEffect, useImperativeHandle, useMemo} from 'react'
import {findNodeHandle, type ListRenderItemInfo, View} from 'react-native'
import {
type AppBskyLabelerDefs,
type InterpretedLabelValueDefinition,
@@ -11,15 +9,13 @@ import {
import {msg, Trans} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {useAnimatedScrollHandler} from '#/lib/hooks/useAnimatedScrollHandler_FIXED'
import {isLabelerSubscribed, lookupLabelValueDefinition} from '#/lib/moderation'
import {useScrollHandlers} from '#/lib/ScrollContext'
import {isIOS, isNative} from '#/platform/detection'
import {type ListRef} from '#/view/com/util/List'
import {atoms as a, useTheme} from '#/alf'
import {List, type ListRef} from '#/view/com/util/List'
import {atoms as a, ios, tokens, useTheme} from '#/alf'
import {Divider} from '#/components/Divider'
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 {LabelerLabelPreference} from '#/components/moderation/LabelPreference'
import {Text} from '#/components/Typography'
@@ -27,6 +23,7 @@ import {ErrorState} from '../ErrorState'
import {type SectionRef} from './types'
interface LabelsSectionProps {
ref: React.Ref<SectionRef>
isLabelerLoading: boolean
labelerInfo: AppBskyLabelerDefs.LabelerViewDetailed | undefined
labelerError: Error | null
@@ -36,11 +33,8 @@ interface LabelsSectionProps {
isFocused: boolean
setScrollViewTag: (tag: number | null) => void
}
export const ProfileLabelsSection = React.forwardRef<
SectionRef,
LabelsSectionProps
>(function LabelsSectionImpl(
{
export function ProfileLabelsSection({
ref,
isLabelerLoading,
labelerInfo,
labelerError,
@@ -49,37 +43,11 @@ export const ProfileLabelsSection = React.forwardRef<
headerHeight,
isFocused,
setScrollViewTag,
},
ref,
) {
const {_} = useLingui()
const {height: minHeight} = useSafeAreaFrame()
}: LabelsSectionProps) {
const t = useTheme()
// Intentionally destructured outside the main thread closure.
// See https://github.com/bluesky-social/social-app/pull/4108.
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
const onScrollToTop = useCallback(() => {
// @ts-expect-error TODO fix this
scrollElRef.current?.scrollTo({
animated: isNative,
x: 0,
@@ -87,33 +55,128 @@ export const ProfileLabelsSection = React.forwardRef<
})
}, [scrollElRef, headerHeight])
React.useImperativeHandle(ref, () => ({
useImperativeHandle(ref, () => ({
scrollToTop: onScrollToTop,
}))
React.useEffect(() => {
useEffect(() => {
if (isIOS && isFocused && scrollElRef.current) {
const nativeTag = findNodeHandle(scrollElRef.current)
setScrollViewTag(nativeTag)
}
}, [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 (
<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 ? (
)
}
if (labelerError || !labelerInfo) {
return (
<View style={[a.w_full, a.align_center, a.py_4xl]}>
<ErrorState
error={
@@ -122,59 +185,29 @@ export const ProfileLabelsSection = React.forwardRef<
}
/>
</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 (
<View style={[a.pt_xl, a.px_lg, a.border_t, t.atoms.border_contrast_low]}>
<View>
<View style={[a.py_xl]}>
<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.
Labels are annotations on users and content. They can be used to hide,
warn, and categorize the network.
</Trans>
</Text>
{labelerInfo.creator.viewer?.blocking ? (
{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]}>
<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.
Blocking does not prevent this labeler from placing labels on your
account.
</Trans>
</Text>
</View>
) : null}
{labelValues.length === 0 ? (
{!hasValues ? (
<Text
style={[
a.pt_xl,
@@ -201,29 +234,5 @@ export function ProfileLabelsSectionInner({
</Text>
) : null}
</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 {z} from 'zod'
@@ -41,7 +41,7 @@ export function useLabelerInfoQuery({
queryKey: labelerInfoQueryKey(did as string),
queryFn: async () => {
const res = await agent.app.bsky.labeler.getServices({
dids: [did as string],
dids: [did!],
detailed: true,
})
return res.data.views[0] as AppBskyLabelerDefs.LabelerViewDetailed