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

Configure Feed

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

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