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
+176 -167
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,50 +33,21 @@ interface LabelsSectionProps {
isFocused: boolean
setScrollViewTag: (tag: number | null) => void
}
export const ProfileLabelsSection = React.forwardRef<
SectionRef,
LabelsSectionProps
>(function LabelsSectionImpl(
{
isLabelerLoading,
labelerInfo,
labelerError,
moderationOpts,
scrollElRef,
headerHeight,
isFocused,
setScrollViewTag,
},
export function ProfileLabelsSection({
ref,
) {
const {_} = useLingui()
const {height: minHeight} = useSafeAreaFrame()
isLabelerLoading,
labelerInfo,
labelerError,
moderationOpts,
scrollElRef,
headerHeight,
isFocused,
setScrollViewTag,
}: 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,143 +55,184 @@ 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])
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>
)
})
const isSubscribed = labelerInfo
? !!isLabelerSubscribed(labelerInfo, moderationOpts)
: false
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 labelValues = useMemo(() => {
if (isLabelerLoading || !labelerInfo || labelerError) return []
const customDefs = interpretLabelValueDefinitions(labelerInfo)
return labelValues
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, labelValues])
}, [labelerInfo, labelerError, isLabelerLoading])
return (
<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>
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 && (
const numItems = labelValues.length
const renderItem = useCallback(
({item, index}: ListRenderItemInfo<InterpretedLabelValueDefinition>) => {
if (!labelerInfo) return null
return (
<View
style={[
a.mt_xl,
a.w_full,
a.rounded_md,
a.overflow_hidden,
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,
},
],
]}>
{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>
)
})}
{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 (
<View style={[a.w_full, a.align_center, a.py_4xl]}>
<Loader size="xl" />
</View>
)
}
if (labelerError || !labelerInfo) {
return (
<View style={[a.w_full, a.align_center, a.py_4xl]}>
<ErrorState
error={
labelerError?.toString() ||
_(msg`Something went wrong, please try again.`)
}
/>
</View>
)
}
return (
<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.
</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}
{!hasValues ? (
<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>
)
}
+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