Update muted words dialog with `expiresAt` and `actorTarget` (#4801)
* WIP not working dropdown * Update MutedWords dialog * Add i18n formatDistance * Comments * Handle text wrapping * Update label copy Co-authored-by: Hailey <me@haileyok.com> * Fix alignment * Improve translation output * Revert toggle changes * Better types for useFormatDistance * Tweaks * Integrate new sdk version into TagMenu * Use ampersand Co-authored-by: surfdude29 <149612116+surfdude29@users.noreply.github.com> * Bump SDK --------- Co-authored-by: Hailey <me@haileyok.com> Co-authored-by: surfdude29 <149612116+surfdude29@users.noreply.github.com>zio/stable
parent
d2e88cc623
commit
b0e130a4d8
|
@ -52,7 +52,7 @@
|
||||||
"open-analyzer": "EXPO_PUBLIC_OPEN_ANALYZER=1 yarn build-web"
|
"open-analyzer": "EXPO_PUBLIC_OPEN_ANALYZER=1 yarn build-web"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@atproto/api": "0.12.25",
|
"@atproto/api": "^0.12.26",
|
||||||
"@bam.tech/react-native-image-resizer": "^3.0.4",
|
"@bam.tech/react-native-image-resizer": "^3.0.4",
|
||||||
"@braintree/sanitize-url": "^6.0.2",
|
"@braintree/sanitize-url": "^6.0.2",
|
||||||
"@discord/bottom-sheet": "bluesky-social/react-native-bottom-sheet",
|
"@discord/bottom-sheet": "bluesky-social/react-native-bottom-sheet",
|
||||||
|
|
|
@ -1,27 +1,27 @@
|
||||||
import React from 'react'
|
import React from 'react'
|
||||||
import {View} from 'react-native'
|
import {View} from 'react-native'
|
||||||
import {useNavigation} from '@react-navigation/native'
|
|
||||||
import {useLingui} from '@lingui/react'
|
|
||||||
import {msg, Trans} from '@lingui/macro'
|
import {msg, Trans} from '@lingui/macro'
|
||||||
|
import {useLingui} from '@lingui/react'
|
||||||
|
import {useNavigation} from '@react-navigation/native'
|
||||||
|
|
||||||
import {atoms as a, native, useTheme} from '#/alf'
|
|
||||||
import * as Dialog from '#/components/Dialog'
|
|
||||||
import {Text} from '#/components/Typography'
|
|
||||||
import {Button, ButtonText} from '#/components/Button'
|
|
||||||
import {MagnifyingGlass2_Stroke2_Corner0_Rounded as Search} from '#/components/icons/MagnifyingGlass2'
|
|
||||||
import {Person_Stroke2_Corner0_Rounded as Person} from '#/components/icons/Person'
|
|
||||||
import {Mute_Stroke2_Corner0_Rounded as Mute} from '#/components/icons/Mute'
|
|
||||||
import {Divider} from '#/components/Divider'
|
|
||||||
import {Link} from '#/components/Link'
|
|
||||||
import {makeSearchLink} from '#/lib/routes/links'
|
import {makeSearchLink} from '#/lib/routes/links'
|
||||||
import {NavigationProp} from '#/lib/routes/types'
|
import {NavigationProp} from '#/lib/routes/types'
|
||||||
|
import {isInvalidHandle} from '#/lib/strings/handles'
|
||||||
import {
|
import {
|
||||||
usePreferencesQuery,
|
usePreferencesQuery,
|
||||||
|
useRemoveMutedWordsMutation,
|
||||||
useUpsertMutedWordsMutation,
|
useUpsertMutedWordsMutation,
|
||||||
useRemoveMutedWordMutation,
|
|
||||||
} from '#/state/queries/preferences'
|
} from '#/state/queries/preferences'
|
||||||
|
import {atoms as a, native, useTheme} from '#/alf'
|
||||||
|
import {Button, ButtonText} from '#/components/Button'
|
||||||
|
import * as Dialog from '#/components/Dialog'
|
||||||
|
import {Divider} from '#/components/Divider'
|
||||||
|
import {MagnifyingGlass2_Stroke2_Corner0_Rounded as Search} from '#/components/icons/MagnifyingGlass2'
|
||||||
|
import {Mute_Stroke2_Corner0_Rounded as Mute} from '#/components/icons/Mute'
|
||||||
|
import {Person_Stroke2_Corner0_Rounded as Person} from '#/components/icons/Person'
|
||||||
|
import {Link} from '#/components/Link'
|
||||||
import {Loader} from '#/components/Loader'
|
import {Loader} from '#/components/Loader'
|
||||||
import {isInvalidHandle} from '#/lib/strings/handles'
|
import {Text} from '#/components/Typography'
|
||||||
|
|
||||||
export function useTagMenuControl() {
|
export function useTagMenuControl() {
|
||||||
return Dialog.useDialogControl()
|
return Dialog.useDialogControl()
|
||||||
|
@ -52,10 +52,10 @@ export function TagMenu({
|
||||||
reset: resetUpsert,
|
reset: resetUpsert,
|
||||||
} = useUpsertMutedWordsMutation()
|
} = useUpsertMutedWordsMutation()
|
||||||
const {
|
const {
|
||||||
mutateAsync: removeMutedWord,
|
mutateAsync: removeMutedWords,
|
||||||
variables: optimisticRemove,
|
variables: optimisticRemove,
|
||||||
reset: resetRemove,
|
reset: resetRemove,
|
||||||
} = useRemoveMutedWordMutation()
|
} = useRemoveMutedWordsMutation()
|
||||||
const displayTag = '#' + tag
|
const displayTag = '#' + tag
|
||||||
|
|
||||||
const isMuted = Boolean(
|
const isMuted = Boolean(
|
||||||
|
@ -65,9 +65,20 @@ export function TagMenu({
|
||||||
optimisticUpsert?.find(
|
optimisticUpsert?.find(
|
||||||
m => m.value === tag && m.targets.includes('tag'),
|
m => m.value === tag && m.targets.includes('tag'),
|
||||||
)) &&
|
)) &&
|
||||||
!(optimisticRemove?.value === tag),
|
!optimisticRemove?.find(m => m?.value === tag),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Mute word records that exactly match the tag in question.
|
||||||
|
*/
|
||||||
|
const removeableMuteWords = React.useMemo(() => {
|
||||||
|
return (
|
||||||
|
preferences?.moderationPrefs.mutedWords?.filter(word => {
|
||||||
|
return word.value === tag
|
||||||
|
}) || []
|
||||||
|
)
|
||||||
|
}, [tag, preferences?.moderationPrefs?.mutedWords])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{children}
|
{children}
|
||||||
|
@ -212,13 +223,16 @@ export function TagMenu({
|
||||||
control.close(() => {
|
control.close(() => {
|
||||||
if (isMuted) {
|
if (isMuted) {
|
||||||
resetUpsert()
|
resetUpsert()
|
||||||
removeMutedWord({
|
removeMutedWords(removeableMuteWords)
|
||||||
value: tag,
|
|
||||||
targets: ['tag'],
|
|
||||||
})
|
|
||||||
} else {
|
} else {
|
||||||
resetRemove()
|
resetRemove()
|
||||||
upsertMutedWord([{value: tag, targets: ['tag']}])
|
upsertMutedWord([
|
||||||
|
{
|
||||||
|
value: tag,
|
||||||
|
targets: ['tag'],
|
||||||
|
actorTarget: 'all',
|
||||||
|
},
|
||||||
|
])
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
}}>
|
}}>
|
||||||
|
|
|
@ -3,16 +3,16 @@ import {msg} from '@lingui/macro'
|
||||||
import {useLingui} from '@lingui/react'
|
import {useLingui} from '@lingui/react'
|
||||||
import {useNavigation} from '@react-navigation/native'
|
import {useNavigation} from '@react-navigation/native'
|
||||||
|
|
||||||
import {isInvalidHandle} from '#/lib/strings/handles'
|
|
||||||
import {EventStopper} from '#/view/com/util/EventStopper'
|
|
||||||
import {NativeDropdown} from '#/view/com/util/forms/NativeDropdown'
|
|
||||||
import {NavigationProp} from '#/lib/routes/types'
|
import {NavigationProp} from '#/lib/routes/types'
|
||||||
|
import {isInvalidHandle} from '#/lib/strings/handles'
|
||||||
|
import {enforceLen} from '#/lib/strings/helpers'
|
||||||
import {
|
import {
|
||||||
usePreferencesQuery,
|
usePreferencesQuery,
|
||||||
|
useRemoveMutedWordsMutation,
|
||||||
useUpsertMutedWordsMutation,
|
useUpsertMutedWordsMutation,
|
||||||
useRemoveMutedWordMutation,
|
|
||||||
} from '#/state/queries/preferences'
|
} from '#/state/queries/preferences'
|
||||||
import {enforceLen} from '#/lib/strings/helpers'
|
import {EventStopper} from '#/view/com/util/EventStopper'
|
||||||
|
import {NativeDropdown} from '#/view/com/util/forms/NativeDropdown'
|
||||||
import {web} from '#/alf'
|
import {web} from '#/alf'
|
||||||
import * as Dialog from '#/components/Dialog'
|
import * as Dialog from '#/components/Dialog'
|
||||||
|
|
||||||
|
@ -47,8 +47,8 @@ export function TagMenu({
|
||||||
const {data: preferences} = usePreferencesQuery()
|
const {data: preferences} = usePreferencesQuery()
|
||||||
const {mutateAsync: upsertMutedWord, variables: optimisticUpsert} =
|
const {mutateAsync: upsertMutedWord, variables: optimisticUpsert} =
|
||||||
useUpsertMutedWordsMutation()
|
useUpsertMutedWordsMutation()
|
||||||
const {mutateAsync: removeMutedWord, variables: optimisticRemove} =
|
const {mutateAsync: removeMutedWords, variables: optimisticRemove} =
|
||||||
useRemoveMutedWordMutation()
|
useRemoveMutedWordsMutation()
|
||||||
const isMuted = Boolean(
|
const isMuted = Boolean(
|
||||||
(preferences?.moderationPrefs.mutedWords?.find(
|
(preferences?.moderationPrefs.mutedWords?.find(
|
||||||
m => m.value === tag && m.targets.includes('tag'),
|
m => m.value === tag && m.targets.includes('tag'),
|
||||||
|
@ -56,10 +56,21 @@ export function TagMenu({
|
||||||
optimisticUpsert?.find(
|
optimisticUpsert?.find(
|
||||||
m => m.value === tag && m.targets.includes('tag'),
|
m => m.value === tag && m.targets.includes('tag'),
|
||||||
)) &&
|
)) &&
|
||||||
!(optimisticRemove?.value === tag),
|
!optimisticRemove?.find(m => m?.value === tag),
|
||||||
)
|
)
|
||||||
const truncatedTag = '#' + enforceLen(tag, 15, true, 'middle')
|
const truncatedTag = '#' + enforceLen(tag, 15, true, 'middle')
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Mute word records that exactly match the tag in question.
|
||||||
|
*/
|
||||||
|
const removeableMuteWords = React.useMemo(() => {
|
||||||
|
return (
|
||||||
|
preferences?.moderationPrefs.mutedWords?.filter(word => {
|
||||||
|
return word.value === tag
|
||||||
|
}) || []
|
||||||
|
)
|
||||||
|
}, [tag, preferences?.moderationPrefs?.mutedWords])
|
||||||
|
|
||||||
const dropdownItems = React.useMemo(() => {
|
const dropdownItems = React.useMemo(() => {
|
||||||
return [
|
return [
|
||||||
{
|
{
|
||||||
|
@ -105,9 +116,11 @@ export function TagMenu({
|
||||||
: _(msg`Mute ${truncatedTag}`),
|
: _(msg`Mute ${truncatedTag}`),
|
||||||
onPress() {
|
onPress() {
|
||||||
if (isMuted) {
|
if (isMuted) {
|
||||||
removeMutedWord({value: tag, targets: ['tag']})
|
removeMutedWords(removeableMuteWords)
|
||||||
} else {
|
} else {
|
||||||
upsertMutedWord([{value: tag, targets: ['tag']}])
|
upsertMutedWord([
|
||||||
|
{value: tag, targets: ['tag'], actorTarget: 'all'},
|
||||||
|
])
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
testID: 'tagMenuMute',
|
testID: 'tagMenuMute',
|
||||||
|
@ -129,7 +142,8 @@ export function TagMenu({
|
||||||
tag,
|
tag,
|
||||||
truncatedTag,
|
truncatedTag,
|
||||||
upsertMutedWord,
|
upsertMutedWord,
|
||||||
removeMutedWord,
|
removeMutedWords,
|
||||||
|
removeableMuteWords,
|
||||||
])
|
])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|
|
@ -1,5 +1,5 @@
|
||||||
import React from 'react'
|
import React from 'react'
|
||||||
import {Keyboard, View} from 'react-native'
|
import {View} from 'react-native'
|
||||||
import {AppBskyActorDefs, sanitizeMutedWordValue} from '@atproto/api'
|
import {AppBskyActorDefs, sanitizeMutedWordValue} from '@atproto/api'
|
||||||
import {msg, Trans} from '@lingui/macro'
|
import {msg, Trans} from '@lingui/macro'
|
||||||
import {useLingui} from '@lingui/react'
|
import {useLingui} from '@lingui/react'
|
||||||
|
@ -24,6 +24,7 @@ import * as Dialog from '#/components/Dialog'
|
||||||
import {useGlobalDialogsControlContext} from '#/components/dialogs/Context'
|
import {useGlobalDialogsControlContext} from '#/components/dialogs/Context'
|
||||||
import {Divider} from '#/components/Divider'
|
import {Divider} from '#/components/Divider'
|
||||||
import * as Toggle from '#/components/forms/Toggle'
|
import * as Toggle from '#/components/forms/Toggle'
|
||||||
|
import {useFormatDistance} from '#/components/hooks/dates'
|
||||||
import {Hashtag_Stroke2_Corner0_Rounded as Hashtag} from '#/components/icons/Hashtag'
|
import {Hashtag_Stroke2_Corner0_Rounded as Hashtag} from '#/components/icons/Hashtag'
|
||||||
import {PageText_Stroke2_Corner0_Rounded as PageText} from '#/components/icons/PageText'
|
import {PageText_Stroke2_Corner0_Rounded as PageText} from '#/components/icons/PageText'
|
||||||
import {PlusLarge_Stroke2_Corner0_Rounded as Plus} from '#/components/icons/Plus'
|
import {PlusLarge_Stroke2_Corner0_Rounded as Plus} from '#/components/icons/Plus'
|
||||||
|
@ -32,6 +33,8 @@ import {Loader} from '#/components/Loader'
|
||||||
import * as Prompt from '#/components/Prompt'
|
import * as Prompt from '#/components/Prompt'
|
||||||
import {Text} from '#/components/Typography'
|
import {Text} from '#/components/Typography'
|
||||||
|
|
||||||
|
const ONE_DAY = 24 * 60 * 60 * 1000
|
||||||
|
|
||||||
export function MutedWordsDialog() {
|
export function MutedWordsDialog() {
|
||||||
const {mutedWordsDialogControl: control} = useGlobalDialogsControlContext()
|
const {mutedWordsDialogControl: control} = useGlobalDialogsControlContext()
|
||||||
return (
|
return (
|
||||||
|
@ -53,16 +56,32 @@ function MutedWordsInner() {
|
||||||
} = usePreferencesQuery()
|
} = usePreferencesQuery()
|
||||||
const {isPending, mutateAsync: addMutedWord} = useUpsertMutedWordsMutation()
|
const {isPending, mutateAsync: addMutedWord} = useUpsertMutedWordsMutation()
|
||||||
const [field, setField] = React.useState('')
|
const [field, setField] = React.useState('')
|
||||||
const [options, setOptions] = React.useState(['content'])
|
const [targets, setTargets] = React.useState(['content'])
|
||||||
const [error, setError] = React.useState('')
|
const [error, setError] = React.useState('')
|
||||||
|
const [durations, setDurations] = React.useState(['forever'])
|
||||||
|
const [excludeFollowing, setExcludeFollowing] = React.useState(false)
|
||||||
|
|
||||||
const submit = React.useCallback(async () => {
|
const submit = React.useCallback(async () => {
|
||||||
const sanitizedValue = sanitizeMutedWordValue(field)
|
const sanitizedValue = sanitizeMutedWordValue(field)
|
||||||
const targets = ['tag', options.includes('content') && 'content'].filter(
|
const surfaces = ['tag', targets.includes('content') && 'content'].filter(
|
||||||
Boolean,
|
Boolean,
|
||||||
) as AppBskyActorDefs.MutedWord['targets']
|
) as AppBskyActorDefs.MutedWord['targets']
|
||||||
|
const actorTarget = excludeFollowing ? 'exclude-following' : 'all'
|
||||||
|
|
||||||
if (!sanitizedValue || !targets.length) {
|
const now = Date.now()
|
||||||
|
const rawDuration = durations.at(0)
|
||||||
|
// undefined evaluates to 'forever'
|
||||||
|
let duration: string | undefined
|
||||||
|
|
||||||
|
if (rawDuration === '24_hours') {
|
||||||
|
duration = new Date(now + ONE_DAY).toISOString()
|
||||||
|
} else if (rawDuration === '7_days') {
|
||||||
|
duration = new Date(now + 7 * ONE_DAY).toISOString()
|
||||||
|
} else if (rawDuration === '30_days') {
|
||||||
|
duration = new Date(now + 30 * ONE_DAY).toISOString()
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!sanitizedValue || !surfaces.length) {
|
||||||
setField('')
|
setField('')
|
||||||
setError(_(msg`Please enter a valid word, tag, or phrase to mute`))
|
setError(_(msg`Please enter a valid word, tag, or phrase to mute`))
|
||||||
return
|
return
|
||||||
|
@ -70,28 +89,37 @@ function MutedWordsInner() {
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// send raw value and rely on SDK as sanitization source of truth
|
// send raw value and rely on SDK as sanitization source of truth
|
||||||
await addMutedWord([{value: field, targets}])
|
await addMutedWord([
|
||||||
|
{
|
||||||
|
value: field,
|
||||||
|
targets: surfaces,
|
||||||
|
actorTarget,
|
||||||
|
expiresAt: duration,
|
||||||
|
},
|
||||||
|
])
|
||||||
setField('')
|
setField('')
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
logger.error(`Failed to save muted word`, {message: e.message})
|
logger.error(`Failed to save muted word`, {message: e.message})
|
||||||
setError(e.message)
|
setError(e.message)
|
||||||
}
|
}
|
||||||
}, [_, field, options, addMutedWord, setField])
|
}, [_, field, targets, addMutedWord, setField, durations, excludeFollowing])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog.ScrollableInner label={_(msg`Manage your muted words and tags`)}>
|
<Dialog.ScrollableInner label={_(msg`Manage your muted words and tags`)}>
|
||||||
<View onTouchStart={Keyboard.dismiss}>
|
<View>
|
||||||
<Text
|
<Text
|
||||||
style={[a.text_md, a.font_bold, a.pb_sm, t.atoms.text_contrast_high]}>
|
style={[a.text_md, a.font_bold, a.pb_sm, t.atoms.text_contrast_high]}>
|
||||||
<Trans>Add muted words and tags</Trans>
|
<Trans>Add muted words and tags</Trans>
|
||||||
</Text>
|
</Text>
|
||||||
<Text style={[a.pb_lg, a.leading_snug, t.atoms.text_contrast_medium]}>
|
<Text style={[a.pb_lg, a.leading_snug, t.atoms.text_contrast_medium]}>
|
||||||
<Trans>
|
<Trans>
|
||||||
Posts can be muted based on their text, their tags, or both.
|
Posts can be muted based on their text, their tags, or both. We
|
||||||
|
recommend avoiding common words that appear in many posts, since it
|
||||||
|
can result in no posts being shown.
|
||||||
</Trans>
|
</Trans>
|
||||||
</Text>
|
</Text>
|
||||||
|
|
||||||
<View style={[a.pb_lg]}>
|
<View style={[a.pb_sm]}>
|
||||||
<Dialog.Input
|
<Dialog.Input
|
||||||
autoCorrect={false}
|
autoCorrect={false}
|
||||||
autoCapitalize="none"
|
autoCapitalize="none"
|
||||||
|
@ -107,30 +135,135 @@ function MutedWordsInner() {
|
||||||
}}
|
}}
|
||||||
onSubmitEditing={submit}
|
onSubmitEditing={submit}
|
||||||
/>
|
/>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View style={[a.pb_xl, a.gap_sm]}>
|
||||||
<Toggle.Group
|
<Toggle.Group
|
||||||
label={_(msg`Toggle between muted word options.`)}
|
label={_(msg`Select how long to mute this word for.`)}
|
||||||
type="radio"
|
type="radio"
|
||||||
values={options}
|
values={durations}
|
||||||
onChange={setOptions}>
|
onChange={setDurations}>
|
||||||
|
<Text
|
||||||
|
style={[
|
||||||
|
a.pb_xs,
|
||||||
|
a.text_sm,
|
||||||
|
a.font_bold,
|
||||||
|
t.atoms.text_contrast_medium,
|
||||||
|
]}>
|
||||||
|
<Trans>Duration:</Trans>
|
||||||
|
</Text>
|
||||||
|
|
||||||
<View
|
<View
|
||||||
style={[
|
style={[
|
||||||
a.pt_sm,
|
gtMobile && [a.flex_row, a.align_center, a.justify_start],
|
||||||
a.py_sm,
|
a.gap_sm,
|
||||||
|
]}>
|
||||||
|
<View
|
||||||
|
style={[
|
||||||
|
a.flex_1,
|
||||||
a.flex_row,
|
a.flex_row,
|
||||||
|
a.justify_start,
|
||||||
a.align_center,
|
a.align_center,
|
||||||
a.gap_sm,
|
a.gap_sm,
|
||||||
a.flex_wrap,
|
|
||||||
]}>
|
]}>
|
||||||
|
<Toggle.Item
|
||||||
|
label={_(msg`Mute this word until you unmute it`)}
|
||||||
|
name="forever"
|
||||||
|
style={[a.flex_1]}>
|
||||||
|
<TargetToggle>
|
||||||
|
<View
|
||||||
|
style={[a.flex_1, a.flex_row, a.align_center, a.gap_sm]}>
|
||||||
|
<Toggle.Radio />
|
||||||
|
<Toggle.LabelText style={[a.flex_1, a.leading_tight]}>
|
||||||
|
<Trans>Forever</Trans>
|
||||||
|
</Toggle.LabelText>
|
||||||
|
</View>
|
||||||
|
</TargetToggle>
|
||||||
|
</Toggle.Item>
|
||||||
|
|
||||||
|
<Toggle.Item
|
||||||
|
label={_(msg`Mute this word for 24 hours`)}
|
||||||
|
name="24_hours"
|
||||||
|
style={[a.flex_1]}>
|
||||||
|
<TargetToggle>
|
||||||
|
<View
|
||||||
|
style={[a.flex_1, a.flex_row, a.align_center, a.gap_sm]}>
|
||||||
|
<Toggle.Radio />
|
||||||
|
<Toggle.LabelText style={[a.flex_1, a.leading_tight]}>
|
||||||
|
<Trans>24 hours</Trans>
|
||||||
|
</Toggle.LabelText>
|
||||||
|
</View>
|
||||||
|
</TargetToggle>
|
||||||
|
</Toggle.Item>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View
|
||||||
|
style={[
|
||||||
|
a.flex_1,
|
||||||
|
a.flex_row,
|
||||||
|
a.justify_start,
|
||||||
|
a.align_center,
|
||||||
|
a.gap_sm,
|
||||||
|
]}>
|
||||||
|
<Toggle.Item
|
||||||
|
label={_(msg`Mute this word for 7 days`)}
|
||||||
|
name="7_days"
|
||||||
|
style={[a.flex_1]}>
|
||||||
|
<TargetToggle>
|
||||||
|
<View
|
||||||
|
style={[a.flex_1, a.flex_row, a.align_center, a.gap_sm]}>
|
||||||
|
<Toggle.Radio />
|
||||||
|
<Toggle.LabelText style={[a.flex_1, a.leading_tight]}>
|
||||||
|
<Trans>7 days</Trans>
|
||||||
|
</Toggle.LabelText>
|
||||||
|
</View>
|
||||||
|
</TargetToggle>
|
||||||
|
</Toggle.Item>
|
||||||
|
|
||||||
|
<Toggle.Item
|
||||||
|
label={_(msg`Mute this word for 30 days`)}
|
||||||
|
name="30_days"
|
||||||
|
style={[a.flex_1]}>
|
||||||
|
<TargetToggle>
|
||||||
|
<View
|
||||||
|
style={[a.flex_1, a.flex_row, a.align_center, a.gap_sm]}>
|
||||||
|
<Toggle.Radio />
|
||||||
|
<Toggle.LabelText style={[a.flex_1, a.leading_tight]}>
|
||||||
|
<Trans>30 days</Trans>
|
||||||
|
</Toggle.LabelText>
|
||||||
|
</View>
|
||||||
|
</TargetToggle>
|
||||||
|
</Toggle.Item>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</Toggle.Group>
|
||||||
|
|
||||||
|
<Toggle.Group
|
||||||
|
label={_(msg`Select what content this mute word should apply to.`)}
|
||||||
|
type="radio"
|
||||||
|
values={targets}
|
||||||
|
onChange={setTargets}>
|
||||||
|
<Text
|
||||||
|
style={[
|
||||||
|
a.pb_xs,
|
||||||
|
a.text_sm,
|
||||||
|
a.font_bold,
|
||||||
|
t.atoms.text_contrast_medium,
|
||||||
|
]}>
|
||||||
|
<Trans>Mute in:</Trans>
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
<View style={[a.flex_row, a.align_center, a.gap_sm, a.flex_wrap]}>
|
||||||
<Toggle.Item
|
<Toggle.Item
|
||||||
label={_(msg`Mute this word in post text and tags`)}
|
label={_(msg`Mute this word in post text and tags`)}
|
||||||
name="content"
|
name="content"
|
||||||
style={[a.flex_1, !gtMobile && [a.w_full, a.flex_0]]}>
|
style={[a.flex_1]}>
|
||||||
<TargetToggle>
|
<TargetToggle>
|
||||||
<View style={[a.flex_row, a.align_center, a.gap_sm]}>
|
<View
|
||||||
|
style={[a.flex_1, a.flex_row, a.align_center, a.gap_sm]}>
|
||||||
<Toggle.Radio />
|
<Toggle.Radio />
|
||||||
<Toggle.LabelText>
|
<Toggle.LabelText style={[a.flex_1, a.leading_tight]}>
|
||||||
<Trans>Mute in text & tags</Trans>
|
<Trans>Text & tags</Trans>
|
||||||
</Toggle.LabelText>
|
</Toggle.LabelText>
|
||||||
</View>
|
</View>
|
||||||
<PageText size="sm" />
|
<PageText size="sm" />
|
||||||
|
@ -140,33 +273,63 @@ function MutedWordsInner() {
|
||||||
<Toggle.Item
|
<Toggle.Item
|
||||||
label={_(msg`Mute this word in tags only`)}
|
label={_(msg`Mute this word in tags only`)}
|
||||||
name="tag"
|
name="tag"
|
||||||
style={[a.flex_1, !gtMobile && [a.w_full, a.flex_0]]}>
|
style={[a.flex_1]}>
|
||||||
<TargetToggle>
|
<TargetToggle>
|
||||||
<View style={[a.flex_row, a.align_center, a.gap_sm]}>
|
<View
|
||||||
|
style={[a.flex_1, a.flex_row, a.align_center, a.gap_sm]}>
|
||||||
<Toggle.Radio />
|
<Toggle.Radio />
|
||||||
<Toggle.LabelText>
|
<Toggle.LabelText style={[a.flex_1, a.leading_tight]}>
|
||||||
<Trans>Mute in tags only</Trans>
|
<Trans>Tags only</Trans>
|
||||||
</Toggle.LabelText>
|
</Toggle.LabelText>
|
||||||
</View>
|
</View>
|
||||||
<Hashtag size="sm" />
|
<Hashtag size="sm" />
|
||||||
</TargetToggle>
|
</TargetToggle>
|
||||||
</Toggle.Item>
|
</Toggle.Item>
|
||||||
|
</View>
|
||||||
|
</Toggle.Group>
|
||||||
|
|
||||||
|
<View>
|
||||||
|
<Text
|
||||||
|
style={[
|
||||||
|
a.pb_xs,
|
||||||
|
a.text_sm,
|
||||||
|
a.font_bold,
|
||||||
|
t.atoms.text_contrast_medium,
|
||||||
|
]}>
|
||||||
|
<Trans>Options:</Trans>
|
||||||
|
</Text>
|
||||||
|
<Toggle.Item
|
||||||
|
label={_(msg`Do not apply this mute word to users you follow`)}
|
||||||
|
name="exclude_following"
|
||||||
|
style={[a.flex_row, a.justify_between]}
|
||||||
|
value={excludeFollowing}
|
||||||
|
onChange={setExcludeFollowing}>
|
||||||
|
<TargetToggle>
|
||||||
|
<View style={[a.flex_1, a.flex_row, a.align_center, a.gap_sm]}>
|
||||||
|
<Toggle.Checkbox />
|
||||||
|
<Toggle.LabelText style={[a.flex_1, a.leading_tight]}>
|
||||||
|
<Trans>Exclude users you follow</Trans>
|
||||||
|
</Toggle.LabelText>
|
||||||
|
</View>
|
||||||
|
</TargetToggle>
|
||||||
|
</Toggle.Item>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View style={[a.pt_xs]}>
|
||||||
<Button
|
<Button
|
||||||
disabled={isPending || !field}
|
disabled={isPending || !field}
|
||||||
label={_(msg`Add mute word for configured settings`)}
|
label={_(msg`Add mute word for configured settings`)}
|
||||||
size="small"
|
size="medium"
|
||||||
color="primary"
|
color="primary"
|
||||||
variant="solid"
|
variant="solid"
|
||||||
style={[!gtMobile && [a.w_full, a.flex_0]]}
|
style={[]}
|
||||||
onPress={submit}>
|
onPress={submit}>
|
||||||
<ButtonText>
|
<ButtonText>
|
||||||
<Trans>Add</Trans>
|
<Trans>Add</Trans>
|
||||||
</ButtonText>
|
</ButtonText>
|
||||||
<ButtonIcon icon={isPending ? Loader : Plus} />
|
<ButtonIcon icon={isPending ? Loader : Plus} position="right" />
|
||||||
</Button>
|
</Button>
|
||||||
</View>
|
</View>
|
||||||
</Toggle.Group>
|
|
||||||
|
|
||||||
{error && (
|
{error && (
|
||||||
<View
|
<View
|
||||||
|
@ -191,20 +354,6 @@ function MutedWordsInner() {
|
||||||
</Text>
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<Text
|
|
||||||
style={[
|
|
||||||
a.pt_xs,
|
|
||||||
a.text_sm,
|
|
||||||
a.italic,
|
|
||||||
a.leading_snug,
|
|
||||||
t.atoms.text_contrast_medium,
|
|
||||||
]}>
|
|
||||||
<Trans>
|
|
||||||
We recommend avoiding common words that appear in many posts,
|
|
||||||
since it can result in no posts being shown.
|
|
||||||
</Trans>
|
|
||||||
</Text>
|
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
<Divider />
|
<Divider />
|
||||||
|
@ -268,6 +417,9 @@ function MutedWordRow({
|
||||||
const {_} = useLingui()
|
const {_} = useLingui()
|
||||||
const {isPending, mutateAsync: removeMutedWord} = useRemoveMutedWordMutation()
|
const {isPending, mutateAsync: removeMutedWord} = useRemoveMutedWordMutation()
|
||||||
const control = Prompt.usePromptControl()
|
const control = Prompt.usePromptControl()
|
||||||
|
const expiryDate = word.expiresAt ? new Date(word.expiresAt) : undefined
|
||||||
|
const isExpired = expiryDate && expiryDate < new Date()
|
||||||
|
const formatDistance = useFormatDistance()
|
||||||
|
|
||||||
const remove = React.useCallback(async () => {
|
const remove = React.useCallback(async () => {
|
||||||
control.close()
|
control.close()
|
||||||
|
@ -280,7 +432,7 @@ function MutedWordRow({
|
||||||
control={control}
|
control={control}
|
||||||
title={_(msg`Are you sure?`)}
|
title={_(msg`Are you sure?`)}
|
||||||
description={_(
|
description={_(
|
||||||
msg`This will delete ${word.value} from your muted words. You can always add it back later.`,
|
msg`This will delete "${word.value}" from your muted words. You can always add it back later.`,
|
||||||
)}
|
)}
|
||||||
onConfirm={remove}
|
onConfirm={remove}
|
||||||
confirmButtonCta={_(msg`Remove`)}
|
confirmButtonCta={_(msg`Remove`)}
|
||||||
|
@ -289,54 +441,95 @@ function MutedWordRow({
|
||||||
|
|
||||||
<View
|
<View
|
||||||
style={[
|
style={[
|
||||||
|
a.flex_row,
|
||||||
|
a.justify_between,
|
||||||
a.py_md,
|
a.py_md,
|
||||||
a.px_lg,
|
a.px_lg,
|
||||||
a.flex_row,
|
|
||||||
a.align_center,
|
|
||||||
a.justify_between,
|
|
||||||
a.rounded_md,
|
a.rounded_md,
|
||||||
a.gap_md,
|
a.gap_md,
|
||||||
style,
|
style,
|
||||||
]}>
|
]}>
|
||||||
|
<View style={[a.flex_1, a.gap_xs]}>
|
||||||
|
<View style={[a.flex_row, a.align_center, a.gap_sm]}>
|
||||||
<Text
|
<Text
|
||||||
style={[
|
style={[
|
||||||
a.flex_1,
|
a.flex_1,
|
||||||
a.leading_snug,
|
a.leading_snug,
|
||||||
a.w_full,
|
|
||||||
a.font_bold,
|
a.font_bold,
|
||||||
t.atoms.text_contrast_high,
|
|
||||||
web({
|
web({
|
||||||
overflowWrap: 'break-word',
|
overflowWrap: 'break-word',
|
||||||
wordBreak: 'break-word',
|
wordBreak: 'break-word',
|
||||||
}),
|
}),
|
||||||
]}>
|
]}>
|
||||||
{word.value}
|
{word.targets.find(t => t === 'content') ? (
|
||||||
|
<Trans comment="Pattern: {wordValue} in text, tags">
|
||||||
|
{word.value}{' '}
|
||||||
|
<Text style={[a.font_normal, t.atoms.text_contrast_medium]}>
|
||||||
|
in{' '}
|
||||||
|
<Text style={[a.font_bold, t.atoms.text_contrast_medium]}>
|
||||||
|
text & tags
|
||||||
</Text>
|
</Text>
|
||||||
|
</Text>
|
||||||
<View style={[a.flex_row, a.align_center, a.justify_end, a.gap_sm]}>
|
</Trans>
|
||||||
{word.targets.map(target => (
|
) : (
|
||||||
<View
|
<Trans comment="Pattern: {wordValue} in tags">
|
||||||
key={target}
|
{word.value}{' '}
|
||||||
style={[a.py_xs, a.px_sm, a.rounded_sm, t.atoms.bg_contrast_100]}>
|
<Text style={[a.font_normal, t.atoms.text_contrast_medium]}>
|
||||||
<Text
|
in{' '}
|
||||||
style={[a.text_xs, a.font_bold, t.atoms.text_contrast_medium]}>
|
<Text style={[a.font_bold, t.atoms.text_contrast_medium]}>
|
||||||
{target === 'content' ? _(msg`text`) : _(msg`tag`)}
|
tags
|
||||||
|
</Text>
|
||||||
|
</Text>
|
||||||
|
</Trans>
|
||||||
|
)}
|
||||||
</Text>
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
))}
|
|
||||||
|
{(expiryDate || word.actorTarget === 'exclude-following') && (
|
||||||
|
<View style={[a.flex_1, a.flex_row, a.align_center, a.gap_sm]}>
|
||||||
|
<Text
|
||||||
|
style={[
|
||||||
|
a.flex_1,
|
||||||
|
a.text_xs,
|
||||||
|
a.leading_snug,
|
||||||
|
t.atoms.text_contrast_medium,
|
||||||
|
]}>
|
||||||
|
{expiryDate && (
|
||||||
|
<>
|
||||||
|
{isExpired ? (
|
||||||
|
<Trans>Expired</Trans>
|
||||||
|
) : (
|
||||||
|
<Trans>
|
||||||
|
Expires{' '}
|
||||||
|
{formatDistance(expiryDate, new Date(), {
|
||||||
|
addSuffix: true,
|
||||||
|
})}
|
||||||
|
</Trans>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{word.actorTarget === 'exclude-following' && (
|
||||||
|
<>
|
||||||
|
{' • '}
|
||||||
|
<Trans>Excludes users you follow</Trans>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
|
||||||
<Button
|
<Button
|
||||||
label={_(msg`Remove mute word from your list`)}
|
label={_(msg`Remove mute word from your list`)}
|
||||||
size="tiny"
|
size="tiny"
|
||||||
shape="round"
|
shape="round"
|
||||||
variant="ghost"
|
variant="outline"
|
||||||
color="secondary"
|
color="secondary"
|
||||||
onPress={() => control.open()}
|
onPress={() => control.open()}
|
||||||
style={[a.ml_sm]}>
|
style={[a.ml_sm]}>
|
||||||
<ButtonIcon icon={isPending ? Loader : X} />
|
<ButtonIcon icon={isPending ? Loader : X} />
|
||||||
</Button>
|
</Button>
|
||||||
</View>
|
</View>
|
||||||
</View>
|
|
||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
@ -0,0 +1,69 @@
|
||||||
|
/**
|
||||||
|
* Hooks for date-fns localized formatters.
|
||||||
|
*
|
||||||
|
* Our app supports some languages that are not included in date-fns by
|
||||||
|
* default, in which case it will fall back to English.
|
||||||
|
*
|
||||||
|
* {@link https://github.com/date-fns/date-fns/blob/main/docs/i18n.md}
|
||||||
|
*/
|
||||||
|
|
||||||
|
import React from 'react'
|
||||||
|
import {formatDistance, Locale} from 'date-fns'
|
||||||
|
import {
|
||||||
|
ca,
|
||||||
|
de,
|
||||||
|
es,
|
||||||
|
fi,
|
||||||
|
fr,
|
||||||
|
hi,
|
||||||
|
id,
|
||||||
|
it,
|
||||||
|
ja,
|
||||||
|
ko,
|
||||||
|
ptBR,
|
||||||
|
tr,
|
||||||
|
uk,
|
||||||
|
zhCN,
|
||||||
|
zhTW,
|
||||||
|
} from 'date-fns/locale'
|
||||||
|
|
||||||
|
import {AppLanguage} from '#/locale/languages'
|
||||||
|
import {useLanguagePrefs} from '#/state/preferences'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* {@link AppLanguage}
|
||||||
|
*/
|
||||||
|
const locales: Record<AppLanguage, Locale | undefined> = {
|
||||||
|
en: undefined,
|
||||||
|
ca,
|
||||||
|
de,
|
||||||
|
es,
|
||||||
|
fi,
|
||||||
|
fr,
|
||||||
|
ga: undefined,
|
||||||
|
hi,
|
||||||
|
id,
|
||||||
|
it,
|
||||||
|
ja,
|
||||||
|
ko,
|
||||||
|
['pt-BR']: ptBR,
|
||||||
|
tr,
|
||||||
|
uk,
|
||||||
|
['zh-CN']: zhCN,
|
||||||
|
['zh-TW']: zhTW,
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns a localized `formatDistance` function.
|
||||||
|
* {@link formatDistance}
|
||||||
|
*/
|
||||||
|
export function useFormatDistance() {
|
||||||
|
const {appLanguage} = useLanguagePrefs()
|
||||||
|
return React.useCallback<typeof formatDistance>(
|
||||||
|
(date, baseDate, options) => {
|
||||||
|
const locale = locales[appLanguage as AppLanguage]
|
||||||
|
return formatDistance(date, baseDate, {...options, locale: locale})
|
||||||
|
},
|
||||||
|
[appLanguage],
|
||||||
|
)
|
||||||
|
}
|
|
@ -343,6 +343,21 @@ export function useRemoveMutedWordMutation() {
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function useRemoveMutedWordsMutation() {
|
||||||
|
const queryClient = useQueryClient()
|
||||||
|
const agent = useAgent()
|
||||||
|
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: async (mutedWords: AppBskyActorDefs.MutedWord[]) => {
|
||||||
|
await agent.removeMutedWords(mutedWords)
|
||||||
|
// triggers a refetch
|
||||||
|
await queryClient.invalidateQueries({
|
||||||
|
queryKey: preferencesQueryKey,
|
||||||
|
})
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
export function useQueueNudgesMutation() {
|
export function useQueueNudgesMutation() {
|
||||||
const queryClient = useQueryClient()
|
const queryClient = useQueryClient()
|
||||||
const agent = useAgent()
|
const agent = useAgent()
|
||||||
|
|
|
@ -34,10 +34,10 @@
|
||||||
jsonpointer "^5.0.0"
|
jsonpointer "^5.0.0"
|
||||||
leven "^3.1.0"
|
leven "^3.1.0"
|
||||||
|
|
||||||
"@atproto/api@0.12.25":
|
"@atproto/api@^0.12.26":
|
||||||
version "0.12.25"
|
version "0.12.26"
|
||||||
resolved "https://registry.yarnpkg.com/@atproto/api/-/api-0.12.25.tgz#9eeb51484106a5e07f89f124e505674a3574f93b"
|
resolved "https://registry.yarnpkg.com/@atproto/api/-/api-0.12.26.tgz#940888466522cc9ff8c03d8164dc39221b29d9ca"
|
||||||
integrity sha512-IV3vGPnDw9bmyP/JOd8YKbm8fOpRAgJpEUVnIZNVb/Vo8v+WOroOjrJxtzdHOcXTL9IEcTTyXSCc7yE7kwhN2A==
|
integrity sha512-RH0ymOGbDfT8IL8eNzzY+hwtyTgknHfkzUVqRd0sstNblvTf8WGpDR2FSTveiiMR3OpVO6zG8fRYVzBfmY1+pA==
|
||||||
dependencies:
|
dependencies:
|
||||||
"@atproto/common-web" "^0.3.0"
|
"@atproto/common-web" "^0.3.0"
|
||||||
"@atproto/lexicon" "^0.4.0"
|
"@atproto/lexicon" "^0.4.0"
|
||||||
|
|
Loading…
Reference in New Issue