An ATproto social media client -- with an independent Appview.
1import React, {type JSX} from 'react'
2import {
3 ActivityIndicator,
4 FlatList as RNFlatList,
5 RefreshControl,
6 type StyleProp,
7 View,
8 type ViewStyle,
9} from 'react-native'
10import {type AppBskyGraphDefs as GraphDefs} from '@atproto/api'
11import {msg} from '@lingui/macro'
12import {useLingui} from '@lingui/react'
13
14import {cleanError} from '#/lib/strings/errors'
15import {s} from '#/lib/styles'
16import {logger} from '#/logger'
17import {useModerationOpts} from '#/state/preferences/moderation-opts'
18import {type MyListsFilter, useMyListsQuery} from '#/state/queries/my-lists'
19import {atoms as a, useTheme} from '#/alf'
20import {useColorModeTheme} from '#/alf/util/useColorModeTheme'
21import {BulletList_Stroke2_Corner0_Rounded as ListIcon} from '#/components/icons/BulletList'
22import * as ListCard from '#/components/ListCard'
23import {Text} from '#/components/Typography'
24import {ErrorMessage} from '../util/error/ErrorMessage'
25import {List} from '../util/List'
26
27const LOADING = {_reactKey: '__loading__'}
28const EMPTY = {_reactKey: '__empty__'}
29const ERROR_ITEM = {_reactKey: '__error__'}
30
31export function MyLists({
32 filter,
33 inline,
34 style,
35 renderItem,
36 testID,
37}: {
38 filter: MyListsFilter
39 inline?: boolean
40 style?: StyleProp<ViewStyle>
41 renderItem?: (list: GraphDefs.ListView, index: number) => JSX.Element
42 testID?: string
43}) {
44 const theme = useTheme()
45 const colorMode = useColorModeTheme()
46 const {_} = useLingui()
47 const moderationOpts = useModerationOpts()
48 const [isPTRing, setIsPTRing] = React.useState(false)
49 const {data, isFetching, isFetched, isError, error, refetch} =
50 useMyListsQuery(filter)
51 const isEmpty = !isFetching && !data?.length
52
53 const items = React.useMemo(() => {
54 let items: any[] = []
55 if (isError && isEmpty) {
56 items = items.concat([ERROR_ITEM])
57 }
58 if ((!isFetched && isFetching) || !moderationOpts) {
59 items = items.concat([LOADING])
60 } else if (isEmpty) {
61 items = items.concat([EMPTY])
62 } else {
63 items = items.concat(data)
64 }
65 return items
66 }, [isError, isEmpty, isFetched, isFetching, moderationOpts, data])
67
68 let emptyText
69 switch (filter) {
70 case 'curate':
71 emptyText = _(
72 msg`Public, sharable lists which can be used to drive feeds.`,
73 )
74 break
75 case 'mod':
76 emptyText = _(
77 msg`Public, sharable lists of users to mute or block in bulk.`,
78 )
79 break
80 default:
81 emptyText = _(msg`You have no lists.`)
82 break
83 }
84
85 // events
86 // =
87
88 const onRefresh = React.useCallback(async () => {
89 setIsPTRing(true)
90 try {
91 await refetch()
92 } catch (err) {
93 logger.error('Failed to refresh lists', {message: err})
94 }
95 setIsPTRing(false)
96 }, [refetch, setIsPTRing])
97
98 // rendering
99 // =
100
101 const renderItemInner = React.useCallback(
102 ({item, index}: {item: any; index: number}) => {
103 if (item === EMPTY) {
104 return (
105 <View style={[a.flex_1, a.align_center, a.gap_sm, a.px_xl, a.pt_xl]}>
106 <View
107 style={[
108 a.align_center,
109 a.justify_center,
110 a.rounded_full,
111 theme.atoms.bg_contrast_25,
112 {
113 width: 32,
114 height: 32,
115 },
116 ]}>
117 <ListIcon size="md" fill={theme.atoms.text_contrast_low.color} />
118 </View>
119 <Text
120 style={[
121 a.text_center,
122 a.flex_1,
123 a.text_sm,
124 a.leading_snug,
125 theme.atoms.text_contrast_medium,
126 {
127 maxWidth: 200,
128 },
129 ]}>
130 {emptyText}
131 </Text>
132 </View>
133 )
134 } else if (item === ERROR_ITEM) {
135 return (
136 <ErrorMessage
137 message={cleanError(error)}
138 onPressTryAgain={onRefresh}
139 />
140 )
141 } else if (item === LOADING) {
142 return (
143 <View style={{padding: 20}}>
144 <ActivityIndicator />
145 </View>
146 )
147 }
148 return renderItem ? (
149 renderItem(item, index)
150 ) : (
151 <View
152 style={[
153 index !== 0 && a.border_t,
154 theme.atoms.border_contrast_low,
155 a.px_lg,
156 a.py_lg,
157 ]}>
158 <ListCard.Default view={item} />
159 </View>
160 )
161 },
162 [theme, renderItem, error, onRefresh, emptyText],
163 )
164
165 if (inline) {
166 return (
167 <View testID={testID} style={style}>
168 {items.length > 0 && (
169 <RNFlatList
170 testID={testID ? `${testID}-flatlist` : undefined}
171 data={items}
172 keyExtractor={item => (item.uri ? item.uri : item._reactKey)}
173 renderItem={renderItemInner}
174 refreshControl={
175 <RefreshControl
176 refreshing={isPTRing}
177 onRefresh={onRefresh}
178 tintColor={
179 colorMode === 'light'
180 ? theme.palette.black
181 : theme.palette.white
182 }
183 titleColor={
184 colorMode === 'light'
185 ? theme.palette.black
186 : theme.palette.white
187 }
188 />
189 }
190 contentContainerStyle={[s.contentContainer]}
191 removeClippedSubviews={true}
192 />
193 )}
194 </View>
195 )
196 } else {
197 return (
198 <View testID={testID} style={style}>
199 {items.length > 0 && (
200 <List
201 testID={testID ? `${testID}-flatlist` : undefined}
202 data={items}
203 keyExtractor={item => (item.uri ? item.uri : item._reactKey)}
204 renderItem={renderItemInner}
205 refreshing={isPTRing}
206 onRefresh={onRefresh}
207 contentContainerStyle={[s.contentContainer]}
208 removeClippedSubviews={true}
209 desktopFixedHeight
210 sideBorders={false}
211 />
212 )}
213 </View>
214 )
215 }
216}