Bluesky app fork with some witchin' additions 馃挮
witchsky.app
bluesky
fork
client
1import {
2 useCallback,
3 useEffect,
4 useImperativeHandle,
5 useMemo,
6 useState,
7} from 'react'
8import {
9 findNodeHandle,
10 type ListRenderItemInfo,
11 type StyleProp,
12 useWindowDimensions,
13 View,
14 type ViewStyle,
15} from 'react-native'
16import {msg} from '@lingui/core/macro'
17import {useLingui} from '@lingui/react'
18import {useNavigation} from '@react-navigation/native'
19import {useQueryClient} from '@tanstack/react-query'
20
21import {cleanError} from '#/lib/strings/errors'
22import {logger} from '#/logger'
23import {usePreferencesQuery} from '#/state/queries/preferences'
24import {RQKEY, useProfileListsQuery} from '#/state/queries/profile-lists'
25import {useSession} from '#/state/session'
26import {EmptyState} from '#/view/com/util/EmptyState'
27import {ErrorMessage} from '#/view/com/util/error/ErrorMessage'
28import {List, type ListRef} from '#/view/com/util/List'
29import {FeedLoadingPlaceholder} from '#/view/com/util/LoadingPlaceholder'
30import {LoadMoreRetryBtn} from '#/view/com/util/LoadMoreRetryBtn'
31import {atoms as a, ios, useTheme} from '#/alf'
32import {BulletList_Stroke1_Corner0_Rounded as ListIcon} from '#/components/icons/BulletList'
33import * as ListCard from '#/components/ListCard'
34import {ListFooter} from '#/components/Lists'
35import {IS_IOS, IS_NATIVE, IS_WEB} from '#/env'
36
37const LOADING = {_reactKey: '__loading__'}
38const EMPTY = {_reactKey: '__empty__'}
39const ERROR_ITEM = {_reactKey: '__error__'}
40const LOAD_MORE_ERROR_ITEM = {_reactKey: '__load_more_error__'}
41
42interface SectionRef {
43 scrollToTop: () => void
44}
45
46interface ProfileListsProps {
47 ref?: React.Ref<SectionRef>
48 did: string
49 scrollElRef: ListRef
50 headerOffset: number
51 enabled?: boolean
52 style?: StyleProp<ViewStyle>
53 testID?: string
54 setScrollViewTag: (tag: number | null) => void
55}
56
57export function ProfileLists({
58 ref,
59 did,
60 scrollElRef,
61 headerOffset,
62 enabled,
63 style,
64 testID,
65 setScrollViewTag,
66}: ProfileListsProps) {
67 const {_} = useLingui()
68 const t = useTheme()
69 const [isPTRing, setIsPTRing] = useState(false)
70 const {height} = useWindowDimensions()
71 const opts = useMemo(() => ({enabled}), [enabled])
72 const {
73 data,
74 isPending,
75 isFetchingNextPage,
76 hasNextPage,
77 fetchNextPage,
78 isError,
79 error,
80 refetch,
81 } = useProfileListsQuery(did, opts)
82 const isEmpty = !isPending && !data?.pages[0]?.lists.length
83 const {data: preferences} = usePreferencesQuery()
84 const navigation = useNavigation()
85 const {currentAccount} = useSession()
86 const isSelf = currentAccount?.did === did
87
88 const items = useMemo(() => {
89 let listItems: any[] = []
90 if (isError && isEmpty) {
91 listItems = listItems.concat([ERROR_ITEM])
92 }
93 if (isPending) {
94 listItems = listItems.concat([LOADING])
95 } else if (isEmpty) {
96 listItems = listItems.concat([EMPTY])
97 } else if (data?.pages) {
98 for (const page of data?.pages) {
99 listItems = listItems.concat(page.lists)
100 }
101 } else if (isError && !isEmpty) {
102 listItems = listItems.concat([LOAD_MORE_ERROR_ITEM])
103 }
104 return listItems
105 }, [isError, isEmpty, isPending, data])
106
107 // events
108 // =
109
110 const queryClient = useQueryClient()
111
112 const onScrollToTop = useCallback(() => {
113 scrollElRef.current?.scrollToOffset({
114 animated: IS_NATIVE,
115 offset: -headerOffset,
116 })
117 queryClient.invalidateQueries({queryKey: RQKEY(did)})
118 }, [scrollElRef, queryClient, headerOffset, did])
119
120 useImperativeHandle(ref, () => ({
121 scrollToTop: onScrollToTop,
122 }))
123
124 const onRefresh = useCallback(async () => {
125 setIsPTRing(true)
126 try {
127 await refetch()
128 } catch (err) {
129 logger.error('Failed to refresh lists', {message: err})
130 }
131 setIsPTRing(false)
132 }, [refetch, setIsPTRing])
133
134 const onEndReached = useCallback(async () => {
135 if (isFetchingNextPage || !hasNextPage || isError) return
136
137 try {
138 await fetchNextPage()
139 } catch (err) {
140 logger.error('Failed to load more lists', {message: err})
141 }
142 }, [isFetchingNextPage, hasNextPage, isError, fetchNextPage])
143
144 const onPressRetryLoadMore = useCallback(() => {
145 fetchNextPage()
146 }, [fetchNextPage])
147
148 // rendering
149 // =
150
151 const renderItem = useCallback(
152 ({item, index}: ListRenderItemInfo<any>) => {
153 if (item === EMPTY) {
154 return (
155 <EmptyState
156 icon={ListIcon}
157 message={
158 isSelf
159 ? _(msg`You haven't created any lists yet.`)
160 : _(msg`No lists`)
161 }
162 textStyle={[t.atoms.text_contrast_medium, a.font_medium]}
163 button={
164 isSelf
165 ? {
166 label: _(msg`Create a list`),
167 text: _(msg`Create a list`),
168 onPress: () => navigation.navigate('Lists' as never),
169 size: 'small',
170 color: 'primary',
171 }
172 : undefined
173 }
174 />
175 )
176 } else if (item === ERROR_ITEM) {
177 return (
178 <ErrorMessage message={cleanError(error)} onPressTryAgain={refetch} />
179 )
180 } else if (item === LOAD_MORE_ERROR_ITEM) {
181 return (
182 <LoadMoreRetryBtn
183 label={_(
184 msg`There was an issue fetching your lists. Tap here to try again.`,
185 )}
186 onPress={onPressRetryLoadMore}
187 />
188 )
189 } else if (item === LOADING) {
190 return <FeedLoadingPlaceholder />
191 }
192 if (preferences) {
193 return (
194 <View
195 style={[
196 (index !== 0 || IS_WEB) && a.border_t,
197 t.atoms.border_contrast_low,
198 a.px_lg,
199 a.py_lg,
200 ]}>
201 <ListCard.Default view={item} />
202 </View>
203 )
204 }
205 return null
206 },
207 [
208 _,
209 t,
210 error,
211 refetch,
212 onPressRetryLoadMore,
213 preferences,
214 navigation,
215 isSelf,
216 ],
217 )
218
219 useEffect(() => {
220 if (IS_IOS && enabled && scrollElRef.current) {
221 const nativeTag = findNodeHandle(scrollElRef.current)
222 setScrollViewTag(nativeTag)
223 }
224 }, [enabled, scrollElRef, setScrollViewTag])
225
226 const ProfileListsFooter = useCallback(() => {
227 if (isEmpty) return null
228 return (
229 <ListFooter
230 hasNextPage={hasNextPage}
231 isFetchingNextPage={isFetchingNextPage}
232 onRetry={fetchNextPage}
233 error={cleanError(error)}
234 height={180 + headerOffset}
235 />
236 )
237 }, [
238 hasNextPage,
239 error,
240 isFetchingNextPage,
241 headerOffset,
242 fetchNextPage,
243 isEmpty,
244 ])
245
246 return (
247 <View testID={testID} style={style}>
248 <List
249 testID={testID ? `${testID}-flatlist` : undefined}
250 ref={scrollElRef}
251 data={items}
252 keyExtractor={keyExtractor}
253 renderItem={renderItem}
254 ListFooterComponent={ProfileListsFooter}
255 refreshing={isPTRing}
256 onRefresh={onRefresh}
257 headerOffset={headerOffset}
258 progressViewOffset={ios(0)}
259 removeClippedSubviews={true}
260 desktopFixedHeight
261 onEndReached={onEndReached}
262 contentContainerStyle={{minHeight: height + headerOffset}}
263 />
264 </View>
265 )
266}
267
268function keyExtractor(item: any) {
269 return item._reactKey || item.uri
270}