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