forked from
jollywhoppers.com/witchsky.app
Bluesky app fork with some witchin' additions 馃挮
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}