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 {useEnableSquareButtons} from '#/state/preferences/enable-square-buttons'
9import {
10 usePreferencesQuery,
11 useRemoveMutedWordMutation,
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 Prompt from '#/components/Prompt'
34import {Text} from '#/components/Typography'
35import {IS_NATIVE} from '#/env'
36
37const ONE_DAY = 24 * 60 * 60 * 1000
38
39export function MutedWordsDialog() {
40 const {mutedWordsDialogControl: control} = useGlobalDialogsControlContext()
41 return (
42 <Dialog.Outer control={control}>
43 <Dialog.Handle />
44 <MutedWordsInner />
45 </Dialog.Outer>
46 )
47}
48
49function MutedWordsInner() {
50 const t = useTheme()
51 const {_} = useLingui()
52 const {gtMobile} = useBreakpoints()
53 const {
54 isLoading: isPreferencesLoading,
55 data: preferences,
56 error: preferencesError,
57 } = usePreferencesQuery()
58 const {isPending, mutateAsync: addMutedWord} = useUpsertMutedWordsMutation()
59 const [field, setField] = React.useState('')
60 const [targets, setTargets] = React.useState(['content'])
61 const [error, setError] = React.useState('')
62 const [durations, setDurations] = React.useState(['forever'])
63 const [excludeFollowing, setExcludeFollowing] = React.useState(false)
64
65 const submit = React.useCallback(async () => {
66 const sanitizedValue = sanitizeMutedWordValue(field)
67 const surfaces = ['tag', targets.includes('content') && 'content'].filter(
68 Boolean,
69 ) as AppBskyActorDefs.MutedWord['targets']
70 const actorTarget = excludeFollowing ? 'exclude-following' : 'all'
71
72 const now = Date.now()
73 const rawDuration = durations.at(0)
74 // undefined evaluates to 'forever'
75 let duration: string | undefined
76
77 if (rawDuration === '24_hours') {
78 duration = new Date(now + ONE_DAY).toISOString()
79 } else if (rawDuration === '7_days') {
80 duration = new Date(now + 7 * ONE_DAY).toISOString()
81 } else if (rawDuration === '30_days') {
82 duration = new Date(now + 30 * ONE_DAY).toISOString()
83 }
84
85 if (!sanitizedValue || !surfaces.length) {
86 setField('')
87 setError(_(msg`Please enter a valid word, tag, or phrase to mute`))
88 return
89 }
90
91 try {
92 // send raw value and rely on SDK as sanitization source of truth
93 await addMutedWord([
94 {
95 value: field,
96 targets: surfaces,
97 actorTarget,
98 expiresAt: duration,
99 },
100 ])
101 setField('')
102 } catch (e: any) {
103 logger.error(`Failed to save muted word`, {message: e.message})
104 setError(e.message)
105 }
106 }, [_, field, targets, addMutedWord, setField, durations, excludeFollowing])
107
108 return (
109 <Dialog.ScrollableInner label={_(msg`Manage your muted words and tags`)}>
110 <View>
111 <Text
112 style={[
113 a.text_md,
114 a.font_semi_bold,
115 a.pb_sm,
116 t.atoms.text_contrast_high,
117 ]}>
118 <Trans>Add muted words and tags</Trans>
119 </Text>
120 <Text style={[a.pb_lg, a.leading_snug, t.atoms.text_contrast_medium]}>
121 <Trans>
122 Skeets can be muted based on their text, their tags, or both. We
123 recommend avoiding common words that appear in many skeets, since it
124 can result in no skeets being shown.
125 </Trans>
126 </Text>
127
128 <View style={[a.pb_sm]}>
129 <Dialog.Input
130 autoCorrect={false}
131 autoCapitalize="none"
132 autoComplete="off"
133 label={_(msg`Enter a word or tag`)}
134 placeholder={_(msg`Enter a word or tag`)}
135 value={field}
136 onChangeText={value => {
137 if (error) {
138 setError('')
139 }
140 setField(value)
141 }}
142 onSubmitEditing={submit}
143 />
144 </View>
145
146 <View style={[a.pb_xl, a.gap_sm]}>
147 <Toggle.Group
148 label={_(msg`Select how long to mute this word for.`)}
149 type="radio"
150 values={durations}
151 onChange={setDurations}>
152 <Text
153 style={[
154 a.pb_xs,
155 a.text_sm,
156 a.font_semi_bold,
157 t.atoms.text_contrast_medium,
158 ]}>
159 <Trans>Duration:</Trans>
160 </Text>
161
162 <View
163 style={[
164 gtMobile && [a.flex_row, a.align_center, a.justify_start],
165 a.gap_sm,
166 ]}>
167 <View
168 style={[
169 a.flex_1,
170 a.flex_row,
171 a.justify_start,
172 a.align_center,
173 a.gap_sm,
174 ]}>
175 <Toggle.Item
176 label={_(msg`Mute this word until you unmute it`)}
177 name="forever"
178 style={[a.flex_1]}>
179 <TargetToggle>
180 <View
181 style={[a.flex_1, a.flex_row, a.align_center, a.gap_sm]}>
182 <Toggle.Radio />
183 <Toggle.LabelText style={[a.flex_1, a.leading_tight]}>
184 <Trans>Forever</Trans>
185 </Toggle.LabelText>
186 </View>
187 </TargetToggle>
188 </Toggle.Item>
189
190 <Toggle.Item
191 label={_(msg`Mute this word for 24 hours`)}
192 name="24_hours"
193 style={[a.flex_1]}>
194 <TargetToggle>
195 <View
196 style={[a.flex_1, a.flex_row, a.align_center, a.gap_sm]}>
197 <Toggle.Radio />
198 <Toggle.LabelText style={[a.flex_1, a.leading_tight]}>
199 <Trans>24 hours</Trans>
200 </Toggle.LabelText>
201 </View>
202 </TargetToggle>
203 </Toggle.Item>
204 </View>
205
206 <View
207 style={[
208 a.flex_1,
209 a.flex_row,
210 a.justify_start,
211 a.align_center,
212 a.gap_sm,
213 ]}>
214 <Toggle.Item
215 label={_(msg`Mute this word for 7 days`)}
216 name="7_days"
217 style={[a.flex_1]}>
218 <TargetToggle>
219 <View
220 style={[a.flex_1, a.flex_row, a.align_center, a.gap_sm]}>
221 <Toggle.Radio />
222 <Toggle.LabelText style={[a.flex_1, a.leading_tight]}>
223 <Trans>7 days</Trans>
224 </Toggle.LabelText>
225 </View>
226 </TargetToggle>
227 </Toggle.Item>
228
229 <Toggle.Item
230 label={_(msg`Mute this word for 30 days`)}
231 name="30_days"
232 style={[a.flex_1]}>
233 <TargetToggle>
234 <View
235 style={[a.flex_1, a.flex_row, a.align_center, a.gap_sm]}>
236 <Toggle.Radio />
237 <Toggle.LabelText style={[a.flex_1, a.leading_tight]}>
238 <Trans>30 days</Trans>
239 </Toggle.LabelText>
240 </View>
241 </TargetToggle>
242 </Toggle.Item>
243 </View>
244 </View>
245 </Toggle.Group>
246
247 <Toggle.Group
248 label={_(msg`Select what content this mute word should apply to.`)}
249 type="radio"
250 values={targets}
251 onChange={setTargets}>
252 <Text
253 style={[
254 a.pb_xs,
255 a.text_sm,
256 a.font_semi_bold,
257 t.atoms.text_contrast_medium,
258 ]}>
259 <Trans>Mute in:</Trans>
260 </Text>
261
262 <View style={[a.flex_row, a.align_center, a.gap_sm, a.flex_wrap]}>
263 <Toggle.Item
264 label={_(msg`Mute this word in skeet text and tags`)}
265 name="content"
266 style={[a.flex_1]}>
267 <TargetToggle>
268 <View
269 style={[a.flex_1, a.flex_row, a.align_center, a.gap_sm]}>
270 <Toggle.Radio />
271 <Toggle.LabelText style={[a.flex_1, a.leading_tight]}>
272 <Trans>Text & tags</Trans>
273 </Toggle.LabelText>
274 </View>
275 <PageText size="sm" />
276 </TargetToggle>
277 </Toggle.Item>
278
279 <Toggle.Item
280 label={_(msg`Mute this word in tags only`)}
281 name="tag"
282 style={[a.flex_1]}>
283 <TargetToggle>
284 <View
285 style={[a.flex_1, a.flex_row, a.align_center, a.gap_sm]}>
286 <Toggle.Radio />
287 <Toggle.LabelText style={[a.flex_1, a.leading_tight]}>
288 <Trans>Tags only</Trans>
289 </Toggle.LabelText>
290 </View>
291 <Hashtag size="sm" />
292 </TargetToggle>
293 </Toggle.Item>
294 </View>
295 </Toggle.Group>
296
297 <View>
298 <Text
299 style={[
300 a.pb_xs,
301 a.text_sm,
302 a.font_semi_bold,
303 t.atoms.text_contrast_medium,
304 ]}>
305 <Trans>Options:</Trans>
306 </Text>
307 <Toggle.Item
308 label={_(msg`Do not apply this mute word to users you follow`)}
309 name="exclude_following"
310 style={[a.flex_row, a.justify_between]}
311 value={excludeFollowing}
312 onChange={setExcludeFollowing}>
313 <TargetToggle>
314 <View style={[a.flex_1, a.flex_row, a.align_center, a.gap_sm]}>
315 <Toggle.Checkbox />
316 <Toggle.LabelText style={[a.flex_1, a.leading_tight]}>
317 <Trans>Exclude users you follow</Trans>
318 </Toggle.LabelText>
319 </View>
320 </TargetToggle>
321 </Toggle.Item>
322 </View>
323
324 <View style={[a.pt_xs]}>
325 <Button
326 disabled={isPending || !field}
327 label={_(msg`Add mute word with chosen settings`)}
328 size="large"
329 color="primary"
330 variant="solid"
331 style={[]}
332 onPress={submit}>
333 <ButtonText>
334 <Trans>Add</Trans>
335 </ButtonText>
336 <ButtonIcon icon={isPending ? Loader : Plus} position="right" />
337 </Button>
338 </View>
339
340 {error && (
341 <View
342 style={[
343 a.mb_lg,
344 a.flex_row,
345 a.rounded_sm,
346 a.p_md,
347 a.mb_xs,
348 t.atoms.bg_contrast_25,
349 {
350 backgroundColor: t.palette.negative_400,
351 },
352 ]}>
353 <Text
354 style={[
355 a.italic,
356 {color: t.palette.white},
357 native({marginTop: 2}),
358 ]}>
359 {error}
360 </Text>
361 </View>
362 )}
363 </View>
364
365 <Divider />
366
367 <View style={[a.pt_2xl]}>
368 <Text
369 style={[
370 a.text_md,
371 a.font_semi_bold,
372 a.pb_md,
373 t.atoms.text_contrast_high,
374 ]}>
375 <Trans>Your muted words</Trans>
376 </Text>
377
378 {isPreferencesLoading ? (
379 <Loader />
380 ) : preferencesError || !preferences ? (
381 <View
382 style={[a.py_md, a.px_lg, a.rounded_md, t.atoms.bg_contrast_25]}>
383 <Text style={[a.italic, t.atoms.text_contrast_high]}>
384 <Trans>
385 We're sorry, but we weren't able to load your muted words at
386 this time. Please try again.
387 </Trans>
388 </Text>
389 </View>
390 ) : preferences.moderationPrefs.mutedWords.length ? (
391 [...preferences.moderationPrefs.mutedWords]
392 .reverse()
393 .map((word, i) => (
394 <MutedWordRow
395 key={word.value + i}
396 word={word}
397 style={[i % 2 === 0 && t.atoms.bg_contrast_25]}
398 />
399 ))
400 ) : (
401 <View
402 style={[a.py_md, a.px_lg, a.rounded_md, t.atoms.bg_contrast_25]}>
403 <Text style={[a.italic, t.atoms.text_contrast_high]}>
404 <Trans>You haven't muted any words or tags yet</Trans>
405 </Text>
406 </View>
407 )}
408 </View>
409
410 {IS_NATIVE && <View style={{height: 20}} />}
411 </View>
412
413 <Dialog.Close />
414 </Dialog.ScrollableInner>
415 )
416}
417
418function MutedWordRow({
419 style,
420 word,
421}: ViewStyleProp & {word: AppBskyActorDefs.MutedWord}) {
422 const t = useTheme()
423 const {_} = useLingui()
424 const {isPending, mutateAsync: removeMutedWord} = useRemoveMutedWordMutation()
425 const control = Prompt.usePromptControl()
426 const expiryDate = word.expiresAt ? new Date(word.expiresAt) : undefined
427 const isExpired = expiryDate && expiryDate < new Date()
428 const formatDistance = useFormatDistance()
429
430 const enableSquareButtons = useEnableSquareButtons()
431
432 const remove = React.useCallback(async () => {
433 control.close()
434 removeMutedWord(word)
435 }, [removeMutedWord, word, control])
436
437 return (
438 <>
439 <Prompt.Basic
440 control={control}
441 title={_(msg`Are you sure?`)}
442 description={_(
443 msg`This will delete "${word.value}" from your muted words. You can always add it back later.`,
444 )}
445 onConfirm={remove}
446 confirmButtonCta={_(msg`Remove`)}
447 confirmButtonColor="negative"
448 />
449
450 <View
451 style={[
452 a.flex_row,
453 a.justify_between,
454 a.py_md,
455 a.px_lg,
456 a.rounded_md,
457 a.gap_md,
458 style,
459 ]}>
460 <View style={[a.flex_1, a.gap_xs]}>
461 <View style={[a.flex_row, a.align_center, a.gap_sm]}>
462 <Text
463 style={[
464 a.flex_1,
465 a.leading_snug,
466 a.font_semi_bold,
467 web({
468 overflowWrap: 'break-word',
469 wordBreak: 'break-word',
470 }),
471 ]}>
472 {word.targets.find(t => t === 'content') ? (
473 <Trans comment="Pattern: {wordValue} in text, tags">
474 {word.value}{' '}
475 <Text style={[a.font_normal, t.atoms.text_contrast_medium]}>
476 in{' '}
477 <Text
478 style={[a.font_semi_bold, t.atoms.text_contrast_medium]}>
479 text & tags
480 </Text>
481 </Text>
482 </Trans>
483 ) : (
484 <Trans comment="Pattern: {wordValue} in 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 tags
491 </Text>
492 </Text>
493 </Trans>
494 )}
495 </Text>
496 </View>
497
498 {(expiryDate || word.actorTarget === 'exclude-following') && (
499 <View style={[a.flex_1, a.flex_row, a.align_center, a.gap_sm]}>
500 <Text
501 style={[
502 a.flex_1,
503 a.text_xs,
504 a.leading_snug,
505 t.atoms.text_contrast_medium,
506 ]}>
507 {expiryDate && (
508 <>
509 {isExpired ? (
510 <Trans>Expired</Trans>
511 ) : (
512 <Trans>
513 Expires{' '}
514 {formatDistance(expiryDate, new Date(), {
515 addSuffix: true,
516 })}
517 </Trans>
518 )}
519 </>
520 )}
521 {word.actorTarget === 'exclude-following' && (
522 <>
523 {' • '}
524 <Trans>Excludes users you follow</Trans>
525 </>
526 )}
527 </Text>
528 </View>
529 )}
530 </View>
531
532 <Button
533 label={_(msg`Remove mute word from your list`)}
534 size="tiny"
535 shape={enableSquareButtons ? 'square' : 'round'}
536 variant="outline"
537 color="secondary"
538 onPress={() => control.open()}
539 style={[a.ml_sm]}>
540 <ButtonIcon icon={isPending ? Loader : X} />
541 </Button>
542 </View>
543 </>
544 )
545}
546
547function TargetToggle({children}: React.PropsWithChildren<{}>) {
548 const t = useTheme()
549 const ctx = Toggle.useItemContext()
550 const {gtMobile} = useBreakpoints()
551 return (
552 <View
553 style={[
554 a.flex_row,
555 a.align_center,
556 a.justify_between,
557 a.gap_xs,
558 a.flex_1,
559 a.py_sm,
560 a.px_sm,
561 gtMobile && a.px_md,
562 a.rounded_sm,
563 t.atoms.bg_contrast_25,
564 (ctx.hovered || ctx.focused) && t.atoms.bg_contrast_50,
565 ctx.selected && [
566 {
567 backgroundColor: t.palette.primary_50,
568 },
569 ],
570 ctx.disabled && {
571 opacity: 0.8,
572 },
573 ]}>
574 {children}
575 </View>
576 )
577}