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