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} from '@lingui/core/macro'
5import {useLingui} from '@lingui/react'
6import {Trans} from '@lingui/react/macro'
7
8import {logger} from '#/logger'
9import {useEnableSquareButtons} from '#/state/preferences/enable-square-buttons'
10import {
11 usePreferencesQuery,
12 useRemoveMutedWordMutation,
13 useUpdateMutedWordMutation,
14 useUpsertMutedWordsMutation,
15} from '#/state/queries/preferences'
16import {
17 atoms as a,
18 native,
19 useBreakpoints,
20 useTheme,
21 type ViewStyleProp,
22 web,
23} from '#/alf'
24import {Button, ButtonIcon, ButtonText} from '#/components/Button'
25import * as Dialog from '#/components/Dialog'
26import {useGlobalDialogsControlContext} from '#/components/dialogs/Context'
27import {Divider} from '#/components/Divider'
28import * as Toggle from '#/components/forms/Toggle'
29import {useFormatDistance} from '#/components/hooks/dates'
30import {Hashtag_Stroke2_Corner0_Rounded as Hashtag} from '#/components/icons/Hashtag'
31import {PageText_Stroke2_Corner0_Rounded as PageText} from '#/components/icons/PageText'
32import {PlusLarge_Stroke2_Corner0_Rounded as Plus} from '#/components/icons/Plus'
33import {TimesLarge_Stroke2_Corner0_Rounded as X} from '#/components/icons/Times'
34import {Loader} from '#/components/Loader'
35import * as Menu from '#/components/Menu'
36import * as Prompt from '#/components/Prompt'
37import {Text} from '#/components/Typography'
38import {IS_NATIVE} from '#/env'
39
40const ONE_DAY = 24 * 60 * 60 * 1000
41
42export function MutedWordsDialog() {
43 const {mutedWordsDialogControl: control} = useGlobalDialogsControlContext()
44 return (
45 <Dialog.Outer control={control}>
46 <Dialog.Handle />
47 <MutedWordsInner />
48 </Dialog.Outer>
49 )
50}
51
52function MutedWordsInner() {
53 const t = useTheme()
54 const {_} = useLingui()
55 const {gtMobile} = useBreakpoints()
56 const {
57 isLoading: isPreferencesLoading,
58 data: preferences,
59 error: preferencesError,
60 } = usePreferencesQuery()
61 const {isPending, mutateAsync: addMutedWord} = useUpsertMutedWordsMutation()
62 const [field, setField] = React.useState('')
63 const [targets, setTargets] = React.useState(['content'])
64 const [error, setError] = React.useState('')
65 const [durations, setDurations] = React.useState(['forever'])
66 const [excludeFollowing, setExcludeFollowing] = React.useState(false)
67
68 const submit = React.useCallback(async () => {
69 const sanitizedValue = sanitizeMutedWordValue(field)
70 const surfaces = ['tag', targets.includes('content') && 'content'].filter(
71 Boolean,
72 ) as AppBskyActorDefs.MutedWord['targets']
73 const actorTarget = excludeFollowing ? 'exclude-following' : 'all'
74
75 const now = Date.now()
76 const rawDuration = durations.at(0)
77 // undefined evaluates to 'forever'
78 let duration: string | undefined
79
80 if (rawDuration === '24_hours') {
81 duration = new Date(now + ONE_DAY).toISOString()
82 } else if (rawDuration === '7_days') {
83 duration = new Date(now + 7 * ONE_DAY).toISOString()
84 } else if (rawDuration === '30_days') {
85 duration = new Date(now + 30 * ONE_DAY).toISOString()
86 }
87
88 if (!sanitizedValue || !surfaces.length) {
89 setField('')
90 setError(_(msg`Please enter a valid word, tag, or phrase to mute`))
91 return
92 }
93
94 try {
95 // send raw value and rely on SDK as sanitization source of truth
96 await addMutedWord([
97 {
98 value: field,
99 targets: surfaces,
100 actorTarget,
101 expiresAt: duration,
102 },
103 ])
104 setField('')
105 } catch (e: any) {
106 logger.error(`Failed to save muted word`, {message: e.message})
107 setError(e.message)
108 }
109 }, [_, field, targets, addMutedWord, setField, durations, excludeFollowing])
110
111 return (
112 <Dialog.ScrollableInner label={_(msg`Manage your muted words and tags`)}>
113 <View>
114 <Text
115 style={[
116 a.text_md,
117 a.font_semi_bold,
118 a.pb_sm,
119 t.atoms.text_contrast_high,
120 ]}>
121 <Trans>Add muted words and tags</Trans>
122 </Text>
123 <Text style={[a.pb_lg, a.leading_snug, t.atoms.text_contrast_medium]}>
124 <Trans>
125 Posts can be muted based on their text, their tags, or both. We
126 recommend avoiding common words that appear in many posts, since it
127 can result in no posts being shown.
128 </Trans>
129 </Text>
130
131 <View style={[a.pb_sm]}>
132 <Dialog.Input
133 autoCorrect={false}
134 autoCapitalize="none"
135 autoComplete="off"
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 enableSquareButtons = useEnableSquareButtons()
435
436 const remove = React.useCallback(async () => {
437 control.close()
438 removeMutedWord(word)
439 }, [removeMutedWord, word, control])
440
441 const renew = (days?: number) => {
442 updateMutedWord({
443 ...word,
444 expiresAt: days
445 ? new Date(Date.now() + days * ONE_DAY).toISOString()
446 : undefined,
447 })
448 }
449
450 return (
451 <>
452 <Prompt.Basic
453 control={control}
454 title={_(msg`Are you sure?`)}
455 description={_(
456 msg`This will delete "${word.value}" from your muted words. You can always add it back later.`,
457 )}
458 onConfirm={remove}
459 confirmButtonCta={_(msg`Remove`)}
460 confirmButtonColor="negative"
461 />
462
463 <View
464 style={[
465 a.flex_row,
466 a.justify_between,
467 a.py_md,
468 a.px_lg,
469 a.rounded_md,
470 a.gap_md,
471 style,
472 ]}>
473 <View style={[a.flex_1, a.gap_xs]}>
474 <View style={[a.flex_row, a.align_center, a.gap_sm]}>
475 <Text
476 style={[
477 a.flex_1,
478 a.leading_snug,
479 a.font_semi_bold,
480 web({
481 overflowWrap: 'break-word',
482 wordBreak: 'break-word',
483 }),
484 ]}>
485 {word.targets.find(t => t === 'content') ? (
486 <Trans comment="Pattern: {wordValue} in text, tags">
487 {word.value}{' '}
488 <Text style={[a.font_normal, t.atoms.text_contrast_medium]}>
489 in{' '}
490 <Text
491 style={[a.font_semi_bold, t.atoms.text_contrast_medium]}>
492 text & tags
493 </Text>
494 </Text>
495 </Trans>
496 ) : (
497 <Trans comment="Pattern: {wordValue} in tags">
498 {word.value}{' '}
499 <Text style={[a.font_normal, t.atoms.text_contrast_medium]}>
500 in{' '}
501 <Text
502 style={[a.font_semi_bold, t.atoms.text_contrast_medium]}>
503 tags
504 </Text>
505 </Text>
506 </Trans>
507 )}
508 </Text>
509 </View>
510
511 {(expiryDate || word.actorTarget === 'exclude-following') && (
512 <View style={[a.flex_1, a.flex_row, a.align_center, a.flex_wrap]}>
513 {expiryDate &&
514 (isExpired ? (
515 <>
516 <Text
517 style={[
518 a.text_xs,
519 a.leading_snug,
520 t.atoms.text_contrast_medium,
521 ]}>
522 <Trans>Expired</Trans>
523 </Text>
524 <Text
525 style={[
526 a.text_xs,
527 a.leading_snug,
528 t.atoms.text_contrast_medium,
529 ]}>
530 {' 路 '}
531 </Text>
532 <Menu.Root>
533 <Menu.Trigger label={_(msg`Renew mute word`)}>
534 {({props}) => (
535 <Text
536 {...props}
537 style={[
538 a.text_xs,
539 a.leading_snug,
540 a.font_semi_bold,
541 {color: t.palette.primary_500},
542 ]}>
543 <Trans>Renew</Trans>
544 </Text>
545 )}
546 </Menu.Trigger>
547 <Menu.Outer>
548 <Menu.LabelText>
549 <Trans>Renew duration</Trans>
550 </Menu.LabelText>
551 <Menu.Group>
552 <Menu.Item
553 label={_(msg`24 hours`)}
554 onPress={() => renew(1)}>
555 <Menu.ItemText>
556 <Trans>24 hours</Trans>
557 </Menu.ItemText>
558 </Menu.Item>
559 <Menu.Item
560 label={_(msg`7 days`)}
561 onPress={() => renew(7)}>
562 <Menu.ItemText>
563 <Trans>7 days</Trans>
564 </Menu.ItemText>
565 </Menu.Item>
566 <Menu.Item
567 label={_(msg`30 days`)}
568 onPress={() => renew(30)}>
569 <Menu.ItemText>
570 <Trans>30 days</Trans>
571 </Menu.ItemText>
572 </Menu.Item>
573 <Menu.Item
574 label={_(msg`Forever`)}
575 onPress={() => renew()}>
576 <Menu.ItemText>
577 <Trans>Forever</Trans>
578 </Menu.ItemText>
579 </Menu.Item>
580 </Menu.Group>
581 </Menu.Outer>
582 </Menu.Root>
583 </>
584 ) : (
585 <Text
586 style={[
587 a.text_xs,
588 a.leading_snug,
589 t.atoms.text_contrast_medium,
590 ]}>
591 <Trans>
592 Expires{' '}
593 {formatDistance(expiryDate, new Date(), {
594 addSuffix: true,
595 })}
596 </Trans>
597 </Text>
598 ))}
599 {word.actorTarget === 'exclude-following' && (
600 <Text
601 style={[
602 a.text_xs,
603 a.leading_snug,
604 t.atoms.text_contrast_medium,
605 ]}>
606 {expiryDate ? ' 路 ' : ''}
607 <Trans>Excludes users you follow</Trans>
608 </Text>
609 )}
610 </View>
611 )}
612 </View>
613
614 <Button
615 label={_(msg`Remove mute word from your list`)}
616 size="tiny"
617 shape={enableSquareButtons ? 'square' : 'round'}
618 variant="outline"
619 color="secondary"
620 onPress={() => control.open()}
621 style={[a.ml_sm]}>
622 <ButtonIcon icon={isPending ? Loader : X} />
623 </Button>
624 </View>
625 </>
626 )
627}
628
629function TargetToggle({children}: React.PropsWithChildren<{}>) {
630 const t = useTheme()
631 const ctx = Toggle.useItemContext()
632 const {gtMobile} = useBreakpoints()
633 return (
634 <View
635 style={[
636 a.flex_row,
637 a.align_center,
638 a.justify_between,
639 a.gap_xs,
640 a.flex_1,
641 a.py_sm,
642 a.px_sm,
643 gtMobile && a.px_md,
644 a.rounded_sm,
645 t.atoms.bg_contrast_25,
646 (ctx.hovered || ctx.focused) && t.atoms.bg_contrast_50,
647 ctx.selected && [
648 {
649 backgroundColor: t.palette.primary_50,
650 },
651 ],
652 ctx.disabled && {
653 opacity: 0.8,
654 },
655 ]}>
656 {children}
657 </View>
658 )
659}