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 returnKeyType="done"
137 label={_(msg`Enter a word or tag`)}
138 placeholder={_(msg`Enter a word or tag`)}
139 value={field}
140 onChangeText={value => {
141 if (error) {
142 setError('')
143 }
144 setField(value)
145 }}
146 onSubmitEditing={submit}
147 />
148 </View>
149
150 <View style={[a.pb_xl, a.gap_sm]}>
151 <Toggle.Group
152 label={_(msg`Select how long to mute this word for.`)}
153 type="radio"
154 values={durations}
155 onChange={setDurations}>
156 <Text
157 style={[
158 a.pb_xs,
159 a.text_sm,
160 a.font_semi_bold,
161 t.atoms.text_contrast_medium,
162 ]}>
163 <Trans>Duration:</Trans>
164 </Text>
165
166 <View
167 style={[
168 gtMobile && [a.flex_row, a.align_center, a.justify_start],
169 a.gap_sm,
170 ]}>
171 <View
172 style={[
173 a.flex_1,
174 a.flex_row,
175 a.justify_start,
176 a.align_center,
177 a.gap_sm,
178 ]}>
179 <Toggle.Item
180 label={_(msg`Mute this word until you unmute it`)}
181 name="forever"
182 style={[a.flex_1]}>
183 <TargetToggle>
184 <View
185 style={[a.flex_1, a.flex_row, a.align_center, a.gap_sm]}>
186 <Toggle.Radio />
187 <Toggle.LabelText style={[a.flex_1, a.leading_tight]}>
188 <Trans>Forever</Trans>
189 </Toggle.LabelText>
190 </View>
191 </TargetToggle>
192 </Toggle.Item>
193
194 <Toggle.Item
195 label={_(msg`Mute this word for 24 hours`)}
196 name="24_hours"
197 style={[a.flex_1]}>
198 <TargetToggle>
199 <View
200 style={[a.flex_1, a.flex_row, a.align_center, a.gap_sm]}>
201 <Toggle.Radio />
202 <Toggle.LabelText style={[a.flex_1, a.leading_tight]}>
203 <Trans>24 hours</Trans>
204 </Toggle.LabelText>
205 </View>
206 </TargetToggle>
207 </Toggle.Item>
208 </View>
209
210 <View
211 style={[
212 a.flex_1,
213 a.flex_row,
214 a.justify_start,
215 a.align_center,
216 a.gap_sm,
217 ]}>
218 <Toggle.Item
219 label={_(msg`Mute this word for 7 days`)}
220 name="7_days"
221 style={[a.flex_1]}>
222 <TargetToggle>
223 <View
224 style={[a.flex_1, a.flex_row, a.align_center, a.gap_sm]}>
225 <Toggle.Radio />
226 <Toggle.LabelText style={[a.flex_1, a.leading_tight]}>
227 <Trans>7 days</Trans>
228 </Toggle.LabelText>
229 </View>
230 </TargetToggle>
231 </Toggle.Item>
232
233 <Toggle.Item
234 label={_(msg`Mute this word for 30 days`)}
235 name="30_days"
236 style={[a.flex_1]}>
237 <TargetToggle>
238 <View
239 style={[a.flex_1, a.flex_row, a.align_center, a.gap_sm]}>
240 <Toggle.Radio />
241 <Toggle.LabelText style={[a.flex_1, a.leading_tight]}>
242 <Trans>30 days</Trans>
243 </Toggle.LabelText>
244 </View>
245 </TargetToggle>
246 </Toggle.Item>
247 </View>
248 </View>
249 </Toggle.Group>
250
251 <Toggle.Group
252 label={_(msg`Select what content this mute word should apply to.`)}
253 type="radio"
254 values={targets}
255 onChange={setTargets}>
256 <Text
257 style={[
258 a.pb_xs,
259 a.text_sm,
260 a.font_semi_bold,
261 t.atoms.text_contrast_medium,
262 ]}>
263 <Trans>Mute in:</Trans>
264 </Text>
265
266 <View style={[a.flex_row, a.align_center, a.gap_sm, a.flex_wrap]}>
267 <Toggle.Item
268 label={_(msg`Mute this word in post text and tags`)}
269 name="content"
270 style={[a.flex_1]}>
271 <TargetToggle>
272 <View
273 style={[a.flex_1, a.flex_row, a.align_center, a.gap_sm]}>
274 <Toggle.Radio />
275 <Toggle.LabelText style={[a.flex_1, a.leading_tight]}>
276 <Trans>Text & tags</Trans>
277 </Toggle.LabelText>
278 </View>
279 <PageText size="sm" />
280 </TargetToggle>
281 </Toggle.Item>
282
283 <Toggle.Item
284 label={_(msg`Mute this word in tags only`)}
285 name="tag"
286 style={[a.flex_1]}>
287 <TargetToggle>
288 <View
289 style={[a.flex_1, a.flex_row, a.align_center, a.gap_sm]}>
290 <Toggle.Radio />
291 <Toggle.LabelText style={[a.flex_1, a.leading_tight]}>
292 <Trans>Tags only</Trans>
293 </Toggle.LabelText>
294 </View>
295 <Hashtag size="sm" />
296 </TargetToggle>
297 </Toggle.Item>
298 </View>
299 </Toggle.Group>
300
301 <View>
302 <Text
303 style={[
304 a.pb_xs,
305 a.text_sm,
306 a.font_semi_bold,
307 t.atoms.text_contrast_medium,
308 ]}>
309 <Trans>Options:</Trans>
310 </Text>
311 <Toggle.Item
312 label={_(msg`Do not apply this mute word to users you follow`)}
313 name="exclude_following"
314 style={[a.flex_row, a.justify_between]}
315 value={excludeFollowing}
316 onChange={setExcludeFollowing}>
317 <TargetToggle>
318 <View style={[a.flex_1, a.flex_row, a.align_center, a.gap_sm]}>
319 <Toggle.Checkbox />
320 <Toggle.LabelText style={[a.flex_1, a.leading_tight]}>
321 <Trans>Exclude users you follow</Trans>
322 </Toggle.LabelText>
323 </View>
324 </TargetToggle>
325 </Toggle.Item>
326 </View>
327
328 <View style={[a.pt_xs]}>
329 <Button
330 disabled={isPending || !field}
331 label={_(msg`Add mute word with chosen settings`)}
332 size="large"
333 color="primary"
334 variant="solid"
335 style={[]}
336 onPress={submit}>
337 <ButtonText>
338 <Trans>Add</Trans>
339 </ButtonText>
340 <ButtonIcon icon={isPending ? Loader : Plus} position="right" />
341 </Button>
342 </View>
343
344 {error && (
345 <View
346 style={[
347 a.mb_lg,
348 a.flex_row,
349 a.rounded_sm,
350 a.p_md,
351 a.mb_xs,
352 t.atoms.bg_contrast_25,
353 {
354 backgroundColor: t.palette.negative_400,
355 },
356 ]}>
357 <Text
358 style={[
359 a.italic,
360 {color: t.palette.white},
361 native({marginTop: 2}),
362 ]}>
363 {error}
364 </Text>
365 </View>
366 )}
367 </View>
368
369 <Divider />
370
371 <View style={[a.pt_2xl]}>
372 <Text
373 style={[
374 a.text_md,
375 a.font_semi_bold,
376 a.pb_md,
377 t.atoms.text_contrast_high,
378 ]}>
379 <Trans>Your muted words</Trans>
380 </Text>
381
382 {isPreferencesLoading ? (
383 <Loader />
384 ) : preferencesError || !preferences ? (
385 <View
386 style={[a.py_md, a.px_lg, a.rounded_md, t.atoms.bg_contrast_25]}>
387 <Text style={[a.italic, t.atoms.text_contrast_high]}>
388 <Trans>
389 We're sorry, but we weren't able to load your muted words at
390 this time. Please try again.
391 </Trans>
392 </Text>
393 </View>
394 ) : preferences.moderationPrefs.mutedWords.length ? (
395 [...preferences.moderationPrefs.mutedWords]
396 .reverse()
397 .map((word, i) => (
398 <MutedWordRow
399 key={word.value + i}
400 word={word}
401 style={[i % 2 === 0 && t.atoms.bg_contrast_25]}
402 />
403 ))
404 ) : (
405 <View
406 style={[a.py_md, a.px_lg, a.rounded_md, t.atoms.bg_contrast_25]}>
407 <Text style={[a.italic, t.atoms.text_contrast_high]}>
408 <Trans>You haven't muted any words or tags yet</Trans>
409 </Text>
410 </View>
411 )}
412 </View>
413
414 {IS_NATIVE && <View style={{height: 20}} />}
415 </View>
416
417 <Dialog.Close />
418 </Dialog.ScrollableInner>
419 )
420}
421
422function MutedWordRow({
423 style,
424 word,
425}: ViewStyleProp & {word: AppBskyActorDefs.MutedWord}) {
426 const t = useTheme()
427 const {_} = useLingui()
428 const {isPending, mutateAsync: removeMutedWord} = useRemoveMutedWordMutation()
429 const {mutateAsync: updateMutedWord} = useUpdateMutedWordMutation()
430 const control = Prompt.usePromptControl()
431 const expiryDate = word.expiresAt ? new Date(word.expiresAt) : undefined
432 const isExpired = expiryDate && expiryDate < new Date()
433 const formatDistance = useFormatDistance()
434
435 const enableSquareButtons = useEnableSquareButtons()
436
437 const remove = React.useCallback(async () => {
438 control.close()
439 removeMutedWord(word)
440 }, [removeMutedWord, word, control])
441
442 const renew = (days?: number) => {
443 updateMutedWord({
444 ...word,
445 expiresAt: days
446 ? new Date(Date.now() + days * ONE_DAY).toISOString()
447 : undefined,
448 })
449 }
450
451 return (
452 <>
453 <Prompt.Basic
454 control={control}
455 title={_(msg`Are you sure?`)}
456 description={_(
457 msg`This will delete "${word.value}" from your muted words. You can always add it back later.`,
458 )}
459 onConfirm={remove}
460 confirmButtonCta={_(msg`Remove`)}
461 confirmButtonColor="negative"
462 />
463
464 <View
465 style={[
466 a.flex_row,
467 a.justify_between,
468 a.py_md,
469 a.px_lg,
470 a.rounded_md,
471 a.gap_md,
472 style,
473 ]}>
474 <View style={[a.flex_1, a.gap_xs]}>
475 <View style={[a.flex_row, a.align_center, a.gap_sm]}>
476 <Text
477 style={[
478 a.flex_1,
479 a.leading_snug,
480 a.font_semi_bold,
481 web({
482 overflowWrap: 'break-word',
483 wordBreak: 'break-word',
484 }),
485 ]}>
486 {word.targets.find(t => t === 'content') ? (
487 <Trans comment="Pattern: {wordValue} in text, tags">
488 {word.value}{' '}
489 <Text style={[a.font_normal, t.atoms.text_contrast_medium]}>
490 in{' '}
491 <Text
492 style={[a.font_semi_bold, t.atoms.text_contrast_medium]}>
493 text & tags
494 </Text>
495 </Text>
496 </Trans>
497 ) : (
498 <Trans comment="Pattern: {wordValue} in tags">
499 {word.value}{' '}
500 <Text style={[a.font_normal, t.atoms.text_contrast_medium]}>
501 in{' '}
502 <Text
503 style={[a.font_semi_bold, t.atoms.text_contrast_medium]}>
504 tags
505 </Text>
506 </Text>
507 </Trans>
508 )}
509 </Text>
510 </View>
511
512 {(expiryDate || word.actorTarget === 'exclude-following') && (
513 <View style={[a.flex_1, a.flex_row, a.align_center, a.flex_wrap]}>
514 {expiryDate &&
515 (isExpired ? (
516 <>
517 <Text
518 style={[
519 a.text_xs,
520 a.leading_snug,
521 t.atoms.text_contrast_medium,
522 ]}>
523 <Trans>Expired</Trans>
524 </Text>
525 <Text
526 style={[
527 a.text_xs,
528 a.leading_snug,
529 t.atoms.text_contrast_medium,
530 ]}>
531 {' 路 '}
532 </Text>
533 <Menu.Root>
534 <Menu.Trigger label={_(msg`Renew mute word`)}>
535 {({props}) => (
536 <Text
537 {...props}
538 style={[
539 a.text_xs,
540 a.leading_snug,
541 a.font_semi_bold,
542 {color: t.palette.primary_500},
543 ]}>
544 <Trans>Renew</Trans>
545 </Text>
546 )}
547 </Menu.Trigger>
548 <Menu.Outer>
549 <Menu.LabelText>
550 <Trans>Renew duration</Trans>
551 </Menu.LabelText>
552 <Menu.Group>
553 <Menu.Item
554 label={_(msg`24 hours`)}
555 onPress={() => renew(1)}>
556 <Menu.ItemText>
557 <Trans>24 hours</Trans>
558 </Menu.ItemText>
559 </Menu.Item>
560 <Menu.Item
561 label={_(msg`7 days`)}
562 onPress={() => renew(7)}>
563 <Menu.ItemText>
564 <Trans>7 days</Trans>
565 </Menu.ItemText>
566 </Menu.Item>
567 <Menu.Item
568 label={_(msg`30 days`)}
569 onPress={() => renew(30)}>
570 <Menu.ItemText>
571 <Trans>30 days</Trans>
572 </Menu.ItemText>
573 </Menu.Item>
574 <Menu.Item
575 label={_(msg`Forever`)}
576 onPress={() => renew()}>
577 <Menu.ItemText>
578 <Trans>Forever</Trans>
579 </Menu.ItemText>
580 </Menu.Item>
581 </Menu.Group>
582 </Menu.Outer>
583 </Menu.Root>
584 </>
585 ) : (
586 <Text
587 style={[
588 a.text_xs,
589 a.leading_snug,
590 t.atoms.text_contrast_medium,
591 ]}>
592 <Trans>
593 Expires{' '}
594 {formatDistance(expiryDate, new Date(), {
595 addSuffix: true,
596 })}
597 </Trans>
598 </Text>
599 ))}
600 {word.actorTarget === 'exclude-following' && (
601 <Text
602 style={[
603 a.text_xs,
604 a.leading_snug,
605 t.atoms.text_contrast_medium,
606 ]}>
607 {expiryDate ? ' 路 ' : ''}
608 <Trans>Excludes users you follow</Trans>
609 </Text>
610 )}
611 </View>
612 )}
613 </View>
614
615 <Button
616 label={_(msg`Remove mute word from your list`)}
617 size="tiny"
618 shape={enableSquareButtons ? 'square' : 'round'}
619 variant="outline"
620 color="secondary"
621 onPress={() => control.open()}
622 style={[a.ml_sm]}>
623 <ButtonIcon icon={isPending ? Loader : X} />
624 </Button>
625 </View>
626 </>
627 )
628}
629
630function TargetToggle({children}: React.PropsWithChildren<{}>) {
631 const t = useTheme()
632 const ctx = Toggle.useItemContext()
633 const {gtMobile} = useBreakpoints()
634 return (
635 <View
636 style={[
637 a.flex_row,
638 a.align_center,
639 a.justify_between,
640 a.gap_xs,
641 a.flex_1,
642 a.py_sm,
643 a.px_sm,
644 gtMobile && a.px_md,
645 a.rounded_sm,
646 t.atoms.bg_contrast_25,
647 (ctx.hovered || ctx.focused) && t.atoms.bg_contrast_50,
648 ctx.selected && [
649 {
650 backgroundColor: t.palette.primary_50,
651 },
652 ],
653 ctx.disabled && {
654 opacity: 0.8,
655 },
656 ]}>
657 {children}
658 </View>
659 )
660}