mirror of https://git.lenooby09.tech/LeNooby09/social-app.git
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

at utm-source 405 lines 12 kB view raw
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})