forked from
jollywhoppers.com/witchsky.app
Bluesky app fork with some witchin' additions 馃挮
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}