Bluesky app fork with some witchin' additions 💫

Virtualise labeler list (#8566)

* use flatlist for labeler list

* use key extractor

* dedupe `labelValues`

* add comment

authored by samuel.fm and committed by

GitHub e5f9377a 954e7d2a

+173 -164
+171 -162
src/screens/Profile/Sections/Labels.tsx
··· 1 - import React from 'react' 2 - import {findNodeHandle, View} from 'react-native' 3 - import type Animated from 'react-native-reanimated' 4 - import {useSafeAreaFrame} from 'react-native-safe-area-context' 1 + import {useCallback, useEffect, useImperativeHandle, useMemo} from 'react' 2 + import {findNodeHandle, type ListRenderItemInfo, View} from 'react-native' 5 3 import { 6 4 type AppBskyLabelerDefs, 7 5 type InterpretedLabelValueDefinition, ··· 11 9 import {msg, Trans} from '@lingui/macro' 12 10 import {useLingui} from '@lingui/react' 13 11 14 - import {useAnimatedScrollHandler} from '#/lib/hooks/useAnimatedScrollHandler_FIXED' 15 12 import {isLabelerSubscribed, lookupLabelValueDefinition} from '#/lib/moderation' 16 - import {useScrollHandlers} from '#/lib/ScrollContext' 17 13 import {isIOS, isNative} from '#/platform/detection' 18 - import {type ListRef} from '#/view/com/util/List' 19 - import {atoms as a, useTheme} from '#/alf' 14 + import {List, type ListRef} from '#/view/com/util/List' 15 + import {atoms as a, ios, tokens, useTheme} from '#/alf' 20 16 import {Divider} from '#/components/Divider' 21 17 import {CircleInfo_Stroke2_Corner0_Rounded as CircleInfo} from '#/components/icons/CircleInfo' 22 - import * as Layout from '#/components/Layout' 18 + import {ListFooter} from '#/components/Lists' 23 19 import {Loader} from '#/components/Loader' 24 20 import {LabelerLabelPreference} from '#/components/moderation/LabelPreference' 25 21 import {Text} from '#/components/Typography' ··· 27 23 import {type SectionRef} from './types' 28 24 29 25 interface LabelsSectionProps { 26 + ref: React.Ref<SectionRef> 30 27 isLabelerLoading: boolean 31 28 labelerInfo: AppBskyLabelerDefs.LabelerViewDetailed | undefined 32 29 labelerError: Error | null ··· 36 33 isFocused: boolean 37 34 setScrollViewTag: (tag: number | null) => void 38 35 } 39 - export const ProfileLabelsSection = React.forwardRef< 40 - SectionRef, 41 - LabelsSectionProps 42 - >(function LabelsSectionImpl( 43 - { 44 - isLabelerLoading, 45 - labelerInfo, 46 - labelerError, 47 - moderationOpts, 48 - scrollElRef, 49 - headerHeight, 50 - isFocused, 51 - setScrollViewTag, 52 - }, 36 + export function ProfileLabelsSection({ 53 37 ref, 54 - ) { 55 - const {_} = useLingui() 56 - const {height: minHeight} = useSafeAreaFrame() 57 - 58 - // Intentionally destructured outside the main thread closure. 59 - // See https://github.com/bluesky-social/social-app/pull/4108. 60 - const { 61 - onBeginDrag: onBeginDragFromContext, 62 - onEndDrag: onEndDragFromContext, 63 - onScroll: onScrollFromContext, 64 - onMomentumEnd: onMomentumEndFromContext, 65 - } = useScrollHandlers() 66 - const scrollHandler = useAnimatedScrollHandler({ 67 - onBeginDrag(e, ctx) { 68 - onBeginDragFromContext?.(e, ctx) 69 - }, 70 - onEndDrag(e, ctx) { 71 - onEndDragFromContext?.(e, ctx) 72 - }, 73 - onScroll(e, ctx) { 74 - onScrollFromContext?.(e, ctx) 75 - }, 76 - onMomentumEnd(e, ctx) { 77 - onMomentumEndFromContext?.(e, ctx) 78 - }, 79 - }) 38 + isLabelerLoading, 39 + labelerInfo, 40 + labelerError, 41 + moderationOpts, 42 + scrollElRef, 43 + headerHeight, 44 + isFocused, 45 + setScrollViewTag, 46 + }: LabelsSectionProps) { 47 + const t = useTheme() 80 48 81 - const onScrollToTop = React.useCallback(() => { 82 - // @ts-ignore TODO fix this 49 + const onScrollToTop = useCallback(() => { 50 + // @ts-expect-error TODO fix this 83 51 scrollElRef.current?.scrollTo({ 84 52 animated: isNative, 85 53 x: 0, ··· 87 55 }) 88 56 }, [scrollElRef, headerHeight]) 89 57 90 - React.useImperativeHandle(ref, () => ({ 58 + useImperativeHandle(ref, () => ({ 91 59 scrollToTop: onScrollToTop, 92 60 })) 93 61 94 - React.useEffect(() => { 62 + useEffect(() => { 95 63 if (isIOS && isFocused && scrollElRef.current) { 96 64 const nativeTag = findNodeHandle(scrollElRef.current) 97 65 setScrollViewTag(nativeTag) 98 66 } 99 67 }, [isFocused, scrollElRef, setScrollViewTag]) 100 68 69 + const isSubscribed = labelerInfo 70 + ? !!isLabelerSubscribed(labelerInfo, moderationOpts) 71 + : false 72 + 73 + const labelValues = useMemo(() => { 74 + if (isLabelerLoading || !labelerInfo || labelerError) return [] 75 + const customDefs = interpretLabelValueDefinitions(labelerInfo) 76 + return labelerInfo.policies.labelValues 77 + .filter((val, i, arr) => arr.indexOf(val) === i) // dedupe 78 + .map(val => lookupLabelValueDefinition(val, customDefs)) 79 + .filter( 80 + def => def && def?.configurable, 81 + ) as InterpretedLabelValueDefinition[] 82 + }, [labelerInfo, labelerError, isLabelerLoading]) 83 + 84 + const numItems = labelValues.length 85 + 86 + const renderItem = useCallback( 87 + ({item, index}: ListRenderItemInfo<InterpretedLabelValueDefinition>) => { 88 + if (!labelerInfo) return null 89 + return ( 90 + <View 91 + style={[ 92 + t.atoms.bg_contrast_25, 93 + index === 0 && [ 94 + a.overflow_hidden, 95 + { 96 + borderTopLeftRadius: tokens.borderRadius.md, 97 + borderTopRightRadius: tokens.borderRadius.md, 98 + }, 99 + ], 100 + index === numItems - 1 && [ 101 + a.overflow_hidden, 102 + { 103 + borderBottomLeftRadius: tokens.borderRadius.md, 104 + borderBottomRightRadius: tokens.borderRadius.md, 105 + }, 106 + ], 107 + ]}> 108 + {index !== 0 && <Divider />} 109 + <LabelerLabelPreference 110 + disabled={isSubscribed ? undefined : true} 111 + labelDefinition={item} 112 + labelerDid={labelerInfo.creator.did} 113 + /> 114 + </View> 115 + ) 116 + }, 117 + [labelerInfo, isSubscribed, numItems, t], 118 + ) 119 + 101 120 return ( 102 - <Layout.Center style={{minHeight}}> 103 - <Layout.Content 104 - ref={scrollElRef as React.Ref<Animated.ScrollView>} 105 - scrollEventThrottle={1} 106 - contentContainerStyle={{ 107 - paddingTop: headerHeight, 108 - borderWidth: 0, 109 - }} 110 - contentOffset={{x: 0, y: headerHeight * -1}} 111 - onScroll={scrollHandler}> 112 - {isLabelerLoading ? ( 113 - <View style={[a.w_full, a.align_center, a.py_4xl]}> 114 - <Loader size="xl" /> 115 - </View> 116 - ) : labelerError || !labelerInfo ? ( 117 - <View style={[a.w_full, a.align_center, a.py_4xl]}> 118 - <ErrorState 119 - error={ 120 - labelerError?.toString() || 121 - _(msg`Something went wrong, please try again.`) 122 - } 123 - /> 124 - </View> 125 - ) : ( 126 - <ProfileLabelsSectionInner 127 - moderationOpts={moderationOpts} 121 + <View> 122 + <List 123 + ref={scrollElRef} 124 + data={labelValues} 125 + renderItem={renderItem} 126 + keyExtractor={keyExtractor} 127 + contentContainerStyle={a.px_xl} 128 + headerOffset={headerHeight} 129 + progressViewOffset={ios(0)} 130 + ListHeaderComponent={ 131 + <LabelerListHeader 132 + isLabelerLoading={isLabelerLoading} 128 133 labelerInfo={labelerInfo} 134 + labelerError={labelerError} 135 + hasValues={labelValues.length !== 0} 136 + isSubscribed={isSubscribed} 129 137 /> 130 - )} 131 - </Layout.Content> 132 - </Layout.Center> 138 + } 139 + ListFooterComponent={ 140 + <ListFooter 141 + height={headerHeight + 180} 142 + style={a.border_transparent} 143 + /> 144 + } 145 + /> 146 + </View> 133 147 ) 134 - }) 148 + } 135 149 136 - export function ProfileLabelsSectionInner({ 137 - moderationOpts, 150 + function keyExtractor(item: InterpretedLabelValueDefinition) { 151 + return item.identifier 152 + } 153 + 154 + export function LabelerListHeader({ 155 + isLabelerLoading, 156 + labelerError, 138 157 labelerInfo, 158 + hasValues, 159 + isSubscribed, 139 160 }: { 140 - moderationOpts: ModerationOpts 141 - labelerInfo: AppBskyLabelerDefs.LabelerViewDetailed 161 + isLabelerLoading: boolean 162 + labelerError?: Error | null 163 + labelerInfo?: AppBskyLabelerDefs.LabelerViewDetailed 164 + hasValues: boolean 165 + isSubscribed: boolean 142 166 }) { 143 167 const t = useTheme() 168 + const {_} = useLingui() 144 169 145 - const {labelValues} = labelerInfo.policies 146 - const isSubscribed = isLabelerSubscribed(labelerInfo, moderationOpts) 147 - const labelDefs = React.useMemo(() => { 148 - const customDefs = interpretLabelValueDefinitions(labelerInfo) 149 - return labelValues 150 - .map(val => lookupLabelValueDefinition(val, customDefs)) 151 - .filter( 152 - def => def && def?.configurable, 153 - ) as InterpretedLabelValueDefinition[] 154 - }, [labelerInfo, labelValues]) 170 + if (isLabelerLoading) { 171 + return ( 172 + <View style={[a.w_full, a.align_center, a.py_4xl]}> 173 + <Loader size="xl" /> 174 + </View> 175 + ) 176 + } 177 + 178 + if (labelerError || !labelerInfo) { 179 + return ( 180 + <View style={[a.w_full, a.align_center, a.py_4xl]}> 181 + <ErrorState 182 + error={ 183 + labelerError?.toString() || 184 + _(msg`Something went wrong, please try again.`) 185 + } 186 + /> 187 + </View> 188 + ) 189 + } 155 190 156 191 return ( 157 - <View style={[a.pt_xl, a.px_lg, a.border_t, t.atoms.border_contrast_low]}> 158 - <View> 159 - <Text style={[t.atoms.text_contrast_high, a.leading_snug, a.text_sm]}> 160 - <Trans> 161 - Labels are annotations on users and content. They can be used to 162 - hide, warn, and categorize the network. 163 - </Trans> 164 - </Text> 165 - {labelerInfo.creator.viewer?.blocking ? ( 166 - <View style={[a.flex_row, a.gap_sm, a.align_center, a.mt_md]}> 167 - <CircleInfo size="sm" fill={t.atoms.text_contrast_medium.color} /> 168 - <Text 169 - style={[t.atoms.text_contrast_high, a.leading_snug, a.text_sm]}> 170 - <Trans> 171 - Blocking does not prevent this labeler from placing labels on 172 - your account. 173 - </Trans> 174 - </Text> 175 - </View> 176 - ) : null} 177 - {labelValues.length === 0 ? ( 178 - <Text 179 - style={[ 180 - a.pt_xl, 181 - t.atoms.text_contrast_high, 182 - a.leading_snug, 183 - a.text_sm, 184 - ]}> 192 + <View style={[a.py_xl]}> 193 + <Text style={[t.atoms.text_contrast_high, a.leading_snug, a.text_sm]}> 194 + <Trans> 195 + Labels are annotations on users and content. They can be used to hide, 196 + warn, and categorize the network. 197 + </Trans> 198 + </Text> 199 + {labelerInfo?.creator.viewer?.blocking ? ( 200 + <View style={[a.flex_row, a.gap_sm, a.align_center, a.mt_md]}> 201 + <CircleInfo size="sm" fill={t.atoms.text_contrast_medium.color} /> 202 + <Text style={[t.atoms.text_contrast_high, a.leading_snug, a.text_sm]}> 185 203 <Trans> 186 - This labeler hasn't declared what labels it publishes, and may not 187 - be active. 204 + Blocking does not prevent this labeler from placing labels on your 205 + account. 188 206 </Trans> 189 207 </Text> 190 - ) : !isSubscribed ? ( 191 - <Text 192 - style={[ 193 - a.pt_xl, 194 - t.atoms.text_contrast_high, 195 - a.leading_snug, 196 - a.text_sm, 197 - ]}> 198 - <Trans> 199 - Subscribe to @{labelerInfo.creator.handle} to use these labels: 200 - </Trans> 201 - </Text> 202 - ) : null} 203 - </View> 204 - {labelDefs.length > 0 && ( 205 - <View 208 + </View> 209 + ) : null} 210 + {!hasValues ? ( 211 + <Text 212 + style={[ 213 + a.pt_xl, 214 + t.atoms.text_contrast_high, 215 + a.leading_snug, 216 + a.text_sm, 217 + ]}> 218 + <Trans> 219 + This labeler hasn't declared what labels it publishes, and may not 220 + be active. 221 + </Trans> 222 + </Text> 223 + ) : !isSubscribed ? ( 224 + <Text 206 225 style={[ 207 - a.mt_xl, 208 - a.w_full, 209 - a.rounded_md, 210 - a.overflow_hidden, 211 - t.atoms.bg_contrast_25, 226 + a.pt_xl, 227 + t.atoms.text_contrast_high, 228 + a.leading_snug, 229 + a.text_sm, 212 230 ]}> 213 - {labelDefs.map((labelDef, i) => { 214 - return ( 215 - <React.Fragment key={labelDef.identifier}> 216 - {i !== 0 && <Divider />} 217 - <LabelerLabelPreference 218 - disabled={isSubscribed ? undefined : true} 219 - labelDefinition={labelDef} 220 - labelerDid={labelerInfo.creator.did} 221 - /> 222 - </React.Fragment> 223 - ) 224 - })} 225 - </View> 226 - )} 231 + <Trans> 232 + Subscribe to @{labelerInfo.creator.handle} to use these labels: 233 + </Trans> 234 + </Text> 235 + ) : null} 227 236 </View> 228 237 ) 229 238 }
+2 -2
src/state/queries/labeler.ts
··· 1 - import {AppBskyLabelerDefs} from '@atproto/api' 1 + import {type AppBskyLabelerDefs} from '@atproto/api' 2 2 import {useMutation, useQuery, useQueryClient} from '@tanstack/react-query' 3 3 import {z} from 'zod' 4 4 ··· 41 41 queryKey: labelerInfoQueryKey(did as string), 42 42 queryFn: async () => { 43 43 const res = await agent.app.bsky.labeler.getServices({ 44 - dids: [did as string], 44 + dids: [did!], 45 45 detailed: true, 46 46 }) 47 47 return res.data.views[0] as AppBskyLabelerDefs.LabelerViewDetailed