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

Configure Feed

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

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