Bluesky app fork with some witchin' additions 馃挮
witchsky.app
bluesky
fork
client
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}