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