diff --git a/package.json b/package.json index 91b427ae91..3d053bc83b 100644 --- a/package.json +++ b/package.json @@ -52,7 +52,7 @@ "open-analyzer": "EXPO_PUBLIC_OPEN_ANALYZER=1 yarn build-web" }, "dependencies": { - "@atproto/api": "0.12.25", + "@atproto/api": "^0.12.26", "@bam.tech/react-native-image-resizer": "^3.0.4", "@braintree/sanitize-url": "^6.0.2", "@discord/bottom-sheet": "bluesky-social/react-native-bottom-sheet", diff --git a/src/components/TagMenu/index.tsx b/src/components/TagMenu/index.tsx index 0ed7036671..2c6a0b674c 100644 --- a/src/components/TagMenu/index.tsx +++ b/src/components/TagMenu/index.tsx @@ -1,27 +1,27 @@ import React from 'react' import {View} from 'react-native' -import {useNavigation} from '@react-navigation/native' -import {useLingui} from '@lingui/react' 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 {NavigationProp} from '#/lib/routes/types' +import {isInvalidHandle} from '#/lib/strings/handles' import { usePreferencesQuery, + useRemoveMutedWordsMutation, useUpsertMutedWordsMutation, - useRemoveMutedWordMutation, } 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 {isInvalidHandle} from '#/lib/strings/handles' +import {Text} from '#/components/Typography' export function useTagMenuControl() { return Dialog.useDialogControl() @@ -52,10 +52,10 @@ export function TagMenu({ reset: resetUpsert, } = useUpsertMutedWordsMutation() const { - mutateAsync: removeMutedWord, + mutateAsync: removeMutedWords, variables: optimisticRemove, reset: resetRemove, - } = useRemoveMutedWordMutation() + } = useRemoveMutedWordsMutation() const displayTag = '#' + tag const isMuted = Boolean( @@ -65,9 +65,20 @@ export function TagMenu({ optimisticUpsert?.find( 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 ( <> {children} @@ -212,13 +223,16 @@ export function TagMenu({ control.close(() => { if (isMuted) { resetUpsert() - removeMutedWord({ - value: tag, - targets: ['tag'], - }) + removeMutedWords(removeableMuteWords) } else { resetRemove() - upsertMutedWord([{value: tag, targets: ['tag']}]) + upsertMutedWord([ + { + value: tag, + targets: ['tag'], + actorTarget: 'all', + }, + ]) } }) }}> diff --git a/src/components/TagMenu/index.web.tsx b/src/components/TagMenu/index.web.tsx index 4336223861..b6c306439a 100644 --- a/src/components/TagMenu/index.web.tsx +++ b/src/components/TagMenu/index.web.tsx @@ -3,16 +3,16 @@ import {msg} from '@lingui/macro' import {useLingui} from '@lingui/react' 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 {isInvalidHandle} from '#/lib/strings/handles' +import {enforceLen} from '#/lib/strings/helpers' import { usePreferencesQuery, + useRemoveMutedWordsMutation, useUpsertMutedWordsMutation, - useRemoveMutedWordMutation, } 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 * as Dialog from '#/components/Dialog' @@ -47,8 +47,8 @@ export function TagMenu({ const {data: preferences} = usePreferencesQuery() const {mutateAsync: upsertMutedWord, variables: optimisticUpsert} = useUpsertMutedWordsMutation() - const {mutateAsync: removeMutedWord, variables: optimisticRemove} = - useRemoveMutedWordMutation() + const {mutateAsync: removeMutedWords, variables: optimisticRemove} = + useRemoveMutedWordsMutation() const isMuted = Boolean( (preferences?.moderationPrefs.mutedWords?.find( m => m.value === tag && m.targets.includes('tag'), @@ -56,10 +56,21 @@ export function TagMenu({ optimisticUpsert?.find( m => m.value === tag && m.targets.includes('tag'), )) && - !(optimisticRemove?.value === tag), + !optimisticRemove?.find(m => m?.value === tag), ) 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(() => { return [ { @@ -105,9 +116,11 @@ export function TagMenu({ : _(msg`Mute ${truncatedTag}`), onPress() { if (isMuted) { - removeMutedWord({value: tag, targets: ['tag']}) + removeMutedWords(removeableMuteWords) } else { - upsertMutedWord([{value: tag, targets: ['tag']}]) + upsertMutedWord([ + {value: tag, targets: ['tag'], actorTarget: 'all'}, + ]) } }, testID: 'tagMenuMute', @@ -129,7 +142,8 @@ export function TagMenu({ tag, truncatedTag, upsertMutedWord, - removeMutedWord, + removeMutedWords, + removeableMuteWords, ]) return ( diff --git a/src/components/dialogs/MutedWords.tsx b/src/components/dialogs/MutedWords.tsx index 526652be95..38273aad54 100644 --- a/src/components/dialogs/MutedWords.tsx +++ b/src/components/dialogs/MutedWords.tsx @@ -1,5 +1,5 @@ import React from 'react' -import {Keyboard, View} from 'react-native' +import {View} from 'react-native' import {AppBskyActorDefs, sanitizeMutedWordValue} from '@atproto/api' import {msg, Trans} from '@lingui/macro' import {useLingui} from '@lingui/react' @@ -24,6 +24,7 @@ import * as Dialog from '#/components/Dialog' import {useGlobalDialogsControlContext} from '#/components/dialogs/Context' import {Divider} from '#/components/Divider' 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 {PageText_Stroke2_Corner0_Rounded as PageText} from '#/components/icons/PageText' 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 {Text} from '#/components/Typography' +const ONE_DAY = 24 * 60 * 60 * 1000 + export function MutedWordsDialog() { const {mutedWordsDialogControl: control} = useGlobalDialogsControlContext() return ( @@ -53,16 +56,32 @@ function MutedWordsInner() { } = usePreferencesQuery() const {isPending, mutateAsync: addMutedWord} = useUpsertMutedWordsMutation() const [field, setField] = React.useState('') - const [options, setOptions] = React.useState(['content']) + const [targets, setTargets] = React.useState(['content']) const [error, setError] = React.useState('') + const [durations, setDurations] = React.useState(['forever']) + const [excludeFollowing, setExcludeFollowing] = React.useState(false) const submit = React.useCallback(async () => { const sanitizedValue = sanitizeMutedWordValue(field) - const targets = ['tag', options.includes('content') && 'content'].filter( + const surfaces = ['tag', targets.includes('content') && 'content'].filter( Boolean, ) as AppBskyActorDefs.MutedWord['targets'] + const actorTarget = excludeFollowing ? 'exclude-following' : 'all' + + 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 || !targets.length) { + if (!sanitizedValue || !surfaces.length) { setField('') setError(_(msg`Please enter a valid word, tag, or phrase to mute`)) return @@ -70,28 +89,37 @@ function MutedWordsInner() { try { // 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('') } catch (e: any) { logger.error(`Failed to save muted word`, {message: e.message}) setError(e.message) } - }, [_, field, options, addMutedWord, setField]) + }, [_, field, targets, addMutedWord, setField, durations, excludeFollowing]) return ( - + Add muted words and tags - 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. - + + + + values={durations} + onChange={setDurations}> + + Duration: + + + + + + + + + Forever + + + + + + + + + + + 24 hours + + + + + + + + + + + + + 7 days + + + + + + + + + + + 30 days + + + + + + + + + + + Mute in: + + + + style={[a.flex_1]}> - + - - Mute in text & tags + + Text & tags @@ -140,34 +273,64 @@ function MutedWordsInner() { + style={[a.flex_1]}> - + - - Mute in tags only + + Tags only - - + + + Options: + + + + + + + Exclude users you follow + + + + + + + + + + {error && ( )} - - - - We recommend avoiding common words that appear in many posts, - since it can result in no posts being shown. - - @@ -268,6 +417,9 @@ function MutedWordRow({ const {_} = useLingui() const {isPending, mutateAsync: removeMutedWord} = useRemoveMutedWordMutation() 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 () => { control.close() @@ -280,7 +432,7 @@ function MutedWordRow({ control={control} title={_(msg`Are you sure?`)} 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} confirmButtonCta={_(msg`Remove`)} @@ -289,53 +441,94 @@ function MutedWordRow({ - - {word.value} - + + + + {word.targets.find(t => t === 'content') ? ( + + {word.value}{' '} + + in{' '} + + text & tags + + + + ) : ( + + {word.value}{' '} + + in{' '} + + tags + + + + )} + + - - {word.targets.map(target => ( - + {(expiryDate || word.actorTarget === 'exclude-following') && ( + - {target === 'content' ? _(msg`text`) : _(msg`tag`)} + style={[ + a.flex_1, + a.text_xs, + a.leading_snug, + t.atoms.text_contrast_medium, + ]}> + {expiryDate && ( + <> + {isExpired ? ( + Expired + ) : ( + + Expires{' '} + {formatDistance(expiryDate, new Date(), { + addSuffix: true, + })} + + )} + + )} + {word.actorTarget === 'exclude-following' && ( + <> + {' • '} + Excludes users you follow + + )} - ))} - - + )} + + ) diff --git a/src/components/hooks/dates.ts b/src/components/hooks/dates.ts new file mode 100644 index 0000000000..b0f94133b7 --- /dev/null +++ b/src/components/hooks/dates.ts @@ -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 = { + 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( + (date, baseDate, options) => { + const locale = locales[appLanguage as AppLanguage] + return formatDistance(date, baseDate, {...options, locale: locale}) + }, + [appLanguage], + ) +} diff --git a/src/state/queries/preferences/index.ts b/src/state/queries/preferences/index.ts index 9bb57fcaf6..6991f8647b 100644 --- a/src/state/queries/preferences/index.ts +++ b/src/state/queries/preferences/index.ts @@ -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() { const queryClient = useQueryClient() const agent = useAgent() diff --git a/yarn.lock b/yarn.lock index 675fda4c2f..6fa8805125 100644 --- a/yarn.lock +++ b/yarn.lock @@ -34,10 +34,10 @@ jsonpointer "^5.0.0" leven "^3.1.0" -"@atproto/api@0.12.25": - version "0.12.25" - resolved "https://registry.yarnpkg.com/@atproto/api/-/api-0.12.25.tgz#9eeb51484106a5e07f89f124e505674a3574f93b" - integrity sha512-IV3vGPnDw9bmyP/JOd8YKbm8fOpRAgJpEUVnIZNVb/Vo8v+WOroOjrJxtzdHOcXTL9IEcTTyXSCc7yE7kwhN2A== +"@atproto/api@^0.12.26": + version "0.12.26" + resolved "https://registry.yarnpkg.com/@atproto/api/-/api-0.12.26.tgz#940888466522cc9ff8c03d8164dc39221b29d9ca" + integrity sha512-RH0ymOGbDfT8IL8eNzzY+hwtyTgknHfkzUVqRd0sstNblvTf8WGpDR2FSTveiiMR3OpVO6zG8fRYVzBfmY1+pA== dependencies: "@atproto/common-web" "^0.3.0" "@atproto/lexicon" "^0.4.0"