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