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