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 session/base 426 lines 12 kB view raw
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})