Bluesky app fork with some witchin' additions 馃挮 witchsky.app
bluesky fork client
120
fork

Configure Feed

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

at a876aae44ea07494ebea9727350aa060b81f317b 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}