Bluesky app fork with some witchin' additions 馃挮
at main 660 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 returnKeyType="done" 137 label={_(msg`Enter a word or tag`)} 138 placeholder={_(msg`Enter a word or tag`)} 139 value={field} 140 onChangeText={value => { 141 if (error) { 142 setError('') 143 } 144 setField(value) 145 }} 146 onSubmitEditing={submit} 147 /> 148 </View> 149 150 <View style={[a.pb_xl, a.gap_sm]}> 151 <Toggle.Group 152 label={_(msg`Select how long to mute this word for.`)} 153 type="radio" 154 values={durations} 155 onChange={setDurations}> 156 <Text 157 style={[ 158 a.pb_xs, 159 a.text_sm, 160 a.font_semi_bold, 161 t.atoms.text_contrast_medium, 162 ]}> 163 <Trans>Duration:</Trans> 164 </Text> 165 166 <View 167 style={[ 168 gtMobile && [a.flex_row, a.align_center, a.justify_start], 169 a.gap_sm, 170 ]}> 171 <View 172 style={[ 173 a.flex_1, 174 a.flex_row, 175 a.justify_start, 176 a.align_center, 177 a.gap_sm, 178 ]}> 179 <Toggle.Item 180 label={_(msg`Mute this word until you unmute it`)} 181 name="forever" 182 style={[a.flex_1]}> 183 <TargetToggle> 184 <View 185 style={[a.flex_1, a.flex_row, a.align_center, a.gap_sm]}> 186 <Toggle.Radio /> 187 <Toggle.LabelText style={[a.flex_1, a.leading_tight]}> 188 <Trans>Forever</Trans> 189 </Toggle.LabelText> 190 </View> 191 </TargetToggle> 192 </Toggle.Item> 193 194 <Toggle.Item 195 label={_(msg`Mute this word for 24 hours`)} 196 name="24_hours" 197 style={[a.flex_1]}> 198 <TargetToggle> 199 <View 200 style={[a.flex_1, a.flex_row, a.align_center, a.gap_sm]}> 201 <Toggle.Radio /> 202 <Toggle.LabelText style={[a.flex_1, a.leading_tight]}> 203 <Trans>24 hours</Trans> 204 </Toggle.LabelText> 205 </View> 206 </TargetToggle> 207 </Toggle.Item> 208 </View> 209 210 <View 211 style={[ 212 a.flex_1, 213 a.flex_row, 214 a.justify_start, 215 a.align_center, 216 a.gap_sm, 217 ]}> 218 <Toggle.Item 219 label={_(msg`Mute this word for 7 days`)} 220 name="7_days" 221 style={[a.flex_1]}> 222 <TargetToggle> 223 <View 224 style={[a.flex_1, a.flex_row, a.align_center, a.gap_sm]}> 225 <Toggle.Radio /> 226 <Toggle.LabelText style={[a.flex_1, a.leading_tight]}> 227 <Trans>7 days</Trans> 228 </Toggle.LabelText> 229 </View> 230 </TargetToggle> 231 </Toggle.Item> 232 233 <Toggle.Item 234 label={_(msg`Mute this word for 30 days`)} 235 name="30_days" 236 style={[a.flex_1]}> 237 <TargetToggle> 238 <View 239 style={[a.flex_1, a.flex_row, a.align_center, a.gap_sm]}> 240 <Toggle.Radio /> 241 <Toggle.LabelText style={[a.flex_1, a.leading_tight]}> 242 <Trans>30 days</Trans> 243 </Toggle.LabelText> 244 </View> 245 </TargetToggle> 246 </Toggle.Item> 247 </View> 248 </View> 249 </Toggle.Group> 250 251 <Toggle.Group 252 label={_(msg`Select what content this mute word should apply to.`)} 253 type="radio" 254 values={targets} 255 onChange={setTargets}> 256 <Text 257 style={[ 258 a.pb_xs, 259 a.text_sm, 260 a.font_semi_bold, 261 t.atoms.text_contrast_medium, 262 ]}> 263 <Trans>Mute in:</Trans> 264 </Text> 265 266 <View style={[a.flex_row, a.align_center, a.gap_sm, a.flex_wrap]}> 267 <Toggle.Item 268 label={_(msg`Mute this word in post text and tags`)} 269 name="content" 270 style={[a.flex_1]}> 271 <TargetToggle> 272 <View 273 style={[a.flex_1, a.flex_row, a.align_center, a.gap_sm]}> 274 <Toggle.Radio /> 275 <Toggle.LabelText style={[a.flex_1, a.leading_tight]}> 276 <Trans>Text & tags</Trans> 277 </Toggle.LabelText> 278 </View> 279 <PageText size="sm" /> 280 </TargetToggle> 281 </Toggle.Item> 282 283 <Toggle.Item 284 label={_(msg`Mute this word in tags only`)} 285 name="tag" 286 style={[a.flex_1]}> 287 <TargetToggle> 288 <View 289 style={[a.flex_1, a.flex_row, a.align_center, a.gap_sm]}> 290 <Toggle.Radio /> 291 <Toggle.LabelText style={[a.flex_1, a.leading_tight]}> 292 <Trans>Tags only</Trans> 293 </Toggle.LabelText> 294 </View> 295 <Hashtag size="sm" /> 296 </TargetToggle> 297 </Toggle.Item> 298 </View> 299 </Toggle.Group> 300 301 <View> 302 <Text 303 style={[ 304 a.pb_xs, 305 a.text_sm, 306 a.font_semi_bold, 307 t.atoms.text_contrast_medium, 308 ]}> 309 <Trans>Options:</Trans> 310 </Text> 311 <Toggle.Item 312 label={_(msg`Do not apply this mute word to users you follow`)} 313 name="exclude_following" 314 style={[a.flex_row, a.justify_between]} 315 value={excludeFollowing} 316 onChange={setExcludeFollowing}> 317 <TargetToggle> 318 <View style={[a.flex_1, a.flex_row, a.align_center, a.gap_sm]}> 319 <Toggle.Checkbox /> 320 <Toggle.LabelText style={[a.flex_1, a.leading_tight]}> 321 <Trans>Exclude users you follow</Trans> 322 </Toggle.LabelText> 323 </View> 324 </TargetToggle> 325 </Toggle.Item> 326 </View> 327 328 <View style={[a.pt_xs]}> 329 <Button 330 disabled={isPending || !field} 331 label={_(msg`Add mute word with chosen settings`)} 332 size="large" 333 color="primary" 334 variant="solid" 335 style={[]} 336 onPress={submit}> 337 <ButtonText> 338 <Trans>Add</Trans> 339 </ButtonText> 340 <ButtonIcon icon={isPending ? Loader : Plus} position="right" /> 341 </Button> 342 </View> 343 344 {error && ( 345 <View 346 style={[ 347 a.mb_lg, 348 a.flex_row, 349 a.rounded_sm, 350 a.p_md, 351 a.mb_xs, 352 t.atoms.bg_contrast_25, 353 { 354 backgroundColor: t.palette.negative_400, 355 }, 356 ]}> 357 <Text 358 style={[ 359 a.italic, 360 {color: t.palette.white}, 361 native({marginTop: 2}), 362 ]}> 363 {error} 364 </Text> 365 </View> 366 )} 367 </View> 368 369 <Divider /> 370 371 <View style={[a.pt_2xl]}> 372 <Text 373 style={[ 374 a.text_md, 375 a.font_semi_bold, 376 a.pb_md, 377 t.atoms.text_contrast_high, 378 ]}> 379 <Trans>Your muted words</Trans> 380 </Text> 381 382 {isPreferencesLoading ? ( 383 <Loader /> 384 ) : preferencesError || !preferences ? ( 385 <View 386 style={[a.py_md, a.px_lg, a.rounded_md, t.atoms.bg_contrast_25]}> 387 <Text style={[a.italic, t.atoms.text_contrast_high]}> 388 <Trans> 389 We're sorry, but we weren't able to load your muted words at 390 this time. Please try again. 391 </Trans> 392 </Text> 393 </View> 394 ) : preferences.moderationPrefs.mutedWords.length ? ( 395 [...preferences.moderationPrefs.mutedWords] 396 .reverse() 397 .map((word, i) => ( 398 <MutedWordRow 399 key={word.value + i} 400 word={word} 401 style={[i % 2 === 0 && t.atoms.bg_contrast_25]} 402 /> 403 )) 404 ) : ( 405 <View 406 style={[a.py_md, a.px_lg, a.rounded_md, t.atoms.bg_contrast_25]}> 407 <Text style={[a.italic, t.atoms.text_contrast_high]}> 408 <Trans>You haven't muted any words or tags yet</Trans> 409 </Text> 410 </View> 411 )} 412 </View> 413 414 {IS_NATIVE && <View style={{height: 20}} />} 415 </View> 416 417 <Dialog.Close /> 418 </Dialog.ScrollableInner> 419 ) 420} 421 422function MutedWordRow({ 423 style, 424 word, 425}: ViewStyleProp & {word: AppBskyActorDefs.MutedWord}) { 426 const t = useTheme() 427 const {_} = useLingui() 428 const {isPending, mutateAsync: removeMutedWord} = useRemoveMutedWordMutation() 429 const {mutateAsync: updateMutedWord} = useUpdateMutedWordMutation() 430 const control = Prompt.usePromptControl() 431 const expiryDate = word.expiresAt ? new Date(word.expiresAt) : undefined 432 const isExpired = expiryDate && expiryDate < new Date() 433 const formatDistance = useFormatDistance() 434 435 const enableSquareButtons = useEnableSquareButtons() 436 437 const remove = React.useCallback(async () => { 438 control.close() 439 removeMutedWord(word) 440 }, [removeMutedWord, word, control]) 441 442 const renew = (days?: number) => { 443 updateMutedWord({ 444 ...word, 445 expiresAt: days 446 ? new Date(Date.now() + days * ONE_DAY).toISOString() 447 : undefined, 448 }) 449 } 450 451 return ( 452 <> 453 <Prompt.Basic 454 control={control} 455 title={_(msg`Are you sure?`)} 456 description={_( 457 msg`This will delete "${word.value}" from your muted words. You can always add it back later.`, 458 )} 459 onConfirm={remove} 460 confirmButtonCta={_(msg`Remove`)} 461 confirmButtonColor="negative" 462 /> 463 464 <View 465 style={[ 466 a.flex_row, 467 a.justify_between, 468 a.py_md, 469 a.px_lg, 470 a.rounded_md, 471 a.gap_md, 472 style, 473 ]}> 474 <View style={[a.flex_1, a.gap_xs]}> 475 <View style={[a.flex_row, a.align_center, a.gap_sm]}> 476 <Text 477 style={[ 478 a.flex_1, 479 a.leading_snug, 480 a.font_semi_bold, 481 web({ 482 overflowWrap: 'break-word', 483 wordBreak: 'break-word', 484 }), 485 ]}> 486 {word.targets.find(t => t === 'content') ? ( 487 <Trans comment="Pattern: {wordValue} in text, tags"> 488 {word.value}{' '} 489 <Text style={[a.font_normal, t.atoms.text_contrast_medium]}> 490 in{' '} 491 <Text 492 style={[a.font_semi_bold, t.atoms.text_contrast_medium]}> 493 text & tags 494 </Text> 495 </Text> 496 </Trans> 497 ) : ( 498 <Trans comment="Pattern: {wordValue} in tags"> 499 {word.value}{' '} 500 <Text style={[a.font_normal, t.atoms.text_contrast_medium]}> 501 in{' '} 502 <Text 503 style={[a.font_semi_bold, t.atoms.text_contrast_medium]}> 504 tags 505 </Text> 506 </Text> 507 </Trans> 508 )} 509 </Text> 510 </View> 511 512 {(expiryDate || word.actorTarget === 'exclude-following') && ( 513 <View style={[a.flex_1, a.flex_row, a.align_center, a.flex_wrap]}> 514 {expiryDate && 515 (isExpired ? ( 516 <> 517 <Text 518 style={[ 519 a.text_xs, 520 a.leading_snug, 521 t.atoms.text_contrast_medium, 522 ]}> 523 <Trans>Expired</Trans> 524 </Text> 525 <Text 526 style={[ 527 a.text_xs, 528 a.leading_snug, 529 t.atoms.text_contrast_medium, 530 ]}> 531 {' '} 532 </Text> 533 <Menu.Root> 534 <Menu.Trigger label={_(msg`Renew mute word`)}> 535 {({props}) => ( 536 <Text 537 {...props} 538 style={[ 539 a.text_xs, 540 a.leading_snug, 541 a.font_semi_bold, 542 {color: t.palette.primary_500}, 543 ]}> 544 <Trans>Renew</Trans> 545 </Text> 546 )} 547 </Menu.Trigger> 548 <Menu.Outer> 549 <Menu.LabelText> 550 <Trans>Renew duration</Trans> 551 </Menu.LabelText> 552 <Menu.Group> 553 <Menu.Item 554 label={_(msg`24 hours`)} 555 onPress={() => renew(1)}> 556 <Menu.ItemText> 557 <Trans>24 hours</Trans> 558 </Menu.ItemText> 559 </Menu.Item> 560 <Menu.Item 561 label={_(msg`7 days`)} 562 onPress={() => renew(7)}> 563 <Menu.ItemText> 564 <Trans>7 days</Trans> 565 </Menu.ItemText> 566 </Menu.Item> 567 <Menu.Item 568 label={_(msg`30 days`)} 569 onPress={() => renew(30)}> 570 <Menu.ItemText> 571 <Trans>30 days</Trans> 572 </Menu.ItemText> 573 </Menu.Item> 574 <Menu.Item 575 label={_(msg`Forever`)} 576 onPress={() => renew()}> 577 <Menu.ItemText> 578 <Trans>Forever</Trans> 579 </Menu.ItemText> 580 </Menu.Item> 581 </Menu.Group> 582 </Menu.Outer> 583 </Menu.Root> 584 </> 585 ) : ( 586 <Text 587 style={[ 588 a.text_xs, 589 a.leading_snug, 590 t.atoms.text_contrast_medium, 591 ]}> 592 <Trans> 593 Expires{' '} 594 {formatDistance(expiryDate, new Date(), { 595 addSuffix: true, 596 })} 597 </Trans> 598 </Text> 599 ))} 600 {word.actorTarget === 'exclude-following' && ( 601 <Text 602 style={[ 603 a.text_xs, 604 a.leading_snug, 605 t.atoms.text_contrast_medium, 606 ]}> 607 {expiryDate ? ' ' : ''} 608 <Trans>Excludes users you follow</Trans> 609 </Text> 610 )} 611 </View> 612 )} 613 </View> 614 615 <Button 616 label={_(msg`Remove mute word from your list`)} 617 size="tiny" 618 shape={enableSquareButtons ? 'square' : 'round'} 619 variant="outline" 620 color="secondary" 621 onPress={() => control.open()} 622 style={[a.ml_sm]}> 623 <ButtonIcon icon={isPending ? Loader : X} /> 624 </Button> 625 </View> 626 </> 627 ) 628} 629 630function TargetToggle({children}: React.PropsWithChildren<{}>) { 631 const t = useTheme() 632 const ctx = Toggle.useItemContext() 633 const {gtMobile} = useBreakpoints() 634 return ( 635 <View 636 style={[ 637 a.flex_row, 638 a.align_center, 639 a.justify_between, 640 a.gap_xs, 641 a.flex_1, 642 a.py_sm, 643 a.px_sm, 644 gtMobile && a.px_md, 645 a.rounded_sm, 646 t.atoms.bg_contrast_25, 647 (ctx.hovered || ctx.focused) && t.atoms.bg_contrast_50, 648 ctx.selected && [ 649 { 650 backgroundColor: t.palette.primary_50, 651 }, 652 ], 653 ctx.disabled && { 654 opacity: 0.8, 655 }, 656 ]}> 657 {children} 658 </View> 659 ) 660}