Bluesky app fork with some witchin' additions 馃挮
0
fork

Configure Feed

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

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