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 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}