forked from
jollywhoppers.com/witchsky.app
Bluesky app fork with some witchin' additions 馃挮
1import React, {useCallback} from 'react'
2import {
3 ActivityIndicator,
4 StyleSheet,
5 useWindowDimensions,
6 View,
7} from 'react-native'
8import {type AppBskyGraphDefs as GraphDefs} from '@atproto/api'
9import {msg, Trans} from '@lingui/macro'
10import {useLingui} from '@lingui/react'
11
12import {usePalette} from '#/lib/hooks/usePalette'
13import {sanitizeDisplayName} from '#/lib/strings/display-names'
14import {cleanError} from '#/lib/strings/errors'
15import {sanitizeHandle} from '#/lib/strings/handles'
16import {s} from '#/lib/styles'
17import {useTheme} from '#/lib/ThemeContext'
18import {isAndroid, isMobileWeb, isWeb} from '#/platform/detection'
19import {useModalControls} from '#/state/modals'
20import {
21 getMembership,
22 type ListMembersip,
23 useDangerousListMembershipsQuery,
24 useListMembershipAddMutation,
25 useListMembershipRemoveMutation,
26} from '#/state/queries/list-memberships'
27import {useSession} from '#/state/session'
28import {MyLists} from '../lists/MyLists'
29import {Button} from '../util/forms/Button'
30import {Text} from '../util/text/Text'
31import * as Toast from '../util/Toast'
32import {UserAvatar} from '../util/UserAvatar'
33
34export const snapPoints = ['fullscreen']
35
36export function Component({
37 subject,
38 handle,
39 displayName,
40 onAdd,
41 onRemove,
42}: {
43 subject: string
44 handle: string
45 displayName: string
46 onAdd?: (listUri: string) => void
47 onRemove?: (listUri: string) => void
48}) {
49 const {closeModal} = useModalControls()
50 const pal = usePalette('default')
51 const {height: screenHeight} = useWindowDimensions()
52 const {_} = useLingui()
53 const {data: memberships} = useDangerousListMembershipsQuery()
54
55 const onPressDone = useCallback(() => {
56 closeModal()
57 }, [closeModal])
58
59 const listStyle = React.useMemo(() => {
60 if (isMobileWeb) {
61 return [pal.border, {height: screenHeight / 2}]
62 } else if (isWeb) {
63 return [pal.border, {height: screenHeight / 1.5}]
64 }
65
66 return [pal.border, {flex: 1, borderTopWidth: StyleSheet.hairlineWidth}]
67 }, [pal.border, screenHeight])
68
69 const headerStyles = [
70 {
71 textAlign: 'center',
72 fontWeight: '600',
73 fontSize: 20,
74 marginBottom: 12,
75 paddingHorizontal: 12,
76 } as const,
77 pal.text,
78 ]
79
80 return (
81 <View testID="userAddRemoveListsModal" style={s.hContentRegion}>
82 <Text style={headerStyles} numberOfLines={1}>
83 <Trans>
84 Update{' '}
85 <Text style={headerStyles} numberOfLines={1}>
86 {displayName}
87 </Text>{' '}
88 in Lists
89 </Trans>
90 </Text>
91 <MyLists
92 filter="all"
93 inline
94 renderItem={(list, index) => (
95 <ListItem
96 key={list.uri}
97 index={index}
98 list={list}
99 memberships={memberships}
100 subject={subject}
101 handle={handle}
102 onAdd={onAdd}
103 onRemove={onRemove}
104 />
105 )}
106 style={listStyle}
107 />
108 <View style={[styles.btns, pal.border]}>
109 <Button
110 testID="doneBtn"
111 type="default"
112 onPress={onPressDone}
113 style={styles.footerBtn}
114 accessibilityLabel={_(msg({message: `Done`, context: 'action'}))}
115 accessibilityHint=""
116 onAccessibilityEscape={onPressDone}
117 label={_(msg({message: `Done`, context: 'action'}))}
118 />
119 </View>
120 </View>
121 )
122}
123
124function ListItem({
125 index,
126 list,
127 memberships,
128 subject,
129 handle,
130 onAdd,
131 onRemove,
132}: {
133 index: number
134 list: GraphDefs.ListView
135 memberships: ListMembersip[] | undefined
136 subject: string
137 handle: string
138 onAdd?: (listUri: string) => void
139 onRemove?: (listUri: string) => void
140}) {
141 const t = useTheme();
142 const pal = usePalette('default')
143 const {_} = useLingui()
144 const {currentAccount} = useSession()
145 const [isProcessing, setIsProcessing] = React.useState(false)
146 const membership = React.useMemo(
147 () => getMembership(memberships, list.uri, subject),
148 [memberships, list.uri, subject],
149 )
150 const listMembershipAddMutation = useListMembershipAddMutation()
151 const listMembershipRemoveMutation = useListMembershipRemoveMutation()
152
153 const onToggleMembership = useCallback(async () => {
154 if (typeof membership === 'undefined') {
155 return
156 }
157 setIsProcessing(true)
158 try {
159 if (membership === false) {
160 await listMembershipAddMutation.mutateAsync({
161 listUri: list.uri,
162 actorDid: subject,
163 })
164 Toast.show(_(msg`Added to list`))
165 onAdd?.(list.uri)
166 } else {
167 await listMembershipRemoveMutation.mutateAsync({
168 listUri: list.uri,
169 actorDid: subject,
170 membershipUri: membership,
171 })
172 Toast.show(_(msg`Removed from list`))
173 onRemove?.(list.uri)
174 }
175 } catch (e) {
176 Toast.show(cleanError(e), 'xmark')
177 } finally {
178 setIsProcessing(false)
179 }
180 }, [
181 _,
182 list,
183 subject,
184 membership,
185 setIsProcessing,
186 onAdd,
187 onRemove,
188 listMembershipAddMutation,
189 listMembershipRemoveMutation,
190 ])
191
192 return (
193 <View
194 testID={`toggleBtn-${list.name}`}
195 style={[
196 styles.listItem,
197 pal.border,
198 index !== 0 && {borderTopWidth: StyleSheet.hairlineWidth},
199 ]}>
200 <View style={styles.listItemAvi}>
201 <UserAvatar size={40} avatar={list.avatar} type="list" />
202 </View>
203 <View style={styles.listItemContent}>
204 <Text
205 type="lg"
206 style={[s.bold, pal.text]}
207 numberOfLines={1}
208 lineHeight={1.2}>
209 {sanitizeDisplayName(list.name)}
210 </Text>
211 <Text type="md" style={[pal.textLight]} numberOfLines={1}>
212 {list.purpose === 'app.bsky.graph.defs#curatelist' &&
213 (list.creator.did === currentAccount?.did ? (
214 <Trans>User list by you</Trans>
215 ) : (
216 <Trans>
217 User list by {sanitizeHandle(list.creator.handle, '@')}
218 </Trans>
219 ))}
220 {list.purpose === 'app.bsky.graph.defs#modlist' &&
221 (list.creator.did === currentAccount?.did ? (
222 <Trans>Moderation list by you</Trans>
223 ) : (
224 <Trans>
225 Moderation list by {sanitizeHandle(list.creator.handle, '@')}
226 </Trans>
227 ))}
228 </Text>
229 </View>
230 <View>
231 {isProcessing || typeof membership === 'undefined' ? (
232 <ActivityIndicator color={t.palette.primary_500} />
233 ) : (
234 <Button
235 testID={`user-${handle}-addBtn`}
236 type="default"
237 label={membership === false ? _(msg`Add`) : _(msg`Remove`)}
238 onPress={onToggleMembership}
239 />
240 )}
241 </View>
242 </View>
243 )
244}
245
246const styles = StyleSheet.create({
247 container: {
248 paddingHorizontal: isWeb ? 0 : 16,
249 },
250 btns: {
251 position: 'relative',
252 flexDirection: 'row',
253 alignItems: 'center',
254 justifyContent: 'center',
255 gap: 10,
256 paddingTop: 10,
257 paddingBottom: isAndroid ? 10 : 0,
258 borderTopWidth: StyleSheet.hairlineWidth,
259 },
260 footerBtn: {
261 paddingHorizontal: 24,
262 paddingVertical: 12,
263 },
264
265 listItem: {
266 flexDirection: 'row',
267 alignItems: 'center',
268 paddingHorizontal: 14,
269 paddingVertical: 10,
270 },
271 listItemAvi: {
272 width: 54,
273 paddingLeft: 4,
274 paddingTop: 8,
275 paddingBottom: 10,
276 },
277 listItemContent: {
278 flex: 1,
279 paddingRight: 10,
280 paddingTop: 10,
281 paddingBottom: 10,
282 },
283 checkbox: {
284 flexDirection: 'row',
285 alignItems: 'center',
286 justifyContent: 'center',
287 borderWidth: 1,
288 width: 24,
289 height: 24,
290 borderRadius: 6,
291 marginRight: 8,
292 },
293 loadingContainer: {
294 position: 'absolute',
295 top: 10,
296 right: 0,
297 bottom: 0,
298 justifyContent: 'center',
299 },
300})