Bluesky app fork with some witchin' additions 💫
0
fork

Configure Feed

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

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