this repo has no description
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}