mirror of https://git.lenooby09.tech/LeNooby09/social-app.git
1import {useCallback, useMemo, useState} from 'react'
2import {
3 ActivityIndicator,
4 KeyboardAvoidingView,
5 ScrollView,
6 StyleSheet,
7 TextInput,
8 TouchableOpacity,
9 View,
10} from 'react-native'
11import {Image as RNImage} from 'react-native-image-crop-picker'
12import {LinearGradient} from 'expo-linear-gradient'
13import {AppBskyGraphDefs, RichText as RichTextAPI} from '@atproto/api'
14import {msg, Trans} from '@lingui/macro'
15import {useLingui} from '@lingui/react'
16
17import {usePalette} from '#/lib/hooks/usePalette'
18import {useWebMediaQueries} from '#/lib/hooks/useWebMediaQueries'
19import {compressIfNeeded} from '#/lib/media/manip'
20import {cleanError, isNetworkError} from '#/lib/strings/errors'
21import {enforceLen} from '#/lib/strings/helpers'
22import {richTextToString} from '#/lib/strings/rich-text-helpers'
23import {shortenLinks, stripInvalidMentions} from '#/lib/strings/rich-text-manip'
24import {colors, gradients, s} from '#/lib/styles'
25import {useTheme} from '#/lib/ThemeContext'
26import {useModalControls} from '#/state/modals'
27import {
28 useListCreateMutation,
29 useListMetadataMutation,
30} from '#/state/queries/list'
31import {useAgent} from '#/state/session'
32import {ErrorMessage} from '../util/error/ErrorMessage'
33import {Text} from '../util/text/Text'
34import * as Toast from '../util/Toast'
35import {EditableUserAvatar} from '../util/UserAvatar'
36
37const MAX_NAME = 64 // todo
38const MAX_DESCRIPTION = 300 // todo
39
40export const snapPoints = ['fullscreen']
41
42export function Component({
43 purpose,
44 onSave,
45 list,
46}: {
47 purpose?: string
48 onSave?: (uri: string) => void
49 list?: AppBskyGraphDefs.ListView
50}) {
51 const {closeModal} = useModalControls()
52 const {isMobile} = useWebMediaQueries()
53 const [error, setError] = useState<string>('')
54 const pal = usePalette('default')
55 const theme = useTheme()
56 const {_} = useLingui()
57 const listCreateMutation = useListCreateMutation()
58 const listMetadataMutation = useListMetadataMutation()
59 const agent = useAgent()
60
61 const activePurpose = useMemo(() => {
62 if (list?.purpose) {
63 return list.purpose
64 }
65 if (purpose) {
66 return purpose
67 }
68 return 'app.bsky.graph.defs#curatelist'
69 }, [list, purpose])
70 const isCurateList = activePurpose === 'app.bsky.graph.defs#curatelist'
71
72 const [isProcessing, setProcessing] = useState<boolean>(false)
73 const [name, setName] = useState<string>(list?.name || '')
74
75 const [descriptionRt, setDescriptionRt] = useState<RichTextAPI>(() => {
76 const text = list?.description
77 const facets = list?.descriptionFacets
78
79 if (!text || !facets) {
80 return new RichTextAPI({text: text || ''})
81 }
82
83 // We want to be working with a blank state here, so let's get the
84 // serialized version and turn it back into a RichText
85 const serialized = richTextToString(new RichTextAPI({text, facets}), false)
86
87 const richText = new RichTextAPI({text: serialized})
88 richText.detectFacetsWithoutResolution()
89
90 return richText
91 })
92 const graphemeLength = useMemo(() => {
93 return shortenLinks(descriptionRt).graphemeLength
94 }, [descriptionRt])
95 const isDescriptionOver = graphemeLength > MAX_DESCRIPTION
96
97 const [avatar, setAvatar] = useState<string | undefined>(list?.avatar)
98 const [newAvatar, setNewAvatar] = useState<RNImage | undefined | null>()
99
100 const onDescriptionChange = useCallback(
101 (newText: string) => {
102 const richText = new RichTextAPI({text: newText})
103 richText.detectFacetsWithoutResolution()
104
105 setDescriptionRt(richText)
106 },
107 [setDescriptionRt],
108 )
109
110 const onPressCancel = useCallback(() => {
111 closeModal()
112 }, [closeModal])
113
114 const onSelectNewAvatar = useCallback(
115 async (img: RNImage | null) => {
116 if (!img) {
117 setNewAvatar(null)
118 setAvatar(undefined)
119 return
120 }
121 try {
122 const finalImg = await compressIfNeeded(img, 1000000)
123 setNewAvatar(finalImg)
124 setAvatar(finalImg.path)
125 } catch (e: any) {
126 setError(cleanError(e))
127 }
128 },
129 [setNewAvatar, setAvatar, setError],
130 )
131
132 const onPressSave = useCallback(async () => {
133 const nameTrimmed = name.trim()
134 if (!nameTrimmed) {
135 setError(_(msg`Name is required`))
136 return
137 }
138 setProcessing(true)
139 if (error) {
140 setError('')
141 }
142 try {
143 let richText = new RichTextAPI(
144 {text: descriptionRt.text.trimEnd()},
145 {cleanNewlines: true},
146 )
147
148 await richText.detectFacets(agent)
149 richText = shortenLinks(richText)
150 richText = stripInvalidMentions(richText)
151
152 if (list) {
153 await listMetadataMutation.mutateAsync({
154 uri: list.uri,
155 name: nameTrimmed,
156 description: richText.text,
157 descriptionFacets: richText.facets,
158 avatar: newAvatar,
159 })
160 Toast.show(
161 isCurateList
162 ? _(msg`User list updated`)
163 : _(msg`Moderation list updated`),
164 )
165 onSave?.(list.uri)
166 } else {
167 const res = await listCreateMutation.mutateAsync({
168 purpose: activePurpose,
169 name,
170 description: richText.text,
171 descriptionFacets: richText.facets,
172 avatar: newAvatar,
173 })
174 Toast.show(
175 isCurateList
176 ? _(msg`User list created`)
177 : _(msg`Moderation list created`),
178 )
179 onSave?.(res.uri)
180 }
181 closeModal()
182 } catch (e: any) {
183 if (isNetworkError(e)) {
184 setError(
185 _(
186 msg`Failed to create the list. Check your internet connection and try again.`,
187 ),
188 )
189 } else {
190 setError(cleanError(e))
191 }
192 }
193 setProcessing(false)
194 }, [
195 setProcessing,
196 setError,
197 error,
198 onSave,
199 closeModal,
200 activePurpose,
201 isCurateList,
202 name,
203 descriptionRt,
204 newAvatar,
205 list,
206 listMetadataMutation,
207 listCreateMutation,
208 _,
209 agent,
210 ])
211
212 return (
213 <KeyboardAvoidingView behavior="height">
214 <ScrollView
215 style={[
216 pal.view,
217 {
218 paddingHorizontal: isMobile ? 16 : 0,
219 },
220 ]}
221 testID="createOrEditListModal">
222 <Text style={[styles.title, pal.text]}>
223 {isCurateList ? (
224 list ? (
225 <Trans>Edit User List</Trans>
226 ) : (
227 <Trans>New User List</Trans>
228 )
229 ) : list ? (
230 <Trans>Edit Moderation List</Trans>
231 ) : (
232 <Trans>New Moderation List</Trans>
233 )}
234 </Text>
235 {error !== '' && (
236 <View style={styles.errorContainer}>
237 <ErrorMessage message={error} />
238 </View>
239 )}
240 <Text style={[styles.label, pal.text]}>
241 <Trans>List Avatar</Trans>
242 </Text>
243 <View style={[styles.avi, {borderColor: pal.colors.background}]}>
244 <EditableUserAvatar
245 type="list"
246 size={80}
247 avatar={avatar}
248 onSelectNewAvatar={onSelectNewAvatar}
249 />
250 </View>
251 <View style={styles.form}>
252 <View>
253 <View style={styles.labelWrapper}>
254 <Text style={[styles.label, pal.text]} nativeID="list-name">
255 <Trans>List Name</Trans>
256 </Text>
257 </View>
258 <TextInput
259 testID="editNameInput"
260 style={[styles.textInput, pal.border, pal.text]}
261 placeholder={
262 isCurateList
263 ? _(msg`e.g. Great Posters`)
264 : _(msg`e.g. Spammers`)
265 }
266 placeholderTextColor={colors.gray4}
267 value={name}
268 onChangeText={v => setName(enforceLen(v, MAX_NAME))}
269 accessible={true}
270 accessibilityLabel={_(msg`Name`)}
271 accessibilityHint=""
272 accessibilityLabelledBy="list-name"
273 />
274 </View>
275 <View style={s.pb10}>
276 <View style={styles.labelWrapper}>
277 <Text
278 style={[styles.label, pal.text]}
279 nativeID="list-description">
280 <Trans>Description</Trans>
281 </Text>
282 <Text
283 style={[!isDescriptionOver ? pal.textLight : s.red3, s.f13]}>
284 {graphemeLength}/{MAX_DESCRIPTION}
285 </Text>
286 </View>
287 <TextInput
288 testID="editDescriptionInput"
289 style={[styles.textArea, pal.border, pal.text]}
290 placeholder={
291 isCurateList
292 ? _(msg`e.g. The posters who never miss.`)
293 : _(msg`e.g. Users that repeatedly reply with ads.`)
294 }
295 placeholderTextColor={colors.gray4}
296 keyboardAppearance={theme.colorScheme}
297 multiline
298 value={descriptionRt.text}
299 onChangeText={onDescriptionChange}
300 accessible={true}
301 accessibilityLabel={_(msg`Description`)}
302 accessibilityHint=""
303 accessibilityLabelledBy="list-description"
304 />
305 </View>
306 {isProcessing ? (
307 <View style={[styles.btn, s.mt10, {backgroundColor: colors.gray2}]}>
308 <ActivityIndicator />
309 </View>
310 ) : (
311 <TouchableOpacity
312 testID="saveBtn"
313 style={[s.mt10, isDescriptionOver && s.dimmed]}
314 disabled={isDescriptionOver}
315 onPress={onPressSave}
316 accessibilityRole="button"
317 accessibilityLabel={_(msg`Save`)}
318 accessibilityHint="">
319 <LinearGradient
320 colors={[gradients.blueLight.start, gradients.blueLight.end]}
321 start={{x: 0, y: 0}}
322 end={{x: 1, y: 1}}
323 style={styles.btn}>
324 <Text style={[s.white, s.bold]}>
325 <Trans context="action">Save</Trans>
326 </Text>
327 </LinearGradient>
328 </TouchableOpacity>
329 )}
330 <TouchableOpacity
331 testID="cancelBtn"
332 style={s.mt5}
333 onPress={onPressCancel}
334 accessibilityRole="button"
335 accessibilityLabel={_(msg`Cancel`)}
336 accessibilityHint=""
337 onAccessibilityEscape={onPressCancel}>
338 <View style={[styles.btn]}>
339 <Text style={[s.black, s.bold, pal.text]}>
340 <Trans context="action">Cancel</Trans>
341 </Text>
342 </View>
343 </TouchableOpacity>
344 </View>
345 </ScrollView>
346 </KeyboardAvoidingView>
347 )
348}
349
350const styles = StyleSheet.create({
351 title: {
352 textAlign: 'center',
353 fontWeight: '600',
354 fontSize: 24,
355 marginBottom: 18,
356 },
357 labelWrapper: {
358 flexDirection: 'row',
359 gap: 8,
360 alignItems: 'center',
361 justifyContent: 'space-between',
362 paddingHorizontal: 4,
363 paddingBottom: 4,
364 marginTop: 20,
365 },
366 label: {
367 fontWeight: '600',
368 },
369 form: {
370 paddingHorizontal: 6,
371 },
372 textInput: {
373 borderWidth: 1,
374 borderRadius: 6,
375 paddingHorizontal: 14,
376 paddingVertical: 10,
377 fontSize: 16,
378 },
379 textArea: {
380 borderWidth: 1,
381 borderRadius: 6,
382 paddingHorizontal: 12,
383 paddingTop: 10,
384 fontSize: 16,
385 height: 100,
386 textAlignVertical: 'top',
387 },
388 btn: {
389 flexDirection: 'row',
390 alignItems: 'center',
391 justifyContent: 'center',
392 width: '100%',
393 borderRadius: 32,
394 padding: 10,
395 marginBottom: 10,
396 },
397 avi: {
398 width: 84,
399 height: 84,
400 borderWidth: 2,
401 borderRadius: 42,
402 marginTop: 4,
403 },
404 errorContainer: {marginTop: 20},
405})