diff options
author | Samuel Newman <mozzius@protonmail.com> | 2025-06-26 09:39:34 +0300 |
---|---|---|
committer | GitHub <noreply@github.com> | 2025-06-25 23:39:34 -0700 |
commit | e5f9377a691ef899e755d9611eafa49cbce8ec46 (patch) | |
tree | 237714f33ac33eee44dc6acd24b11d1127d76025 | |
parent | 954e7d2a89ac3e95abda3667ac707156afbdfbf0 (diff) | |
download | voidsky-e5f9377a691ef899e755d9611eafa49cbce8ec46.tar.zst |
Virtualise labeler list (#8566)
* use flatlist for labeler list * use key extractor * dedupe `labelValues` * add comment
-rw-r--r-- | src/screens/Profile/Sections/Labels.tsx | 333 | ||||
-rw-r--r-- | src/state/queries/labeler.ts | 4 |
2 files changed, 173 insertions, 164 deletions
diff --git a/src/screens/Profile/Sections/Labels.tsx b/src/screens/Profile/Sections/Labels.tsx index c04c047c4..669a5dbcc 100644 --- a/src/screens/Profile/Sections/Labels.tsx +++ b/src/screens/Profile/Sections/Labels.tsx @@ -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() - - // 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) - }, - }) + isLabelerLoading, + labelerInfo, + labelerError, + moderationOpts, + scrollElRef, + headerHeight, + isFocused, + setScrollViewTag, +}: LabelsSectionProps) { + const t = useTheme() - 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]) + 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 ( - <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} + <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} /> - )} - </Layout.Content> - </Layout.Center> + } + ListFooterComponent={ + <ListFooter + height={headerHeight + 180} + style={a.border_transparent} + /> + } + /> + </View> ) -}) +} -export function ProfileLabelsSectionInner({ - moderationOpts, +function keyExtractor(item: InterpretedLabelValueDefinition) { + return item.identifier +} + +export function LabelerListHeader({ + isLabelerLoading, + labelerError, labelerInfo, + hasValues, + isSubscribed, }: { - moderationOpts: ModerationOpts - labelerInfo: AppBskyLabelerDefs.LabelerViewDetailed + isLabelerLoading: boolean + labelerError?: Error | null + labelerInfo?: AppBskyLabelerDefs.LabelerViewDetailed + hasValues: boolean + isSubscribed: boolean }) { const t = useTheme() + const {_} = useLingui() - 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]) + 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.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, - ]}> + <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> - This labeler hasn't declared what labels it publishes, and may not - be active. + Blocking does not prevent this labeler from placing labels on your + account. </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 + </View> + ) : null} + {!hasValues ? ( + <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> - )} + <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> ) } diff --git a/src/state/queries/labeler.ts b/src/state/queries/labeler.ts index 53e923a85..4eddb27f4 100644 --- a/src/state/queries/labeler.ts +++ b/src/state/queries/labeler.ts @@ -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 |