Bluesky app fork with some witchin' additions 馃挮
at 893dd78a00d82e4901b9ea8d66e28de35cffe9bd 462 lines 13 kB view raw
1import {useCallback, useEffect, useMemo, useState} from 'react' 2import {useWindowDimensions, View} from 'react-native' 3import {type AppBskyGraphDefs, RichText as RichTextAPI} from '@atproto/api' 4import {msg, Plural, Trans} from '@lingui/macro' 5import {useLingui} from '@lingui/react' 6 7import {cleanError} from '#/lib/strings/errors' 8import {useWarnMaxGraphemeCount} from '#/lib/strings/helpers' 9import {richTextToString} from '#/lib/strings/rich-text-helpers' 10import {shortenLinks, stripInvalidMentions} from '#/lib/strings/rich-text-manip' 11import {logger} from '#/logger' 12import {isWeb} from '#/platform/detection' 13import {type ImageMeta} from '#/state/gallery' 14import { 15 useListCreateMutation, 16 useListMetadataMutation, 17} from '#/state/queries/list' 18import {useAgent} from '#/state/session' 19import {ErrorMessage} from '#/view/com/util/error/ErrorMessage' 20import * as Toast from '#/view/com/util/Toast' 21import {EditableUserAvatar} from '#/view/com/util/UserAvatar' 22import {atoms as a, useTheme, web} from '#/alf' 23import {Button, ButtonIcon, ButtonText} from '#/components/Button' 24import * as Dialog from '#/components/Dialog' 25import * as TextField from '#/components/forms/TextField' 26import {Loader} from '#/components/Loader' 27import * as Prompt from '#/components/Prompt' 28import {Text} from '#/components/Typography' 29 30const DISPLAY_NAME_MAX_GRAPHEMES = 64 31const DESCRIPTION_MAX_GRAPHEMES = 300 32 33export function CreateOrEditListDialog({ 34 control, 35 list, 36 purpose, 37 onSave, 38}: { 39 control: Dialog.DialogControlProps 40 list?: AppBskyGraphDefs.ListView 41 purpose?: AppBskyGraphDefs.ListPurpose 42 onSave?: (uri: string) => void 43}) { 44 const {_} = useLingui() 45 const cancelControl = Dialog.useDialogControl() 46 const [dirty, setDirty] = useState(false) 47 const {height} = useWindowDimensions() 48 49 // 'You might lose unsaved changes' warning 50 useEffect(() => { 51 if (isWeb && dirty) { 52 const abortController = new AbortController() 53 const {signal} = abortController 54 window.addEventListener('beforeunload', evt => evt.preventDefault(), { 55 signal, 56 }) 57 return () => { 58 abortController.abort() 59 } 60 } 61 }, [dirty]) 62 63 const onPressCancel = useCallback(() => { 64 if (dirty) { 65 cancelControl.open() 66 } else { 67 control.close() 68 } 69 }, [dirty, control, cancelControl]) 70 71 return ( 72 <Dialog.Outer 73 control={control} 74 nativeOptions={{ 75 preventDismiss: dirty, 76 minHeight: height, 77 }} 78 testID="createOrEditListDialog"> 79 <DialogInner 80 list={list} 81 purpose={purpose} 82 onSave={onSave} 83 setDirty={setDirty} 84 onPressCancel={onPressCancel} 85 /> 86 87 <Prompt.Basic 88 control={cancelControl} 89 title={_(msg`Discard changes?`)} 90 description={_(msg`Are you sure you want to discard your changes?`)} 91 onConfirm={() => control.close()} 92 confirmButtonCta={_(msg`Discard`)} 93 confirmButtonColor="negative" 94 /> 95 </Dialog.Outer> 96 ) 97} 98 99function DialogInner({ 100 list, 101 purpose, 102 onSave, 103 setDirty, 104 onPressCancel, 105}: { 106 list?: AppBskyGraphDefs.ListView 107 purpose?: AppBskyGraphDefs.ListPurpose 108 onSave?: (uri: string) => void 109 setDirty: (dirty: boolean) => void 110 onPressCancel: () => void 111}) { 112 const activePurpose = useMemo(() => { 113 if (list?.purpose) { 114 return list.purpose 115 } 116 if (purpose) { 117 return purpose 118 } 119 return 'app.bsky.graph.defs#curatelist' 120 }, [list, purpose]) 121 const isCurateList = activePurpose === 'app.bsky.graph.defs#curatelist' 122 123 const {_} = useLingui() 124 const t = useTheme() 125 const agent = useAgent() 126 const control = Dialog.useDialogContext() 127 const { 128 mutateAsync: createListMutation, 129 error: createListError, 130 isError: isCreateListError, 131 isPending: isCreatingList, 132 } = useListCreateMutation() 133 const { 134 mutateAsync: updateListMutation, 135 error: updateListError, 136 isError: isUpdateListError, 137 isPending: isUpdatingList, 138 } = useListMetadataMutation() 139 const [imageError, setImageError] = useState('') 140 const [displayNameTooShort, setDisplayNameTooShort] = useState(false) 141 const initialDisplayName = list?.name || '' 142 const [displayName, setDisplayName] = useState(initialDisplayName) 143 const initialDescription = list?.description || '' 144 const [descriptionRt, setDescriptionRt] = useState<RichTextAPI>(() => { 145 const text = list?.description 146 const facets = list?.descriptionFacets 147 148 if (!text || !facets) { 149 return new RichTextAPI({text: text || ''}) 150 } 151 152 // We want to be working with a blank state here, so let's get the 153 // serialized version and turn it back into a RichText 154 const serialized = richTextToString(new RichTextAPI({text, facets}), false) 155 156 const richText = new RichTextAPI({text: serialized}) 157 richText.detectFacetsWithoutResolution() 158 159 return richText 160 }) 161 162 const [listAvatar, setListAvatar] = useState<string | undefined | null>( 163 list?.avatar, 164 ) 165 const [newListAvatar, setNewListAvatar] = useState< 166 ImageMeta | undefined | null 167 >() 168 169 const dirty = 170 displayName !== initialDisplayName || 171 descriptionRt.text !== initialDescription || 172 listAvatar !== list?.avatar 173 174 useEffect(() => { 175 setDirty(dirty) 176 }, [dirty, setDirty]) 177 178 const onSelectNewAvatar = useCallback( 179 (img: ImageMeta | null) => { 180 setImageError('') 181 if (img === null) { 182 setNewListAvatar(null) 183 setListAvatar(null) 184 return 185 } 186 try { 187 setNewListAvatar(img) 188 setListAvatar(img.path) 189 } catch (e: any) { 190 setImageError(cleanError(e)) 191 } 192 }, 193 [setNewListAvatar, setListAvatar, setImageError], 194 ) 195 196 const onPressSave = useCallback(async () => { 197 setImageError('') 198 setDisplayNameTooShort(false) 199 try { 200 if (displayName.length === 0) { 201 setDisplayNameTooShort(true) 202 return 203 } 204 205 let richText = new RichTextAPI( 206 {text: descriptionRt.text.trimEnd()}, 207 {cleanNewlines: true}, 208 ) 209 210 await richText.detectFacets(agent) 211 richText = shortenLinks(richText) 212 richText = stripInvalidMentions(richText) 213 214 if (list) { 215 await updateListMutation({ 216 uri: list.uri, 217 name: displayName, 218 description: richText.text, 219 descriptionFacets: richText.facets, 220 avatar: newListAvatar, 221 }) 222 Toast.show( 223 isCurateList 224 ? _(msg({message: 'User list updated', context: 'toast'})) 225 : _(msg({message: 'Moderation list updated', context: 'toast'})), 226 ) 227 control.close(() => onSave?.(list.uri)) 228 } else { 229 const {uri} = await createListMutation({ 230 purpose: activePurpose, 231 name: displayName, 232 description: richText.text, 233 descriptionFacets: richText.facets, 234 avatar: newListAvatar, 235 }) 236 Toast.show( 237 isCurateList 238 ? _(msg({message: 'User list created', context: 'toast'})) 239 : _(msg({message: 'Moderation list created', context: 'toast'})), 240 ) 241 control.close(() => onSave?.(uri)) 242 } 243 } catch (e: any) { 244 logger.error('Failed to create/edit list', {message: String(e)}) 245 } 246 }, [ 247 list, 248 createListMutation, 249 updateListMutation, 250 onSave, 251 control, 252 displayName, 253 descriptionRt, 254 newListAvatar, 255 setImageError, 256 activePurpose, 257 isCurateList, 258 agent, 259 _, 260 ]) 261 262 const displayNameTooLong = useWarnMaxGraphemeCount({ 263 text: displayName, 264 maxCount: DISPLAY_NAME_MAX_GRAPHEMES, 265 }) 266 const descriptionTooLong = useWarnMaxGraphemeCount({ 267 text: descriptionRt, 268 maxCount: DESCRIPTION_MAX_GRAPHEMES, 269 }) 270 271 const cancelButton = useCallback( 272 () => ( 273 <Button 274 label={_(msg`Cancel`)} 275 onPress={onPressCancel} 276 size="small" 277 color="primary" 278 variant="ghost" 279 style={[a.rounded_full]} 280 testID="editProfileCancelBtn"> 281 <ButtonText style={[a.text_md]}> 282 <Trans>Cancel</Trans> 283 </ButtonText> 284 </Button> 285 ), 286 [onPressCancel, _], 287 ) 288 289 const saveButton = useCallback( 290 () => ( 291 <Button 292 label={_(msg`Save`)} 293 onPress={onPressSave} 294 disabled={ 295 !dirty || 296 isCreatingList || 297 isUpdatingList || 298 displayNameTooLong || 299 descriptionTooLong 300 } 301 size="small" 302 color="primary" 303 variant="ghost" 304 style={[a.rounded_full]} 305 testID="editProfileSaveBtn"> 306 <ButtonText style={[a.text_md, !dirty && t.atoms.text_contrast_low]}> 307 <Trans>Save</Trans> 308 </ButtonText> 309 {(isCreatingList || isUpdatingList) && <ButtonIcon icon={Loader} />} 310 </Button> 311 ), 312 [ 313 _, 314 t, 315 dirty, 316 onPressSave, 317 isCreatingList, 318 isUpdatingList, 319 displayNameTooLong, 320 descriptionTooLong, 321 ], 322 ) 323 324 const onChangeDisplayName = useCallback( 325 (text: string) => { 326 setDisplayName(text) 327 if (text.length > 0 && displayNameTooShort) { 328 setDisplayNameTooShort(false) 329 } 330 }, 331 [displayNameTooShort], 332 ) 333 334 const onChangeDescription = useCallback( 335 (newText: string) => { 336 const richText = new RichTextAPI({text: newText}) 337 richText.detectFacetsWithoutResolution() 338 339 setDescriptionRt(richText) 340 }, 341 [setDescriptionRt], 342 ) 343 344 const title = list 345 ? isCurateList 346 ? _(msg`Edit user list`) 347 : _(msg`Edit moderation list`) 348 : isCurateList 349 ? _(msg`Create user list`) 350 : _(msg`Create moderation list`) 351 352 const displayNamePlaceholder = isCurateList 353 ? _(msg`e.g. Great Posters`) 354 : _(msg`e.g. Spammers`) 355 356 const descriptionPlaceholder = isCurateList 357 ? _(msg`e.g. The posters who never miss.`) 358 : _(msg`e.g. Users that repeatedly reply with ads.`) 359 360 return ( 361 <Dialog.ScrollableInner 362 label={title} 363 style={[a.overflow_hidden, web({maxWidth: 500})]} 364 contentContainerStyle={[a.px_0, a.pt_0]} 365 header={ 366 <Dialog.Header renderLeft={cancelButton} renderRight={saveButton}> 367 <Dialog.HeaderText>{title}</Dialog.HeaderText> 368 </Dialog.Header> 369 }> 370 {isUpdateListError && ( 371 <ErrorMessage message={cleanError(updateListError)} /> 372 )} 373 {isCreateListError && ( 374 <ErrorMessage message={cleanError(createListError)} /> 375 )} 376 {imageError !== '' && <ErrorMessage message={imageError} />} 377 <View style={[a.pt_xl, a.px_xl, a.gap_xl]}> 378 <View> 379 <TextField.LabelText> 380 <Trans>List avatar</Trans> 381 </TextField.LabelText> 382 <View style={[a.align_start]}> 383 <EditableUserAvatar 384 size={80} 385 avatar={listAvatar} 386 onSelectNewAvatar={onSelectNewAvatar} 387 type="list" 388 /> 389 </View> 390 </View> 391 <View> 392 <TextField.LabelText> 393 <Trans>List name</Trans> 394 </TextField.LabelText> 395 <TextField.Root isInvalid={displayNameTooLong || displayNameTooShort}> 396 <Dialog.Input 397 defaultValue={displayName} 398 onChangeText={onChangeDisplayName} 399 label={_(msg`Name`)} 400 placeholder={displayNamePlaceholder} 401 testID="editListNameInput" 402 /> 403 </TextField.Root> 404 {(displayNameTooLong || displayNameTooShort) && ( 405 <Text 406 style={[ 407 a.text_sm, 408 a.mt_xs, 409 a.font_bold, 410 {color: t.palette.negative_400}, 411 ]}> 412 {displayNameTooLong ? ( 413 <Trans> 414 List name is too long.{' '} 415 <Plural 416 value={DISPLAY_NAME_MAX_GRAPHEMES} 417 other="The maximum number of characters is #." 418 /> 419 </Trans> 420 ) : displayNameTooShort ? ( 421 <Trans>List must have a name.</Trans> 422 ) : null} 423 </Text> 424 )} 425 </View> 426 427 <View> 428 <TextField.LabelText> 429 <Trans>List description</Trans> 430 </TextField.LabelText> 431 <TextField.Root isInvalid={descriptionTooLong}> 432 <Dialog.Input 433 defaultValue={descriptionRt.text} 434 onChangeText={onChangeDescription} 435 multiline 436 label={_(msg`Description`)} 437 placeholder={descriptionPlaceholder} 438 testID="editListDescriptionInput" 439 /> 440 </TextField.Root> 441 {descriptionTooLong && ( 442 <Text 443 style={[ 444 a.text_sm, 445 a.mt_xs, 446 a.font_bold, 447 {color: t.palette.negative_400}, 448 ]}> 449 <Trans> 450 List description is too long.{' '} 451 <Plural 452 value={DESCRIPTION_MAX_GRAPHEMES} 453 other="The maximum number of characters is #." 454 /> 455 </Trans> 456 </Text> 457 )} 458 </View> 459 </View> 460 </Dialog.ScrollableInner> 461 ) 462}