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 {IS_ANDROID, IS_WEB, IS_WEB_MOBILE} from '#/env'
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 (IS_WEB_MOBILE) {
61 return [pal.border, {height: screenHeight / 2}]
62 } else if (IS_WEB) {
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 pal = usePalette('default')
142 const {_} = useLingui()
143 const {currentAccount} = useSession()
144 const [isProcessing, setIsProcessing] = React.useState(false)
145 const membership = React.useMemo(
146 () => getMembership(memberships, list.uri, subject),
147 [memberships, list.uri, subject],
148 )
149 const listMembershipAddMutation = useListMembershipAddMutation()
150 const listMembershipRemoveMutation = useListMembershipRemoveMutation()
151
152 const onToggleMembership = useCallback(async () => {
153 if (typeof membership === 'undefined') {
154 return
155 }
156 setIsProcessing(true)
157 try {
158 if (membership === false) {
159 await listMembershipAddMutation.mutateAsync({
160 listUri: list.uri,
161 actorDid: subject,
162 })
163 Toast.show(_(msg`Added to list`))
164 onAdd?.(list.uri)
165 } else {
166 await listMembershipRemoveMutation.mutateAsync({
167 listUri: list.uri,
168 actorDid: subject,
169 membershipUri: membership,
170 })
171 Toast.show(_(msg`Removed from list`))
172 onRemove?.(list.uri)
173 }
174 } catch (e) {
175 Toast.show(cleanError(e), 'xmark')
176 } finally {
177 setIsProcessing(false)
178 }
179 }, [
180 _,
181 list,
182 subject,
183 membership,
184 setIsProcessing,
185 onAdd,
186 onRemove,
187 listMembershipAddMutation,
188 listMembershipRemoveMutation,
189 ])
190
191 return (
192 <View
193 testID={`toggleBtn-${list.name}`}
194 style={[
195 styles.listItem,
196 pal.border,
197 index !== 0 && {borderTopWidth: StyleSheet.hairlineWidth},
198 ]}>
199 <View style={styles.listItemAvi}>
200 <UserAvatar size={40} avatar={list.avatar} type="list" />
201 </View>
202 <View style={styles.listItemContent}>
203 <Text
204 type="lg"
205 style={[s.bold, pal.text]}
206 numberOfLines={1}
207 lineHeight={1.2}>
208 {sanitizeDisplayName(list.name)}
209 </Text>
210 <Text type="md" style={[pal.textLight]} numberOfLines={1}>
211 {list.purpose === 'app.bsky.graph.defs#curatelist' &&
212 (list.creator.did === currentAccount?.did ? (
213 <Trans>User list by you</Trans>
214 ) : (
215 <Trans>
216 User list by {sanitizeHandle(list.creator.handle, '@')}
217 </Trans>
218 ))}
219 {list.purpose === 'app.bsky.graph.defs#modlist' &&
220 (list.creator.did === currentAccount?.did ? (
221 <Trans>Moderation list by you</Trans>
222 ) : (
223 <Trans>
224 Moderation list by {sanitizeHandle(list.creator.handle, '@')}
225 </Trans>
226 ))}
227 </Text>
228 </View>
229 <View>
230 {isProcessing || typeof membership === 'undefined' ? (
231 <ActivityIndicator />
232 ) : (
233 <Button
234 testID={`user-${handle}-addBtn`}
235 type="default"
236 label={membership === false ? _(msg`Add`) : _(msg`Remove`)}
237 onPress={onToggleMembership}
238 />
239 )}
240 </View>
241 </View>
242 )
243}
244
245const styles = StyleSheet.create({
246 container: {
247 paddingHorizontal: IS_WEB ? 0 : 16,
248 },
249 btns: {
250 position: 'relative',
251 flexDirection: 'row',
252 alignItems: 'center',
253 justifyContent: 'center',
254 gap: 10,
255 paddingTop: 10,
256 paddingBottom: IS_ANDROID ? 10 : 0,
257 borderTopWidth: StyleSheet.hairlineWidth,
258 },
259 footerBtn: {
260 paddingHorizontal: 24,
261 paddingVertical: 12,
262 },
263
264 listItem: {
265 flexDirection: 'row',
266 alignItems: 'center',
267 paddingHorizontal: 14,
268 paddingVertical: 10,
269 },
270 listItemAvi: {
271 width: 54,
272 paddingLeft: 4,
273 paddingTop: 8,
274 paddingBottom: 10,
275 },
276 listItemContent: {
277 flex: 1,
278 paddingRight: 10,
279 paddingTop: 10,
280 paddingBottom: 10,
281 },
282 checkbox: {
283 flexDirection: 'row',
284 alignItems: 'center',
285 justifyContent: 'center',
286 borderWidth: 1,
287 width: 24,
288 height: 24,
289 borderRadius: 6,
290 marginRight: 8,
291 },
292 loadingContainer: {
293 position: 'absolute',
294 top: 10,
295 right: 0,
296 bottom: 0,
297 justifyContent: 'center',
298 },
299})