Bluesky app fork with some witchin' additions 馃挮
at main 267 lines 7.7 kB view raw
1import React, {type JSX, useCallback} from 'react' 2import { 3 Dimensions, 4 type GestureResponderEvent, 5 type StyleProp, 6 View, 7 type ViewStyle, 8} from 'react-native' 9import {type AppBskyGraphDefs} from '@atproto/api' 10import {msg, Trans} from '@lingui/macro' 11import {useLingui} from '@lingui/react' 12 13import {cleanError} from '#/lib/strings/errors' 14import {logger} from '#/logger' 15import {useModalControls} from '#/state/modals' 16import {useModerationOpts} from '#/state/preferences/moderation-opts' 17import {useListMembersQuery} from '#/state/queries/list-members' 18import {useSession} from '#/state/session' 19import {ErrorMessage} from '#/view/com/util/error/ErrorMessage' 20import {List, type ListRef} from '#/view/com/util/List' 21import {ProfileCardFeedLoadingPlaceholder} from '#/view/com/util/LoadingPlaceholder' 22import {LoadMoreRetryBtn} from '#/view/com/util/LoadMoreRetryBtn' 23import {atoms as a, useTheme} from '#/alf' 24import {Button, ButtonText} from '#/components/Button' 25import {ListFooter} from '#/components/Lists' 26import * as ProfileCard from '#/components/ProfileCard' 27import type * as bsky from '#/types/bsky' 28 29const LOADING_ITEM = {_reactKey: '__loading__'} 30const EMPTY_ITEM = {_reactKey: '__empty__'} 31const ERROR_ITEM = {_reactKey: '__error__'} 32const LOAD_MORE_ERROR_ITEM = {_reactKey: '__load_more_error__'} 33 34export function ListMembers({ 35 list, 36 style, 37 scrollElRef, 38 onScrolledDownChange, 39 onPressTryAgain, 40 renderHeader, 41 renderEmptyState, 42 testID, 43 headerOffset = 0, 44 desktopFixedHeightOffset, 45}: { 46 list: string 47 style?: StyleProp<ViewStyle> 48 scrollElRef?: ListRef 49 onScrolledDownChange: (isScrolledDown: boolean) => void 50 onPressTryAgain?: () => void 51 renderHeader: () => JSX.Element 52 renderEmptyState: () => JSX.Element 53 testID?: string 54 headerOffset?: number 55 desktopFixedHeightOffset?: number 56}) { 57 const t = useTheme() 58 const {_} = useLingui() 59 const [isRefreshing, setIsRefreshing] = React.useState(false) 60 const {openModal} = useModalControls() 61 const {currentAccount} = useSession() 62 const moderationOpts = useModerationOpts() 63 64 const { 65 data, 66 isFetching, 67 isFetched, 68 isError, 69 error, 70 refetch, 71 fetchNextPage, 72 hasNextPage, 73 isFetchingNextPage, 74 } = useListMembersQuery(list) 75 const isEmpty = !isFetching && !data?.pages[0].items.length 76 const isOwner = 77 currentAccount && data?.pages[0].list.creator.did === currentAccount.did 78 79 const items = React.useMemo(() => { 80 let items: any[] = [] 81 if (isFetched) { 82 if (isEmpty && isError) { 83 items = items.concat([ERROR_ITEM]) 84 } 85 if (isEmpty) { 86 items = items.concat([EMPTY_ITEM]) 87 } else if (data) { 88 for (const page of data.pages) { 89 items = items.concat(page.items) 90 } 91 } 92 if (!isEmpty && isError) { 93 items = items.concat([LOAD_MORE_ERROR_ITEM]) 94 } 95 } else if (isFetching) { 96 items = items.concat([LOADING_ITEM]) 97 } 98 return items 99 }, [isFetched, isEmpty, isError, data, isFetching]) 100 101 // events 102 // = 103 104 const onRefresh = React.useCallback(async () => { 105 setIsRefreshing(true) 106 try { 107 await refetch() 108 } catch (err) { 109 logger.error('Failed to refresh lists', {message: err}) 110 } 111 setIsRefreshing(false) 112 }, [refetch, setIsRefreshing]) 113 114 const onEndReached = React.useCallback(async () => { 115 if (isFetching || !hasNextPage || isError) return 116 try { 117 await fetchNextPage() 118 } catch (err) { 119 logger.error('Failed to load more lists', {message: err}) 120 } 121 }, [isFetching, hasNextPage, isError, fetchNextPage]) 122 123 const onPressRetryLoadMore = React.useCallback(() => { 124 fetchNextPage() 125 }, [fetchNextPage]) 126 127 const onPressEditMembership = React.useCallback( 128 (e: GestureResponderEvent, profile: bsky.profile.AnyProfileView) => { 129 e.preventDefault() 130 openModal({ 131 name: 'user-add-remove-lists', 132 subject: profile.did, 133 displayName: profile.displayName || profile.handle, 134 handle: profile.handle, 135 }) 136 }, 137 [openModal], 138 ) 139 140 // rendering 141 // = 142 143 const renderItem = React.useCallback( 144 ({item}: {item: any}) => { 145 if (item === EMPTY_ITEM) { 146 return renderEmptyState() 147 } else if (item === ERROR_ITEM) { 148 return ( 149 <ErrorMessage 150 message={cleanError(error)} 151 onPressTryAgain={onPressTryAgain} 152 /> 153 ) 154 } else if (item === LOAD_MORE_ERROR_ITEM) { 155 return ( 156 <LoadMoreRetryBtn 157 label={_( 158 msg`There was an issue fetching the list. Tap here to try again.`, 159 )} 160 onPress={onPressRetryLoadMore} 161 /> 162 ) 163 } else if (item === LOADING_ITEM) { 164 return <ProfileCardFeedLoadingPlaceholder /> 165 } 166 167 const profile = (item as AppBskyGraphDefs.ListItemView).subject 168 if (!moderationOpts) return null 169 170 return ( 171 <View 172 style={[a.py_md, a.px_xl, a.border_t, t.atoms.border_contrast_low]}> 173 <ProfileCard.Link profile={profile}> 174 <ProfileCard.Outer> 175 <ProfileCard.Header> 176 <ProfileCard.Avatar 177 profile={profile} 178 moderationOpts={moderationOpts} 179 /> 180 <ProfileCard.NameAndHandle 181 profile={profile} 182 moderationOpts={moderationOpts} 183 /> 184 {isOwner && ( 185 <Button 186 testID={`user-${profile.handle}-editBtn`} 187 label={_(msg({message: 'Edit', context: 'action'}))} 188 onPress={e => onPressEditMembership(e, profile)} 189 size="small" 190 variant="solid" 191 color="secondary"> 192 <ButtonText> 193 <Trans context="action">Edit</Trans> 194 </ButtonText> 195 </Button> 196 )} 197 </ProfileCard.Header> 198 199 <ProfileCard.Labels 200 profile={profile} 201 moderationOpts={moderationOpts} 202 /> 203 204 <ProfileCard.Description profile={profile} /> 205 </ProfileCard.Outer> 206 </ProfileCard.Link> 207 </View> 208 ) 209 }, 210 [ 211 renderEmptyState, 212 error, 213 onPressTryAgain, 214 onPressRetryLoadMore, 215 moderationOpts, 216 isOwner, 217 onPressEditMembership, 218 _, 219 t, 220 ], 221 ) 222 223 const renderFooter = useCallback(() => { 224 if (isEmpty) return null 225 return ( 226 <ListFooter 227 hasNextPage={hasNextPage} 228 error={cleanError(error)} 229 isFetchingNextPage={isFetchingNextPage} 230 onRetry={fetchNextPage} 231 height={180 + headerOffset} 232 /> 233 ) 234 }, [ 235 hasNextPage, 236 error, 237 isFetchingNextPage, 238 fetchNextPage, 239 isEmpty, 240 headerOffset, 241 ]) 242 243 return ( 244 <View testID={testID} style={style}> 245 <List 246 testID={testID ? `${testID}-flatlist` : undefined} 247 ref={scrollElRef} 248 data={items} 249 keyExtractor={(item: any) => item.subject?.did || item._reactKey} 250 renderItem={renderItem} 251 ListHeaderComponent={!isEmpty ? renderHeader : undefined} 252 ListFooterComponent={renderFooter} 253 refreshing={isRefreshing} 254 onRefresh={onRefresh} 255 headerOffset={headerOffset} 256 contentContainerStyle={{ 257 minHeight: Dimensions.get('window').height * 1.5, 258 }} 259 onScrolledDownChange={onScrolledDownChange} 260 onEndReached={onEndReached} 261 onEndReachedThreshold={0.6} 262 removeClippedSubviews={true} 263 desktopFixedHeight={desktopFixedHeightOffset || true} 264 /> 265 </View> 266 ) 267}