this repo has no description
0
fork

Configure Feed

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

at e28f6d2f370b4e882ed6f23d08ca0f8d94dbac5f 657 lines 22 kB view raw
1import {useCallback, useState} 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 { 10 usePreferencesQuery, 11 useRemoveMutedWordMutation, 12 useUpdateMutedWordMutation, 13 useUpsertMutedWordsMutation, 14} from '#/state/queries/preferences' 15import { 16 atoms as a, 17 native, 18 useBreakpoints, 19 useTheme, 20 type ViewStyleProp, 21 web, 22} from '#/alf' 23import {Button, ButtonIcon, ButtonText} from '#/components/Button' 24import * as Dialog from '#/components/Dialog' 25import {useGlobalDialogsControlContext} from '#/components/dialogs/Context' 26import {Divider} from '#/components/Divider' 27import * as Toggle from '#/components/forms/Toggle' 28import {useFormatDistance} from '#/components/hooks/dates' 29import {Hashtag_Stroke2_Corner0_Rounded as Hashtag} from '#/components/icons/Hashtag' 30import {PageText_Stroke2_Corner0_Rounded as PageText} from '#/components/icons/PageText' 31import {PlusLarge_Stroke2_Corner0_Rounded as Plus} from '#/components/icons/Plus' 32import {TimesLarge_Stroke2_Corner0_Rounded as X} from '#/components/icons/Times' 33import {Loader} from '#/components/Loader' 34import * as Menu from '#/components/Menu' 35import * as Prompt from '#/components/Prompt' 36import {Text} from '#/components/Typography' 37import {IS_NATIVE} from '#/env' 38 39const ONE_DAY = 24 * 60 * 60 * 1000 40 41export function MutedWordsDialog() { 42 const {mutedWordsDialogControl: control} = useGlobalDialogsControlContext() 43 return ( 44 <Dialog.Outer control={control}> 45 <Dialog.Handle /> 46 <MutedWordsInner /> 47 </Dialog.Outer> 48 ) 49} 50 51function MutedWordsInner() { 52 const t = useTheme() 53 const {_} = useLingui() 54 const {gtMobile} = useBreakpoints() 55 const { 56 isLoading: isPreferencesLoading, 57 data: preferences, 58 error: preferencesError, 59 } = usePreferencesQuery() 60 const {isPending, mutateAsync: addMutedWord} = useUpsertMutedWordsMutation() 61 const [field, setField] = useState('') 62 const [targets, setTargets] = useState(['content']) 63 const [error, setError] = useState('') 64 const [durations, setDurations] = useState(['forever']) 65 const [excludeFollowing, setExcludeFollowing] = useState(false) 66 67 const submit = useCallback(async () => { 68 const sanitizedValue = sanitizeMutedWordValue(field) 69 const surfaces = ['tag', targets.includes('content') && 'content'].filter( 70 Boolean, 71 ) as AppBskyActorDefs.MutedWord['targets'] 72 const actorTarget = excludeFollowing ? 'exclude-following' : 'all' 73 74 const now = Date.now() 75 const rawDuration = durations.at(0) 76 // undefined evaluates to 'forever' 77 let duration: string | undefined 78 79 if (rawDuration === '24_hours') { 80 duration = new Date(now + ONE_DAY).toISOString() 81 } else if (rawDuration === '7_days') { 82 duration = new Date(now + 7 * ONE_DAY).toISOString() 83 } else if (rawDuration === '30_days') { 84 duration = new Date(now + 30 * ONE_DAY).toISOString() 85 } 86 87 if (!sanitizedValue || !surfaces.length) { 88 setField('') 89 setError(_(msg`Please enter a valid word, tag, or phrase to mute`)) 90 return 91 } 92 93 try { 94 // send raw value and rely on SDK as sanitization source of truth 95 await addMutedWord([ 96 { 97 value: field, 98 targets: surfaces, 99 actorTarget, 100 expiresAt: duration, 101 }, 102 ]) 103 setField('') 104 } catch (e: any) { 105 logger.error(`Failed to save muted word`, {message: e.message}) 106 setError(e.message) 107 } 108 }, [_, field, targets, addMutedWord, setField, durations, excludeFollowing]) 109 110 return ( 111 <Dialog.ScrollableInner label={_(msg`Manage your muted words and tags`)}> 112 <View> 113 <Text 114 style={[ 115 a.text_md, 116 a.font_semi_bold, 117 a.pb_sm, 118 t.atoms.text_contrast_high, 119 ]}> 120 <Trans>Add muted words and tags</Trans> 121 </Text> 122 <Text style={[a.pb_lg, a.leading_snug, t.atoms.text_contrast_medium]}> 123 <Trans> 124 Posts can be muted based on their text, their tags, or both. We 125 recommend avoiding common words that appear in many posts, since it 126 can result in no posts being shown. 127 </Trans> 128 </Text> 129 130 <View style={[a.pb_sm]}> 131 <Dialog.Input 132 autoCorrect={false} 133 autoCapitalize="none" 134 autoComplete="off" 135 returnKeyType="done" 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 remove = useCallback(async () => { 435 control.close() 436 removeMutedWord(word) 437 }, [removeMutedWord, word, control]) 438 439 const renew = (days?: number) => { 440 updateMutedWord({ 441 ...word, 442 expiresAt: days 443 ? new Date(Date.now() + days * ONE_DAY).toISOString() 444 : undefined, 445 }) 446 } 447 448 return ( 449 <> 450 <Prompt.Basic 451 control={control} 452 title={_(msg`Are you sure?`)} 453 description={_( 454 msg`This will delete "${word.value}" from your muted words. You can always add it back later.`, 455 )} 456 onConfirm={remove} 457 confirmButtonCta={_(msg`Remove`)} 458 confirmButtonColor="negative" 459 /> 460 461 <View 462 style={[ 463 a.flex_row, 464 a.justify_between, 465 a.py_md, 466 a.px_lg, 467 a.rounded_md, 468 a.gap_md, 469 style, 470 ]}> 471 <View style={[a.flex_1, a.gap_xs]}> 472 <View style={[a.flex_row, a.align_center, a.gap_sm]}> 473 <Text 474 style={[ 475 a.flex_1, 476 a.leading_snug, 477 a.font_semi_bold, 478 web({ 479 overflowWrap: 'break-word', 480 wordBreak: 'break-word', 481 }), 482 ]}> 483 {word.targets.find(t => t === 'content') ? ( 484 <Trans comment="Pattern: {wordValue} in text, tags"> 485 {word.value}{' '} 486 <Text style={[a.font_normal, t.atoms.text_contrast_medium]}> 487 in{' '} 488 <Text 489 style={[a.font_semi_bold, t.atoms.text_contrast_medium]}> 490 text & tags 491 </Text> 492 </Text> 493 </Trans> 494 ) : ( 495 <Trans comment="Pattern: {wordValue} in tags"> 496 {word.value}{' '} 497 <Text style={[a.font_normal, t.atoms.text_contrast_medium]}> 498 in{' '} 499 <Text 500 style={[a.font_semi_bold, t.atoms.text_contrast_medium]}> 501 tags 502 </Text> 503 </Text> 504 </Trans> 505 )} 506 </Text> 507 </View> 508 509 {(expiryDate || word.actorTarget === 'exclude-following') && ( 510 <View style={[a.flex_1, a.flex_row, a.align_center, a.flex_wrap]}> 511 {expiryDate && 512 (isExpired ? ( 513 <> 514 <Text 515 style={[ 516 a.text_xs, 517 a.leading_snug, 518 t.atoms.text_contrast_medium, 519 ]}> 520 <Trans>Expired</Trans> 521 </Text> 522 <Text 523 style={[ 524 a.text_xs, 525 a.leading_snug, 526 t.atoms.text_contrast_medium, 527 ]}> 528 {' · '} 529 </Text> 530 <Menu.Root> 531 <Menu.Trigger label={_(msg`Renew mute word`)}> 532 {({props}) => ( 533 <Text 534 {...props} 535 style={[ 536 a.text_xs, 537 a.leading_snug, 538 a.font_semi_bold, 539 {color: t.palette.primary_500}, 540 ]}> 541 <Trans>Renew</Trans> 542 </Text> 543 )} 544 </Menu.Trigger> 545 <Menu.Outer> 546 <Menu.LabelText> 547 <Trans>Renew duration</Trans> 548 </Menu.LabelText> 549 <Menu.Group> 550 <Menu.Item 551 label={_(msg`24 hours`)} 552 onPress={() => renew(1)}> 553 <Menu.ItemText> 554 <Trans>24 hours</Trans> 555 </Menu.ItemText> 556 </Menu.Item> 557 <Menu.Item 558 label={_(msg`7 days`)} 559 onPress={() => renew(7)}> 560 <Menu.ItemText> 561 <Trans>7 days</Trans> 562 </Menu.ItemText> 563 </Menu.Item> 564 <Menu.Item 565 label={_(msg`30 days`)} 566 onPress={() => renew(30)}> 567 <Menu.ItemText> 568 <Trans>30 days</Trans> 569 </Menu.ItemText> 570 </Menu.Item> 571 <Menu.Item 572 label={_(msg`Forever`)} 573 onPress={() => renew()}> 574 <Menu.ItemText> 575 <Trans>Forever</Trans> 576 </Menu.ItemText> 577 </Menu.Item> 578 </Menu.Group> 579 </Menu.Outer> 580 </Menu.Root> 581 </> 582 ) : ( 583 <Text 584 style={[ 585 a.text_xs, 586 a.leading_snug, 587 t.atoms.text_contrast_medium, 588 ]}> 589 <Trans> 590 Expires{' '} 591 {formatDistance(expiryDate, new Date(), { 592 addSuffix: true, 593 })} 594 </Trans> 595 </Text> 596 ))} 597 {word.actorTarget === 'exclude-following' && ( 598 <Text 599 style={[ 600 a.text_xs, 601 a.leading_snug, 602 t.atoms.text_contrast_medium, 603 ]}> 604 {expiryDate ? ' · ' : ''} 605 <Trans>Excludes users you follow</Trans> 606 </Text> 607 )} 608 </View> 609 )} 610 </View> 611 612 <Button 613 label={_(msg`Remove mute word from your list`)} 614 size="tiny" 615 shape="round" 616 variant="outline" 617 color="secondary" 618 onPress={() => control.open()} 619 style={[a.ml_sm]}> 620 <ButtonIcon icon={isPending ? Loader : X} /> 621 </Button> 622 </View> 623 </> 624 ) 625} 626 627function TargetToggle({children}: React.PropsWithChildren<{}>) { 628 const t = useTheme() 629 const ctx = Toggle.useItemContext() 630 const {gtMobile} = useBreakpoints() 631 return ( 632 <View 633 style={[ 634 a.flex_row, 635 a.align_center, 636 a.justify_between, 637 a.gap_xs, 638 a.flex_1, 639 a.py_sm, 640 a.px_sm, 641 gtMobile && a.px_md, 642 a.rounded_sm, 643 t.atoms.bg_contrast_25, 644 (ctx.hovered || ctx.focused) && t.atoms.bg_contrast_50, 645 ctx.selected && [ 646 { 647 backgroundColor: t.palette.primary_50, 648 }, 649 ], 650 ctx.disabled && { 651 opacity: 0.8, 652 }, 653 ]}> 654 {children} 655 </View> 656 ) 657}