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