Bluesky app fork with some witchin' additions 馃挮
at jean/pds-label 659 lines 22 kB view raw
1import React from 'react' 2import {View} from 'react-native' 3import {type AppBskyActorDefs, sanitizeMutedWordValue} from '@atproto/api' 4import {msg} from '@lingui/core/macro' 5import {useLingui} from '@lingui/react' 6import {Trans} from '@lingui/react/macro' 7 8import {logger} from '#/logger' 9import {useEnableSquareButtons} from '#/state/preferences/enable-square-buttons' 10import { 11 usePreferencesQuery, 12 useRemoveMutedWordMutation, 13 useUpdateMutedWordMutation, 14 useUpsertMutedWordsMutation, 15} from '#/state/queries/preferences' 16import { 17 atoms as a, 18 native, 19 useBreakpoints, 20 useTheme, 21 type ViewStyleProp, 22 web, 23} from '#/alf' 24import {Button, ButtonIcon, ButtonText} from '#/components/Button' 25import * as Dialog from '#/components/Dialog' 26import {useGlobalDialogsControlContext} from '#/components/dialogs/Context' 27import {Divider} from '#/components/Divider' 28import * as Toggle from '#/components/forms/Toggle' 29import {useFormatDistance} from '#/components/hooks/dates' 30import {Hashtag_Stroke2_Corner0_Rounded as Hashtag} from '#/components/icons/Hashtag' 31import {PageText_Stroke2_Corner0_Rounded as PageText} from '#/components/icons/PageText' 32import {PlusLarge_Stroke2_Corner0_Rounded as Plus} from '#/components/icons/Plus' 33import {TimesLarge_Stroke2_Corner0_Rounded as X} from '#/components/icons/Times' 34import {Loader} from '#/components/Loader' 35import * as Menu from '#/components/Menu' 36import * as Prompt from '#/components/Prompt' 37import {Text} from '#/components/Typography' 38import {IS_NATIVE} from '#/env' 39 40const ONE_DAY = 24 * 60 * 60 * 1000 41 42export function MutedWordsDialog() { 43 const {mutedWordsDialogControl: control} = useGlobalDialogsControlContext() 44 return ( 45 <Dialog.Outer control={control}> 46 <Dialog.Handle /> 47 <MutedWordsInner /> 48 </Dialog.Outer> 49 ) 50} 51 52function MutedWordsInner() { 53 const t = useTheme() 54 const {_} = useLingui() 55 const {gtMobile} = useBreakpoints() 56 const { 57 isLoading: isPreferencesLoading, 58 data: preferences, 59 error: preferencesError, 60 } = usePreferencesQuery() 61 const {isPending, mutateAsync: addMutedWord} = useUpsertMutedWordsMutation() 62 const [field, setField] = React.useState('') 63 const [targets, setTargets] = React.useState(['content']) 64 const [error, setError] = React.useState('') 65 const [durations, setDurations] = React.useState(['forever']) 66 const [excludeFollowing, setExcludeFollowing] = React.useState(false) 67 68 const submit = React.useCallback(async () => { 69 const sanitizedValue = sanitizeMutedWordValue(field) 70 const surfaces = ['tag', targets.includes('content') && 'content'].filter( 71 Boolean, 72 ) as AppBskyActorDefs.MutedWord['targets'] 73 const actorTarget = excludeFollowing ? 'exclude-following' : 'all' 74 75 const now = Date.now() 76 const rawDuration = durations.at(0) 77 // undefined evaluates to 'forever' 78 let duration: string | undefined 79 80 if (rawDuration === '24_hours') { 81 duration = new Date(now + ONE_DAY).toISOString() 82 } else if (rawDuration === '7_days') { 83 duration = new Date(now + 7 * ONE_DAY).toISOString() 84 } else if (rawDuration === '30_days') { 85 duration = new Date(now + 30 * ONE_DAY).toISOString() 86 } 87 88 if (!sanitizedValue || !surfaces.length) { 89 setField('') 90 setError(_(msg`Please enter a valid word, tag, or phrase to mute`)) 91 return 92 } 93 94 try { 95 // send raw value and rely on SDK as sanitization source of truth 96 await addMutedWord([ 97 { 98 value: field, 99 targets: surfaces, 100 actorTarget, 101 expiresAt: duration, 102 }, 103 ]) 104 setField('') 105 } catch (e: any) { 106 logger.error(`Failed to save muted word`, {message: e.message}) 107 setError(e.message) 108 } 109 }, [_, field, targets, addMutedWord, setField, durations, excludeFollowing]) 110 111 return ( 112 <Dialog.ScrollableInner label={_(msg`Manage your muted words and tags`)}> 113 <View> 114 <Text 115 style={[ 116 a.text_md, 117 a.font_semi_bold, 118 a.pb_sm, 119 t.atoms.text_contrast_high, 120 ]}> 121 <Trans>Add muted words and tags</Trans> 122 </Text> 123 <Text style={[a.pb_lg, a.leading_snug, t.atoms.text_contrast_medium]}> 124 <Trans> 125 Posts can be muted based on their text, their tags, or both. We 126 recommend avoiding common words that appear in many posts, since it 127 can result in no posts being shown. 128 </Trans> 129 </Text> 130 131 <View style={[a.pb_sm]}> 132 <Dialog.Input 133 autoCorrect={false} 134 autoCapitalize="none" 135 autoComplete="off" 136 label={_(msg`Enter a word or tag`)} 137 placeholder={_(msg`Enter a word or tag`)} 138 value={field} 139 onChangeText={value => { 140 if (error) { 141 setError('') 142 } 143 setField(value) 144 }} 145 onSubmitEditing={submit} 146 /> 147 </View> 148 149 <View style={[a.pb_xl, a.gap_sm]}> 150 <Toggle.Group 151 label={_(msg`Select how long to mute this word for.`)} 152 type="radio" 153 values={durations} 154 onChange={setDurations}> 155 <Text 156 style={[ 157 a.pb_xs, 158 a.text_sm, 159 a.font_semi_bold, 160 t.atoms.text_contrast_medium, 161 ]}> 162 <Trans>Duration:</Trans> 163 </Text> 164 165 <View 166 style={[ 167 gtMobile && [a.flex_row, a.align_center, a.justify_start], 168 a.gap_sm, 169 ]}> 170 <View 171 style={[ 172 a.flex_1, 173 a.flex_row, 174 a.justify_start, 175 a.align_center, 176 a.gap_sm, 177 ]}> 178 <Toggle.Item 179 label={_(msg`Mute this word until you unmute it`)} 180 name="forever" 181 style={[a.flex_1]}> 182 <TargetToggle> 183 <View 184 style={[a.flex_1, a.flex_row, a.align_center, a.gap_sm]}> 185 <Toggle.Radio /> 186 <Toggle.LabelText style={[a.flex_1, a.leading_tight]}> 187 <Trans>Forever</Trans> 188 </Toggle.LabelText> 189 </View> 190 </TargetToggle> 191 </Toggle.Item> 192 193 <Toggle.Item 194 label={_(msg`Mute this word for 24 hours`)} 195 name="24_hours" 196 style={[a.flex_1]}> 197 <TargetToggle> 198 <View 199 style={[a.flex_1, a.flex_row, a.align_center, a.gap_sm]}> 200 <Toggle.Radio /> 201 <Toggle.LabelText style={[a.flex_1, a.leading_tight]}> 202 <Trans>24 hours</Trans> 203 </Toggle.LabelText> 204 </View> 205 </TargetToggle> 206 </Toggle.Item> 207 </View> 208 209 <View 210 style={[ 211 a.flex_1, 212 a.flex_row, 213 a.justify_start, 214 a.align_center, 215 a.gap_sm, 216 ]}> 217 <Toggle.Item 218 label={_(msg`Mute this word for 7 days`)} 219 name="7_days" 220 style={[a.flex_1]}> 221 <TargetToggle> 222 <View 223 style={[a.flex_1, a.flex_row, a.align_center, a.gap_sm]}> 224 <Toggle.Radio /> 225 <Toggle.LabelText style={[a.flex_1, a.leading_tight]}> 226 <Trans>7 days</Trans> 227 </Toggle.LabelText> 228 </View> 229 </TargetToggle> 230 </Toggle.Item> 231 232 <Toggle.Item 233 label={_(msg`Mute this word for 30 days`)} 234 name="30_days" 235 style={[a.flex_1]}> 236 <TargetToggle> 237 <View 238 style={[a.flex_1, a.flex_row, a.align_center, a.gap_sm]}> 239 <Toggle.Radio /> 240 <Toggle.LabelText style={[a.flex_1, a.leading_tight]}> 241 <Trans>30 days</Trans> 242 </Toggle.LabelText> 243 </View> 244 </TargetToggle> 245 </Toggle.Item> 246 </View> 247 </View> 248 </Toggle.Group> 249 250 <Toggle.Group 251 label={_(msg`Select what content this mute word should apply to.`)} 252 type="radio" 253 values={targets} 254 onChange={setTargets}> 255 <Text 256 style={[ 257 a.pb_xs, 258 a.text_sm, 259 a.font_semi_bold, 260 t.atoms.text_contrast_medium, 261 ]}> 262 <Trans>Mute in:</Trans> 263 </Text> 264 265 <View style={[a.flex_row, a.align_center, a.gap_sm, a.flex_wrap]}> 266 <Toggle.Item 267 label={_(msg`Mute this word in post text and tags`)} 268 name="content" 269 style={[a.flex_1]}> 270 <TargetToggle> 271 <View 272 style={[a.flex_1, a.flex_row, a.align_center, a.gap_sm]}> 273 <Toggle.Radio /> 274 <Toggle.LabelText style={[a.flex_1, a.leading_tight]}> 275 <Trans>Text & tags</Trans> 276 </Toggle.LabelText> 277 </View> 278 <PageText size="sm" /> 279 </TargetToggle> 280 </Toggle.Item> 281 282 <Toggle.Item 283 label={_(msg`Mute this word in tags only`)} 284 name="tag" 285 style={[a.flex_1]}> 286 <TargetToggle> 287 <View 288 style={[a.flex_1, a.flex_row, a.align_center, a.gap_sm]}> 289 <Toggle.Radio /> 290 <Toggle.LabelText style={[a.flex_1, a.leading_tight]}> 291 <Trans>Tags only</Trans> 292 </Toggle.LabelText> 293 </View> 294 <Hashtag size="sm" /> 295 </TargetToggle> 296 </Toggle.Item> 297 </View> 298 </Toggle.Group> 299 300 <View> 301 <Text 302 style={[ 303 a.pb_xs, 304 a.text_sm, 305 a.font_semi_bold, 306 t.atoms.text_contrast_medium, 307 ]}> 308 <Trans>Options:</Trans> 309 </Text> 310 <Toggle.Item 311 label={_(msg`Do not apply this mute word to users you follow`)} 312 name="exclude_following" 313 style={[a.flex_row, a.justify_between]} 314 value={excludeFollowing} 315 onChange={setExcludeFollowing}> 316 <TargetToggle> 317 <View style={[a.flex_1, a.flex_row, a.align_center, a.gap_sm]}> 318 <Toggle.Checkbox /> 319 <Toggle.LabelText style={[a.flex_1, a.leading_tight]}> 320 <Trans>Exclude users you follow</Trans> 321 </Toggle.LabelText> 322 </View> 323 </TargetToggle> 324 </Toggle.Item> 325 </View> 326 327 <View style={[a.pt_xs]}> 328 <Button 329 disabled={isPending || !field} 330 label={_(msg`Add mute word with chosen settings`)} 331 size="large" 332 color="primary" 333 variant="solid" 334 style={[]} 335 onPress={submit}> 336 <ButtonText> 337 <Trans>Add</Trans> 338 </ButtonText> 339 <ButtonIcon icon={isPending ? Loader : Plus} position="right" /> 340 </Button> 341 </View> 342 343 {error && ( 344 <View 345 style={[ 346 a.mb_lg, 347 a.flex_row, 348 a.rounded_sm, 349 a.p_md, 350 a.mb_xs, 351 t.atoms.bg_contrast_25, 352 { 353 backgroundColor: t.palette.negative_400, 354 }, 355 ]}> 356 <Text 357 style={[ 358 a.italic, 359 {color: t.palette.white}, 360 native({marginTop: 2}), 361 ]}> 362 {error} 363 </Text> 364 </View> 365 )} 366 </View> 367 368 <Divider /> 369 370 <View style={[a.pt_2xl]}> 371 <Text 372 style={[ 373 a.text_md, 374 a.font_semi_bold, 375 a.pb_md, 376 t.atoms.text_contrast_high, 377 ]}> 378 <Trans>Your muted words</Trans> 379 </Text> 380 381 {isPreferencesLoading ? ( 382 <Loader /> 383 ) : preferencesError || !preferences ? ( 384 <View 385 style={[a.py_md, a.px_lg, a.rounded_md, t.atoms.bg_contrast_25]}> 386 <Text style={[a.italic, t.atoms.text_contrast_high]}> 387 <Trans> 388 We're sorry, but we weren't able to load your muted words at 389 this time. Please try again. 390 </Trans> 391 </Text> 392 </View> 393 ) : preferences.moderationPrefs.mutedWords.length ? ( 394 [...preferences.moderationPrefs.mutedWords] 395 .reverse() 396 .map((word, i) => ( 397 <MutedWordRow 398 key={word.value + i} 399 word={word} 400 style={[i % 2 === 0 && t.atoms.bg_contrast_25]} 401 /> 402 )) 403 ) : ( 404 <View 405 style={[a.py_md, a.px_lg, a.rounded_md, t.atoms.bg_contrast_25]}> 406 <Text style={[a.italic, t.atoms.text_contrast_high]}> 407 <Trans>You haven't muted any words or tags yet</Trans> 408 </Text> 409 </View> 410 )} 411 </View> 412 413 {IS_NATIVE && <View style={{height: 20}} />} 414 </View> 415 416 <Dialog.Close /> 417 </Dialog.ScrollableInner> 418 ) 419} 420 421function MutedWordRow({ 422 style, 423 word, 424}: ViewStyleProp & {word: AppBskyActorDefs.MutedWord}) { 425 const t = useTheme() 426 const {_} = useLingui() 427 const {isPending, mutateAsync: removeMutedWord} = useRemoveMutedWordMutation() 428 const {mutateAsync: updateMutedWord} = useUpdateMutedWordMutation() 429 const control = Prompt.usePromptControl() 430 const expiryDate = word.expiresAt ? new Date(word.expiresAt) : undefined 431 const isExpired = expiryDate && expiryDate < new Date() 432 const formatDistance = useFormatDistance() 433 434 const enableSquareButtons = useEnableSquareButtons() 435 436 const remove = React.useCallback(async () => { 437 control.close() 438 removeMutedWord(word) 439 }, [removeMutedWord, word, control]) 440 441 const renew = (days?: number) => { 442 updateMutedWord({ 443 ...word, 444 expiresAt: days 445 ? new Date(Date.now() + days * ONE_DAY).toISOString() 446 : undefined, 447 }) 448 } 449 450 return ( 451 <> 452 <Prompt.Basic 453 control={control} 454 title={_(msg`Are you sure?`)} 455 description={_( 456 msg`This will delete "${word.value}" from your muted words. You can always add it back later.`, 457 )} 458 onConfirm={remove} 459 confirmButtonCta={_(msg`Remove`)} 460 confirmButtonColor="negative" 461 /> 462 463 <View 464 style={[ 465 a.flex_row, 466 a.justify_between, 467 a.py_md, 468 a.px_lg, 469 a.rounded_md, 470 a.gap_md, 471 style, 472 ]}> 473 <View style={[a.flex_1, a.gap_xs]}> 474 <View style={[a.flex_row, a.align_center, a.gap_sm]}> 475 <Text 476 style={[ 477 a.flex_1, 478 a.leading_snug, 479 a.font_semi_bold, 480 web({ 481 overflowWrap: 'break-word', 482 wordBreak: 'break-word', 483 }), 484 ]}> 485 {word.targets.find(t => t === 'content') ? ( 486 <Trans comment="Pattern: {wordValue} in text, tags"> 487 {word.value}{' '} 488 <Text style={[a.font_normal, t.atoms.text_contrast_medium]}> 489 in{' '} 490 <Text 491 style={[a.font_semi_bold, t.atoms.text_contrast_medium]}> 492 text & tags 493 </Text> 494 </Text> 495 </Trans> 496 ) : ( 497 <Trans comment="Pattern: {wordValue} in tags"> 498 {word.value}{' '} 499 <Text style={[a.font_normal, t.atoms.text_contrast_medium]}> 500 in{' '} 501 <Text 502 style={[a.font_semi_bold, t.atoms.text_contrast_medium]}> 503 tags 504 </Text> 505 </Text> 506 </Trans> 507 )} 508 </Text> 509 </View> 510 511 {(expiryDate || word.actorTarget === 'exclude-following') && ( 512 <View style={[a.flex_1, a.flex_row, a.align_center, a.flex_wrap]}> 513 {expiryDate && 514 (isExpired ? ( 515 <> 516 <Text 517 style={[ 518 a.text_xs, 519 a.leading_snug, 520 t.atoms.text_contrast_medium, 521 ]}> 522 <Trans>Expired</Trans> 523 </Text> 524 <Text 525 style={[ 526 a.text_xs, 527 a.leading_snug, 528 t.atoms.text_contrast_medium, 529 ]}> 530 {' '} 531 </Text> 532 <Menu.Root> 533 <Menu.Trigger label={_(msg`Renew mute word`)}> 534 {({props}) => ( 535 <Text 536 {...props} 537 style={[ 538 a.text_xs, 539 a.leading_snug, 540 a.font_semi_bold, 541 {color: t.palette.primary_500}, 542 ]}> 543 <Trans>Renew</Trans> 544 </Text> 545 )} 546 </Menu.Trigger> 547 <Menu.Outer> 548 <Menu.LabelText> 549 <Trans>Renew duration</Trans> 550 </Menu.LabelText> 551 <Menu.Group> 552 <Menu.Item 553 label={_(msg`24 hours`)} 554 onPress={() => renew(1)}> 555 <Menu.ItemText> 556 <Trans>24 hours</Trans> 557 </Menu.ItemText> 558 </Menu.Item> 559 <Menu.Item 560 label={_(msg`7 days`)} 561 onPress={() => renew(7)}> 562 <Menu.ItemText> 563 <Trans>7 days</Trans> 564 </Menu.ItemText> 565 </Menu.Item> 566 <Menu.Item 567 label={_(msg`30 days`)} 568 onPress={() => renew(30)}> 569 <Menu.ItemText> 570 <Trans>30 days</Trans> 571 </Menu.ItemText> 572 </Menu.Item> 573 <Menu.Item 574 label={_(msg`Forever`)} 575 onPress={() => renew()}> 576 <Menu.ItemText> 577 <Trans>Forever</Trans> 578 </Menu.ItemText> 579 </Menu.Item> 580 </Menu.Group> 581 </Menu.Outer> 582 </Menu.Root> 583 </> 584 ) : ( 585 <Text 586 style={[ 587 a.text_xs, 588 a.leading_snug, 589 t.atoms.text_contrast_medium, 590 ]}> 591 <Trans> 592 Expires{' '} 593 {formatDistance(expiryDate, new Date(), { 594 addSuffix: true, 595 })} 596 </Trans> 597 </Text> 598 ))} 599 {word.actorTarget === 'exclude-following' && ( 600 <Text 601 style={[ 602 a.text_xs, 603 a.leading_snug, 604 t.atoms.text_contrast_medium, 605 ]}> 606 {expiryDate ? ' ' : ''} 607 <Trans>Excludes users you follow</Trans> 608 </Text> 609 )} 610 </View> 611 )} 612 </View> 613 614 <Button 615 label={_(msg`Remove mute word from your list`)} 616 size="tiny" 617 shape={enableSquareButtons ? 'square' : 'round'} 618 variant="outline" 619 color="secondary" 620 onPress={() => control.open()} 621 style={[a.ml_sm]}> 622 <ButtonIcon icon={isPending ? Loader : X} /> 623 </Button> 624 </View> 625 </> 626 ) 627} 628 629function TargetToggle({children}: React.PropsWithChildren<{}>) { 630 const t = useTheme() 631 const ctx = Toggle.useItemContext() 632 const {gtMobile} = useBreakpoints() 633 return ( 634 <View 635 style={[ 636 a.flex_row, 637 a.align_center, 638 a.justify_between, 639 a.gap_xs, 640 a.flex_1, 641 a.py_sm, 642 a.px_sm, 643 gtMobile && a.px_md, 644 a.rounded_sm, 645 t.atoms.bg_contrast_25, 646 (ctx.hovered || ctx.focused) && t.atoms.bg_contrast_50, 647 ctx.selected && [ 648 { 649 backgroundColor: t.palette.primary_50, 650 }, 651 ], 652 ctx.disabled && { 653 opacity: 0.8, 654 }, 655 ]}> 656 {children} 657 </View> 658 ) 659}