about summary refs log tree commit diff
diff options
context:
space:
mode:
authorSamuel Newman <mozzius@protonmail.com>2025-06-26 09:39:34 +0300
committerGitHub <noreply@github.com>2025-06-25 23:39:34 -0700
commite5f9377a691ef899e755d9611eafa49cbce8ec46 (patch)
tree237714f33ac33eee44dc6acd24b11d1127d76025
parent954e7d2a89ac3e95abda3667ac707156afbdfbf0 (diff)
downloadvoidsky-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.tsx333
-rw-r--r--src/state/queries/labeler.ts4
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