From 58aaad704aa971c5ebbf5a5f330a2e2129b557f6 Mon Sep 17 00:00:00 2001 From: Eric Bailey Date: Mon, 26 Feb 2024 22:33:48 -0600 Subject: [PATCH] Add tags and mute words (#2968) * Add bare minimum hashtags support (#2804) * Add bare minimum hashtags support As atproto/api already parses hashtags, this is as simple as hooking it up like link segments. This is "bare minimum" because: - Opening hashtag "#foo" is actually just a search for "foo" right now to work around #2491. - There is no integration in the composer. This hasn't stopped people from using hashtags already, and can be added later. - This change itself only had to hook things up - thank you for having already put the hashtag parsing in place. * Remove workaround for hash search not working now that it's fixed * Add RichTextTag and TagMenu * Sketch * Remove hackfix * Some cleanup * Sketch web * Mobile design * Mobile handling of tags search * Web only * Fix navigation woes * Use new callback * Hook it up * Integrate muted tags * Fix dropdown styles * Type error * Use close callback * Fix styles * Cleanup, install latest sdk * Quick muted words screen * Targets * Dir structure * Icons, list view * Move to dialog * Add removal confirmation * Swap copy * Improve checkboxees * Update matching, add tests * Moderate embeds * Create global dialogs concept again to prevent flashing * Add access from moderation screen * Highlight tags on native * Add web highlighting * Add close to web modal * Adjust close color * Rename toggles and adjust logic * Icon update * Load states * Improve regex * Improve regex * Improve regex * Revert link test * Hyphenated words * Improve matching * Enhance * Some tweaks * Muted words modal changes * Handle invalid handles, handle long tags * Remove main regex * Better test * Space/punct check drop to includes * Lowercase post text before comparison * Add better real world test case --------- Co-authored-by: Kisaragi Hiu --- .../checkThick_stroke2_corner0_rounded.svg | 1 + .../clipboard_stroke2_corner2_rounded.svg | 1 + ...gnifyingGlass2_stroke2_corner0_rounded.svg | 1 + assets/icons/mute_stroke2_corner0_rounded.svg | 1 + .../pageText_stroke2_corner0_rounded.svg | 1 + bskyweb/templates/base.html | 5 + package.json | 2 +- src/Navigation.tsx | 3 +- src/alf/atoms.ts | 4 + src/components/Dialog/index.web.tsx | 2 +- src/components/RichText.tsx | 103 +++- src/components/TagMenu/index.tsx | 279 +++++++++ src/components/TagMenu/index.web.tsx | 127 ++++ src/components/dialogs/Context.tsx | 29 + src/components/dialogs/MutedWords.tsx | 328 ++++++++++ src/components/forms/TextField.tsx | 2 +- src/components/forms/Toggle.tsx | 34 +- src/components/icons/Check.tsx | 4 + src/components/icons/Clipboard.tsx | 5 + src/components/icons/Group3.tsx | 2 +- src/components/icons/MagnifyingGlass2.tsx | 5 + src/components/icons/Mute.tsx | 5 + src/components/icons/PageText.tsx | 5 + src/components/icons/Person.tsx | 5 + .../__tests__/moderatePost_wrapped.test.ts | 578 ++++++++++++++++++ src/lib/moderatePost_wrapped.ts | 156 ++++- src/lib/moderation.ts | 7 + src/lib/routes/links.ts | 10 + src/lib/routes/types.ts | 1 + src/state/dialogs/index.tsx | 3 +- src/state/queries/preferences/const.ts | 2 + src/state/queries/preferences/index.ts | 49 +- .../com/composer/text-input/TextInput.tsx | 3 +- .../com/composer/text-input/TextInput.web.tsx | 2 + .../composer/text-input/web/TagDecorator.ts | 83 +++ src/view/com/post-thread/PostThreadItem.tsx | 6 +- src/view/com/post/Post.tsx | 2 + src/view/com/posts/FeedItem.tsx | 2 + .../com/util/forms/NativeDropdown.web.tsx | 5 + src/view/com/util/forms/PostDropdownBtn.tsx | 16 + src/view/com/util/post-embeds/QuoteEmbed.tsx | 2 + src/view/com/util/text/RichText.tsx | 66 ++ src/view/icons/index.tsx | 2 + src/view/screens/Moderation.tsx | 23 +- src/view/screens/Search/Search.tsx | 27 +- src/view/shell/index.tsx | 2 + src/view/shell/index.web.tsx | 2 + web/index.html | 5 + yarn.lock | 14 + 49 files changed, 1983 insertions(+), 39 deletions(-) create mode 100644 assets/icons/checkThick_stroke2_corner0_rounded.svg create mode 100644 assets/icons/clipboard_stroke2_corner2_rounded.svg create mode 100644 assets/icons/magnifyingGlass2_stroke2_corner0_rounded.svg create mode 100644 assets/icons/mute_stroke2_corner0_rounded.svg create mode 100644 assets/icons/pageText_stroke2_corner0_rounded.svg create mode 100644 src/components/TagMenu/index.tsx create mode 100644 src/components/TagMenu/index.web.tsx create mode 100644 src/components/dialogs/Context.tsx create mode 100644 src/components/dialogs/MutedWords.tsx create mode 100644 src/components/icons/Clipboard.tsx create mode 100644 src/components/icons/MagnifyingGlass2.tsx create mode 100644 src/components/icons/Mute.tsx create mode 100644 src/components/icons/PageText.tsx create mode 100644 src/components/icons/Person.tsx create mode 100644 src/lib/__tests__/moderatePost_wrapped.test.ts create mode 100644 src/view/com/composer/text-input/web/TagDecorator.ts diff --git a/assets/icons/checkThick_stroke2_corner0_rounded.svg b/assets/icons/checkThick_stroke2_corner0_rounded.svg new file mode 100644 index 00000000..54af3e85 --- /dev/null +++ b/assets/icons/checkThick_stroke2_corner0_rounded.svg @@ -0,0 +1 @@ + diff --git a/assets/icons/clipboard_stroke2_corner2_rounded.svg b/assets/icons/clipboard_stroke2_corner2_rounded.svg new file mode 100644 index 00000000..f403cfb9 --- /dev/null +++ b/assets/icons/clipboard_stroke2_corner2_rounded.svg @@ -0,0 +1 @@ + diff --git a/assets/icons/magnifyingGlass2_stroke2_corner0_rounded.svg b/assets/icons/magnifyingGlass2_stroke2_corner0_rounded.svg new file mode 100644 index 00000000..2759aaf2 --- /dev/null +++ b/assets/icons/magnifyingGlass2_stroke2_corner0_rounded.svg @@ -0,0 +1 @@ + diff --git a/assets/icons/mute_stroke2_corner0_rounded.svg b/assets/icons/mute_stroke2_corner0_rounded.svg new file mode 100644 index 00000000..8ebecb39 --- /dev/null +++ b/assets/icons/mute_stroke2_corner0_rounded.svg @@ -0,0 +1 @@ + diff --git a/assets/icons/pageText_stroke2_corner0_rounded.svg b/assets/icons/pageText_stroke2_corner0_rounded.svg new file mode 100644 index 00000000..826a36cd --- /dev/null +++ b/assets/icons/pageText_stroke2_corner0_rounded.svg @@ -0,0 +1 @@ + diff --git a/bskyweb/templates/base.html b/bskyweb/templates/base.html index e29e4032..50fb9a2f 100644 --- a/bskyweb/templates/base.html +++ b/bskyweb/templates/base.html @@ -205,6 +205,11 @@ [data-tooltip]:hover::before { display:block; } + + /* NativeDropdown component */ + .nativeDropdown-item:focus { + outline: none; + } {% include "scripts.html" %} diff --git a/package.json b/package.json index 3f007a23..3d151603 100644 --- a/package.json +++ b/package.json @@ -43,7 +43,7 @@ "nuke": "rm -rf ./node_modules && rm -rf ./ios && rm -rf ./android" }, "dependencies": { - "@atproto/api": "^0.9.5", + "@atproto/api": "^0.10.0", "@bam.tech/react-native-image-resizer": "^3.0.4", "@braintree/sanitize-url": "^6.0.2", "@emoji-mart/react": "^1.1.1", diff --git a/src/Navigation.tsx b/src/Navigation.tsx index 6ca4212e..dfbe816f 100644 --- a/src/Navigation.tsx +++ b/src/Navigation.tsx @@ -497,7 +497,8 @@ const LINKING = { }, ]) } else { - return buildStateObject('Flat', name, params) + const res = buildStateObject('Flat', name, params) + return res } }, } diff --git a/src/alf/atoms.ts b/src/alf/atoms.ts index 18f492d6..fff3a4d8 100644 --- a/src/alf/atoms.ts +++ b/src/alf/atoms.ts @@ -1,3 +1,4 @@ +import {web, native} from '#/alf/util/platform' import * as tokens from '#/alf/tokens' export const atoms = { @@ -113,6 +114,9 @@ export const atoms = { flex_wrap: { flexWrap: 'wrap', }, + flex_0: { + flex: web('0 0 auto') || (native(0) as number), + }, flex_1: { flex: 1, }, diff --git a/src/components/Dialog/index.web.tsx b/src/components/Dialog/index.web.tsx index 79441fb5..fa29fbd6 100644 --- a/src/components/Dialog/index.web.tsx +++ b/src/components/Dialog/index.web.tsx @@ -188,7 +188,7 @@ export function Close() { + + ) : null} + + + + + )} + + + + ) +} diff --git a/src/components/TagMenu/index.web.tsx b/src/components/TagMenu/index.web.tsx new file mode 100644 index 00000000..930e47a1 --- /dev/null +++ b/src/components/TagMenu/index.web.tsx @@ -0,0 +1,127 @@ +import React from 'react' +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 { + usePreferencesQuery, + useUpsertMutedWordsMutation, + useRemoveMutedWordMutation, +} from '#/state/queries/preferences' + +export function useTagMenuControl() {} + +export function TagMenu({ + children, + tag, + authorHandle, +}: React.PropsWithChildren<{ + tag: string + authorHandle?: string +}>) { + const sanitizedTag = tag.replace(/^#/, '') + const {_} = useLingui() + const navigation = useNavigation() + const {data: preferences} = usePreferencesQuery() + const {mutateAsync: upsertMutedWord, variables: optimisticUpsert} = + useUpsertMutedWordsMutation() + const {mutateAsync: removeMutedWord, variables: optimisticRemove} = + useRemoveMutedWordMutation() + const isMuted = Boolean( + (preferences?.mutedWords?.find( + m => m.value === sanitizedTag && m.targets.includes('tag'), + ) ?? + optimisticUpsert?.find( + m => m.value === sanitizedTag && m.targets.includes('tag'), + )) && + !(optimisticRemove?.value === sanitizedTag), + ) + + const dropdownItems = React.useMemo(() => { + return [ + { + label: _(msg`See ${tag} posts`), + onPress() { + navigation.navigate('Search', { + q: tag, + }) + }, + testID: 'tagMenuSearch', + icon: { + ios: { + name: 'magnifyingglass', + }, + android: '', + web: 'magnifying-glass', + }, + }, + authorHandle && + !isInvalidHandle(authorHandle) && { + label: _(msg`See ${tag} posts by this user`), + onPress() { + navigation.navigate({ + name: 'Search', + params: { + q: tag + (authorHandle ? ` from:${authorHandle}` : ''), + }, + }) + }, + testID: 'tagMenuSeachByUser', + icon: { + ios: { + name: 'magnifyingglass', + }, + android: '', + web: ['far', 'user'], + }, + }, + preferences && { + label: 'separator', + }, + preferences && { + label: isMuted ? _(msg`Unmute ${tag}`) : _(msg`Mute ${tag}`), + onPress() { + if (isMuted) { + removeMutedWord({value: sanitizedTag, targets: ['tag']}) + } else { + upsertMutedWord([{value: sanitizedTag, targets: ['tag']}]) + } + }, + testID: 'tagMenuMute', + icon: { + ios: { + name: 'speaker.slash', + }, + android: 'ic_menu_sort_alphabetically', + web: isMuted ? 'eye' : ['far', 'eye-slash'], + }, + }, + ].filter(Boolean) + }, [ + _, + authorHandle, + isMuted, + navigation, + preferences, + tag, + sanitizedTag, + upsertMutedWord, + removeMutedWord, + ]) + + return ( + + + {children} + + + ) +} diff --git a/src/components/dialogs/Context.tsx b/src/components/dialogs/Context.tsx new file mode 100644 index 00000000..d86c90a9 --- /dev/null +++ b/src/components/dialogs/Context.tsx @@ -0,0 +1,29 @@ +import React from 'react' + +import * as Dialog from '#/components/Dialog' + +type Control = Dialog.DialogOuterProps['control'] + +type ControlsContext = { + mutedWordsDialogControl: Control +} + +const ControlsContext = React.createContext({ + mutedWordsDialogControl: {} as Control, +}) + +export function useGlobalDialogsControlContext() { + return React.useContext(ControlsContext) +} + +export function Provider({children}: React.PropsWithChildren<{}>) { + const mutedWordsDialogControl = Dialog.useDialogControl() + const ctx = React.useMemo( + () => ({mutedWordsDialogControl}), + [mutedWordsDialogControl], + ) + + return ( + {children} + ) +} diff --git a/src/components/dialogs/MutedWords.tsx b/src/components/dialogs/MutedWords.tsx new file mode 100644 index 00000000..138cc533 --- /dev/null +++ b/src/components/dialogs/MutedWords.tsx @@ -0,0 +1,328 @@ +import React from 'react' +import {View} from 'react-native' +import {msg, Trans} from '@lingui/macro' +import {useLingui} from '@lingui/react' +import {AppBskyActorDefs} from '@atproto/api' + +import { + usePreferencesQuery, + useUpsertMutedWordsMutation, + useRemoveMutedWordMutation, +} from '#/state/queries/preferences' +import {isNative} from '#/platform/detection' +import {atoms as a, useTheme, useBreakpoints, ViewStyleProp} from '#/alf' +import {Text} from '#/components/Typography' +import {Button, ButtonIcon, ButtonText} from '#/components/Button' +import {PlusLarge_Stroke2_Corner0_Rounded as Plus} from '#/components/icons/Plus' +import {TimesLarge_Stroke2_Corner0_Rounded as X} from '#/components/icons/Times' +import {Hashtag_Stroke2_Corner0_Rounded as Hashtag} from '#/components/icons/Hashtag' +import {PageText_Stroke2_Corner0_Rounded as PageText} from '#/components/icons/PageText' +import {Divider} from '#/components/Divider' +import {Loader} from '#/components/Loader' +import {logger} from '#/logger' +import * as Dialog from '#/components/Dialog' +import * as Toggle from '#/components/forms/Toggle' +import * as Prompt from '#/components/Prompt' + +import {useGlobalDialogsControlContext} from '#/components/dialogs/Context' + +export function MutedWordsDialog() { + const {mutedWordsDialogControl: control} = useGlobalDialogsControlContext() + return ( + + + + + ) +} + +function MutedWordsInner({}: {control: Dialog.DialogOuterProps['control']}) { + const t = useTheme() + const {_} = useLingui() + const {gtMobile} = useBreakpoints() + const { + isLoading: isPreferencesLoading, + data: preferences, + error: preferencesError, + } = usePreferencesQuery() + const {isPending, mutateAsync: addMutedWord} = useUpsertMutedWordsMutation() + const [field, setField] = React.useState('') + const [options, setOptions] = React.useState(['content']) + const [_error, setError] = React.useState('') + + const submit = React.useCallback(async () => { + const value = field.trim() + const targets = ['tag', options.includes('content') && 'content'].filter( + Boolean, + ) as AppBskyActorDefs.MutedWord['targets'] + + if (!value || !targets.length) return + + try { + await addMutedWord([{value, targets}]) + setField('') + } catch (e: any) { + logger.error(`Failed to save muted word`, {message: e.message}) + setError(e.message) + } + }, [field, options, addMutedWord, setField]) + + return ( + + + Add muted words and tags + + + + Posts can be muted based on their text, their tags, or both. + + + + + + + + + + + + + + Mute in text & tags + + + + + + + + + + + + Mute in tags only + + + + + + + + + + + + + We recommend avoiding common words that appear in many posts, since + it can result in no posts being shown. + + + + + + + + + Your muted words + + + {isPreferencesLoading ? ( + + ) : preferencesError || !preferences ? ( + + + + We're sorry, but we weren't able to load your muted words at + this time. Please try again. + + + + ) : preferences.mutedWords.length ? ( + [...preferences.mutedWords] + .reverse() + .map((word, i) => ( + + )) + ) : ( + + + You haven't muted any words or tags yet + + + )} + + + {isNative && } + + + + ) +} + +function MutedWordRow({ + style, + word, +}: ViewStyleProp & {word: AppBskyActorDefs.MutedWord}) { + const t = useTheme() + const {_} = useLingui() + const {isPending, mutateAsync: removeMutedWord} = useRemoveMutedWordMutation() + const control = Prompt.usePromptControl() + + const remove = React.useCallback(async () => { + control.close() + removeMutedWord(word) + }, [removeMutedWord, word, control]) + + return ( + <> + + + Are you sure? + + + + This will delete {word.value} from your muted words. You can always + add it back later. + + + + + + Nevermind + + + + + Remove + + + + + + + + {word.value} + + + + {word.targets.map(target => ( + + + {target === 'content' ? _(msg`text`) : _(msg`tag`)} + + + ))} + + + + + + ) +} + +function TargetToggle({children}: React.PropsWithChildren<{}>) { + const t = useTheme() + const ctx = Toggle.useItemContext() + const {gtMobile} = useBreakpoints() + return ( + + {children} + + ) +} diff --git a/src/components/forms/TextField.tsx b/src/components/forms/TextField.tsx index ebf2e475..a781bdd1 100644 --- a/src/components/forms/TextField.tsx +++ b/src/components/forms/TextField.tsx @@ -72,7 +72,7 @@ export function Root({children, isInvalid = false}: RootProps) { return ( inputRef.current?.focus(), onMouseOver: onHoverIn, diff --git a/src/components/forms/Toggle.tsx b/src/components/forms/Toggle.tsx index 9369423f..140740f7 100644 --- a/src/components/forms/Toggle.tsx +++ b/src/components/forms/Toggle.tsx @@ -5,6 +5,7 @@ import {HITSLOP_10} from 'lib/constants' import {useTheme, atoms as a, web, native, flatten, ViewStyleProp} from '#/alf' import {Text} from '#/components/Typography' import {useInteractionState} from '#/components/hooks/useInteractionState' +import {CheckThick_Stroke2_Corner0_Rounded as Checkmark} from '#/components/icons/Check' export type ItemState = { name: string @@ -331,15 +332,14 @@ export function createSharedToggleStyles({ export function Checkbox() { const t = useTheme() const {selected, hovered, focused, disabled, isInvalid} = useItemContext() - const {baseStyles, baseHoverStyles, indicatorStyles} = - createSharedToggleStyles({ - theme: t, - hovered, - focused, - selected, - disabled, - isInvalid, - }) + const {baseStyles, baseHoverStyles} = createSharedToggleStyles({ + theme: t, + hovered, + focused, + selected, + disabled, + isInvalid, + }) return ( - {selected ? ( - - ) : null} + {selected ? : null} ) } diff --git a/src/components/icons/Check.tsx b/src/components/icons/Check.tsx index 24316c78..fe9883ba 100644 --- a/src/components/icons/Check.tsx +++ b/src/components/icons/Check.tsx @@ -3,3 +3,7 @@ import {createSinglePathSVG} from './TEMPLATE' export const Check_Stroke2_Corner0_Rounded = createSinglePathSVG({ path: 'M21.59 3.193a1 1 0 0 1 .217 1.397l-11.706 16a1 1 0 0 1-1.429.193l-6.294-5a1 1 0 1 1 1.244-1.566l5.48 4.353 11.09-15.16a1 1 0 0 1 1.398-.217Z', }) + +export const CheckThick_Stroke2_Corner0_Rounded = createSinglePathSVG({ + path: 'M21.474 2.98a2.5 2.5 0 0 1 .545 3.494l-10.222 14a2.5 2.5 0 0 1-3.528.52L2.49 16.617a2.5 2.5 0 0 1 3.018-3.986l3.75 2.84L17.98 3.525a2.5 2.5 0 0 1 3.493-.545Z', +}) diff --git a/src/components/icons/Clipboard.tsx b/src/components/icons/Clipboard.tsx new file mode 100644 index 00000000..0135992b --- /dev/null +++ b/src/components/icons/Clipboard.tsx @@ -0,0 +1,5 @@ +import {createSinglePathSVG} from './TEMPLATE' + +export const Clipboard_Stroke2_Corner2_Rounded = createSinglePathSVG({ + path: 'M8.17 4A3.001 3.001 0 0 1 11 2h2c1.306 0 2.418.835 2.83 2H17a3 3 0 0 1 3 3v12a3 3 0 0 1-3 3H7a3 3 0 0 1-3-3V7a3 3 0 0 1 3-3h1.17ZM8 6H7a1 1 0 0 0-1 1v12a1 1 0 0 0 1 1h10a1 1 0 0 0 1-1V7a1 1 0 0 0-1-1h-1v1a1 1 0 0 1-1 1H9a1 1 0 0 1-1-1V6Zm6 0V5a1 1 0 0 0-1-1h-2a1 1 0 0 0-1 1v1h4Z', +}) diff --git a/src/components/icons/Group3.tsx b/src/components/icons/Group3.tsx index 2bb16ba8..9e5ab889 100644 --- a/src/components/icons/Group3.tsx +++ b/src/components/icons/Group3.tsx @@ -1,5 +1,5 @@ import {createSinglePathSVG} from './TEMPLATE' export const Group3_Stroke2_Corner0_Rounded = createSinglePathSVG({ - path: 'M17 16H21.1456C20.8246 11.4468 17.7199 9.48509 15.0001 10.1147M10 4C10 5.65685 8.65685 7 7 7C5.34315 7 4 5.65685 4 4C4 2.34315 5.34315 1 7 1C8.65685 1 10 2.34315 10 4ZM18.5 4.5C18.5 5.88071 17.3807 7 16 7C14.6193 7 13.5 5.88071 13.5 4.5C13.5 3.11929 14.6193 2 16 2C17.3807 2 18.5 3.11929 18.5 4.5ZM1 17H13C12.3421 7.66667 1.65792 7.66667 1 17Z', + path: 'M8 5a2 2 0 1 0 0 4 2 2 0 0 0 0-4ZM4 7a4 4 0 1 1 8 0 4 4 0 0 1-8 0Zm13-1a1.5 1.5 0 1 0 0 3 1.5 1.5 0 0 0 0-3Zm-3.5 1.5a3.5 3.5 0 1 1 7 0 3.5 3.5 0 0 1-7 0Zm5.826 7.376c-.919-.779-2.052-1.03-3.1-.787a1 1 0 0 1-.451-1.949c1.671-.386 3.45.028 4.844 1.211 1.397 1.185 2.348 3.084 2.524 5.579a1 1 0 0 1-.997 1.07H18a1 1 0 1 1 0-2h3.007c-.29-1.47-.935-2.49-1.681-3.124ZM3.126 19h9.747c-.61-3.495-2.867-5-4.873-5-2.006 0-4.263 1.505-4.873 5ZM8 12c3.47 0 6.64 2.857 6.998 7.93A1 1 0 0 1 14 21H2a1 1 0 0 1-.998-1.07C1.36 14.857 4.53 12 8 12Z', }) diff --git a/src/components/icons/MagnifyingGlass2.tsx b/src/components/icons/MagnifyingGlass2.tsx new file mode 100644 index 00000000..3ca40340 --- /dev/null +++ b/src/components/icons/MagnifyingGlass2.tsx @@ -0,0 +1,5 @@ +import {createSinglePathSVG} from './TEMPLATE' + +export const MagnifyingGlass2_Stroke2_Corner0_Rounded = createSinglePathSVG({ + path: 'M11 5a6 6 0 1 0 0 12 6 6 0 0 0 0-12Zm-8 6a8 8 0 1 1 14.32 4.906l3.387 3.387a1 1 0 0 1-1.414 1.414l-3.387-3.387A8 8 0 0 1 3 11Z', +}) diff --git a/src/components/icons/Mute.tsx b/src/components/icons/Mute.tsx new file mode 100644 index 00000000..00657078 --- /dev/null +++ b/src/components/icons/Mute.tsx @@ -0,0 +1,5 @@ +import {createSinglePathSVG} from './TEMPLATE' + +export const Mute_Stroke2_Corner0_Rounded = createSinglePathSVG({ + path: 'M20.707 3.293a1 1 0 0 1 0 1.414l-16 16a1 1 0 0 1-1.414-1.414l2.616-2.616A1.998 1.998 0 0 1 5 15V9a2 2 0 0 1 2-2h2.697l5.748-3.832A1 1 0 0 1 17 4v1.586l2.293-2.293a1 1 0 0 1 1.414 0ZM15 7.586 7.586 15H7V9h2.697a2 2 0 0 0 1.11-.336L15 5.87v1.717Zm2 3.657-2 2v4.888l-2.933-1.955-1.442 1.442 4.82 3.214A1 1 0 0 0 17 20v-8.757Z', +}) diff --git a/src/components/icons/PageText.tsx b/src/components/icons/PageText.tsx new file mode 100644 index 00000000..25fbde33 --- /dev/null +++ b/src/components/icons/PageText.tsx @@ -0,0 +1,5 @@ +import {createSinglePathSVG} from './TEMPLATE' + +export const PageText_Stroke2_Corner0_Rounded = createSinglePathSVG({ + path: 'M5 2a1 1 0 0 0-1 1v18a1 1 0 0 0 1 1h14a1 1 0 0 0 1-1V3a1 1 0 0 0-1-1H5Zm1 18V4h12v16H6Zm3-6a1 1 0 1 0 0 2h2a1 1 0 1 0 0-2H9Zm-1-3a1 1 0 0 1 1-1h6a1 1 0 1 1 0 2H9a1 1 0 0 1-1-1Zm1-5a1 1 0 0 0 0 2h6a1 1 0 1 0 0-2H9Z', +}) diff --git a/src/components/icons/Person.tsx b/src/components/icons/Person.tsx new file mode 100644 index 00000000..6d09148c --- /dev/null +++ b/src/components/icons/Person.tsx @@ -0,0 +1,5 @@ +import {createSinglePathSVG} from './TEMPLATE' + +export const Person_Stroke2_Corner0_Rounded = createSinglePathSVG({ + path: 'M12 4a2.5 2.5 0 1 0 0 5 2.5 2.5 0 0 0 0-5ZM7.5 6.5a4.5 4.5 0 1 1 9 0 4.5 4.5 0 0 1-9 0ZM5.678 19h12.644c-.71-2.909-3.092-5-6.322-5s-5.613 2.091-6.322 5Zm-2.174.906C3.917 15.521 7.242 12 12 12c4.758 0 8.083 3.521 8.496 7.906A1 1 0 0 1 19.5 21h-15a1 1 0 0 1-.996-1.094Z', +}) diff --git a/src/lib/__tests__/moderatePost_wrapped.test.ts b/src/lib/__tests__/moderatePost_wrapped.test.ts new file mode 100644 index 00000000..1d907963 --- /dev/null +++ b/src/lib/__tests__/moderatePost_wrapped.test.ts @@ -0,0 +1,578 @@ +import {describe, it, expect} from '@jest/globals' +import {RichText} from '@atproto/api' + +import {hasMutedWord} from '../moderatePost_wrapped' + +describe(`hasMutedWord`, () => { + describe(`tags`, () => { + it(`match: outline tag`, () => { + const rt = new RichText({ + text: `This is a post #inlineTag`, + }) + rt.detectFacetsWithoutResolution() + + const match = hasMutedWord( + [{value: 'outlineTag', targets: ['tag']}], + rt.text, + rt.facets, + ['outlineTag'], + ) + + expect(match).toBe(true) + }) + + it(`match: inline tag`, () => { + const rt = new RichText({ + text: `This is a post #inlineTag`, + }) + rt.detectFacetsWithoutResolution() + + const match = hasMutedWord( + [{value: 'inlineTag', targets: ['tag']}], + rt.text, + rt.facets, + ['outlineTag'], + ) + + expect(match).toBe(true) + }) + + it(`match: content target matches inline tag`, () => { + const rt = new RichText({ + text: `This is a post #inlineTag`, + }) + rt.detectFacetsWithoutResolution() + + const match = hasMutedWord( + [{value: 'inlineTag', targets: ['content']}], + rt.text, + rt.facets, + ['outlineTag'], + ) + + expect(match).toBe(true) + }) + + it(`no match: only tag targets`, () => { + const rt = new RichText({ + text: `This is a post`, + }) + rt.detectFacetsWithoutResolution() + + const match = hasMutedWord( + [{value: 'inlineTag', targets: ['tag']}], + rt.text, + rt.facets, + [], + ) + + expect(match).toBe(false) + }) + }) + + describe(`early exits`, () => { + it(`match: single character 希`, () => { + /** + * @see https://bsky.app/profile/mukuuji.bsky.social/post/3klji4fvsdk2c + */ + const rt = new RichText({ + text: `改善希望です`, + }) + rt.detectFacetsWithoutResolution() + + const match = hasMutedWord( + [{value: '希', targets: ['content']}], + rt.text, + rt.facets, + [], + ) + + expect(match).toBe(true) + }) + + it(`no match: long muted word, short post`, () => { + const rt = new RichText({ + text: `hey`, + }) + rt.detectFacetsWithoutResolution() + + const match = hasMutedWord( + [{value: 'politics', targets: ['content']}], + rt.text, + rt.facets, + [], + ) + + expect(match).toBe(false) + }) + + it(`match: exact text`, () => { + const rt = new RichText({ + text: `javascript`, + }) + rt.detectFacetsWithoutResolution() + + const match = hasMutedWord( + [{value: 'javascript', targets: ['content']}], + rt.text, + rt.facets, + [], + ) + + expect(match).toBe(true) + }) + }) + + describe(`general content`, () => { + it(`match: word within post`, () => { + const rt = new RichText({ + text: `This is a post about javascript`, + }) + rt.detectFacetsWithoutResolution() + + const match = hasMutedWord( + [{value: 'javascript', targets: ['content']}], + rt.text, + rt.facets, + [], + ) + + expect(match).toBe(true) + }) + + it(`no match: partial word`, () => { + const rt = new RichText({ + text: `Use your brain, Eric`, + }) + rt.detectFacetsWithoutResolution() + + const match = hasMutedWord( + [{value: 'ai', targets: ['content']}], + rt.text, + rt.facets, + [], + ) + + expect(match).toBe(false) + }) + + it(`match: multiline`, () => { + const rt = new RichText({ + text: `Use your\n\tbrain, Eric`, + }) + rt.detectFacetsWithoutResolution() + + const match = hasMutedWord( + [{value: 'brain', targets: ['content']}], + rt.text, + rt.facets, + [], + ) + + expect(match).toBe(true) + }) + + it(`match: :)`, () => { + const rt = new RichText({ + text: `So happy :)`, + }) + rt.detectFacetsWithoutResolution() + + const match = hasMutedWord( + [{value: `:)`, targets: ['content']}], + rt.text, + rt.facets, + [], + ) + + expect(match).toBe(true) + }) + }) + + describe(`punctuation semi-fuzzy`, () => { + describe(`yay!`, () => { + const rt = new RichText({ + text: `We're federating, yay!`, + }) + rt.detectFacetsWithoutResolution() + + it(`match: yay!`, () => { + const match = hasMutedWord( + [{value: 'yay!', targets: ['content']}], + rt.text, + rt.facets, + [], + ) + + expect(match).toBe(true) + }) + + it(`match: yay`, () => { + const match = hasMutedWord( + [{value: 'yay', targets: ['content']}], + rt.text, + rt.facets, + [], + ) + + expect(match).toBe(true) + }) + }) + + describe(`y!ppee!!`, () => { + const rt = new RichText({ + text: `We're federating, y!ppee!!`, + }) + rt.detectFacetsWithoutResolution() + + it(`match: y!ppee`, () => { + const match = hasMutedWord( + [{value: 'y!ppee', targets: ['content']}], + rt.text, + rt.facets, + [], + ) + + expect(match).toBe(true) + }) + + // single exclamation point, source has double + it(`no match: y!ppee!`, () => { + const match = hasMutedWord( + [{value: 'y!ppee!', targets: ['content']}], + rt.text, + rt.facets, + [], + ) + + expect(match).toBe(true) + }) + }) + + describe(`Why so S@assy?`, () => { + const rt = new RichText({ + text: `Why so S@assy?`, + }) + rt.detectFacetsWithoutResolution() + + it(`match: S@assy`, () => { + const match = hasMutedWord( + [{value: 'S@assy', targets: ['content']}], + rt.text, + rt.facets, + [], + ) + + expect(match).toBe(true) + }) + + it(`match: s@assy`, () => { + const match = hasMutedWord( + [{value: 's@assy', targets: ['content']}], + rt.text, + rt.facets, + [], + ) + + expect(match).toBe(true) + }) + }) + + describe(`New York Times`, () => { + const rt = new RichText({ + text: `New York Times`, + }) + rt.detectFacetsWithoutResolution() + + // case insensitive + it(`match: new york times`, () => { + const match = hasMutedWord( + [{value: 'new york times', targets: ['content']}], + rt.text, + rt.facets, + [], + ) + + expect(match).toBe(true) + }) + }) + + describe(`!command`, () => { + const rt = new RichText({ + text: `Idk maybe a bot !command`, + }) + rt.detectFacetsWithoutResolution() + + it(`match: !command`, () => { + const match = hasMutedWord( + [{value: `!command`, targets: ['content']}], + rt.text, + rt.facets, + [], + ) + + expect(match).toBe(true) + }) + + it(`match: command`, () => { + const match = hasMutedWord( + [{value: `command`, targets: ['content']}], + rt.text, + rt.facets, + [], + ) + + expect(match).toBe(true) + }) + + it(`no match: !command`, () => { + const rt = new RichText({ + text: `Idk maybe a bot command`, + }) + rt.detectFacetsWithoutResolution() + + const match = hasMutedWord( + [{value: `!command`, targets: ['content']}], + rt.text, + rt.facets, + [], + ) + + expect(match).toBe(false) + }) + }) + + describe(`e/acc`, () => { + const rt = new RichText({ + text: `I'm e/acc pilled`, + }) + rt.detectFacetsWithoutResolution() + + it(`match: e/acc`, () => { + const match = hasMutedWord( + [{value: `e/acc`, targets: ['content']}], + rt.text, + rt.facets, + [], + ) + + expect(match).toBe(true) + }) + + it(`match: acc`, () => { + const match = hasMutedWord( + [{value: `acc`, targets: ['content']}], + rt.text, + rt.facets, + [], + ) + + expect(match).toBe(true) + }) + }) + + describe(`super-bad`, () => { + const rt = new RichText({ + text: `I'm super-bad`, + }) + rt.detectFacetsWithoutResolution() + + it(`match: super-bad`, () => { + const match = hasMutedWord( + [{value: `super-bad`, targets: ['content']}], + rt.text, + rt.facets, + [], + ) + + expect(match).toBe(true) + }) + + it(`match: super`, () => { + const match = hasMutedWord( + [{value: `super`, targets: ['content']}], + rt.text, + rt.facets, + [], + ) + + expect(match).toBe(true) + }) + + it(`match: super bad`, () => { + const match = hasMutedWord( + [{value: `super bad`, targets: ['content']}], + rt.text, + rt.facets, + [], + ) + + expect(match).toBe(true) + }) + + it(`match: superbad`, () => { + const match = hasMutedWord( + [{value: `superbad`, targets: ['content']}], + rt.text, + rt.facets, + [], + ) + + expect(match).toBe(false) + }) + }) + + describe(`idk_what_this_would_be`, () => { + const rt = new RichText({ + text: `Weird post with idk_what_this_would_be`, + }) + rt.detectFacetsWithoutResolution() + + it(`match: idk what this would be`, () => { + const match = hasMutedWord( + [{value: `idk what this would be`, targets: ['content']}], + rt.text, + rt.facets, + [], + ) + + expect(match).toBe(true) + }) + + it(`no match: idk what this would be for`, () => { + // extra word + const match = hasMutedWord( + [{value: `idk what this would be for`, targets: ['content']}], + rt.text, + rt.facets, + [], + ) + + expect(match).toBe(false) + }) + + it(`match: idk`, () => { + // extra word + const match = hasMutedWord( + [{value: `idk`, targets: ['content']}], + rt.text, + rt.facets, + [], + ) + + expect(match).toBe(true) + }) + + it(`match: idkwhatthiswouldbe`, () => { + const match = hasMutedWord( + [{value: `idkwhatthiswouldbe`, targets: ['content']}], + rt.text, + rt.facets, + [], + ) + + expect(match).toBe(false) + }) + }) + + describe(`parentheses`, () => { + const rt = new RichText({ + text: `Post with context(iykyk)`, + }) + rt.detectFacetsWithoutResolution() + + it(`match: context(iykyk)`, () => { + const match = hasMutedWord( + [{value: `context(iykyk)`, targets: ['content']}], + rt.text, + rt.facets, + [], + ) + + expect(match).toBe(true) + }) + + it(`match: context`, () => { + const match = hasMutedWord( + [{value: `context`, targets: ['content']}], + rt.text, + rt.facets, + [], + ) + + expect(match).toBe(true) + }) + + it(`match: iykyk`, () => { + const match = hasMutedWord( + [{value: `iykyk`, targets: ['content']}], + rt.text, + rt.facets, + [], + ) + + expect(match).toBe(true) + }) + + it(`match: (iykyk)`, () => { + const match = hasMutedWord( + [{value: `(iykyk)`, targets: ['content']}], + rt.text, + rt.facets, + [], + ) + + expect(match).toBe(true) + }) + }) + + describe(`🦋`, () => { + const rt = new RichText({ + text: `Post with 🦋`, + }) + rt.detectFacetsWithoutResolution() + + it(`match: 🦋`, () => { + const match = hasMutedWord( + [{value: `🦋`, targets: ['content']}], + rt.text, + rt.facets, + [], + ) + + expect(match).toBe(true) + }) + }) + }) + + describe(`phrases`, () => { + describe(`I like turtles, or how I learned to stop worrying and love the internet.`, () => { + const rt = new RichText({ + text: `I like turtles, or how I learned to stop worrying and love the internet.`, + }) + rt.detectFacetsWithoutResolution() + + it(`match: stop worrying`, () => { + const match = hasMutedWord( + [{value: 'stop worrying', targets: ['content']}], + rt.text, + rt.facets, + [], + ) + + expect(match).toBe(true) + }) + + it(`match: turtles, or how`, () => { + const match = hasMutedWord( + [{value: 'turtles, or how', targets: ['content']}], + rt.text, + rt.facets, + [], + ) + + expect(match).toBe(true) + }) + }) + }) +}) diff --git a/src/lib/moderatePost_wrapped.ts b/src/lib/moderatePost_wrapped.ts index 2195b230..862f2de6 100644 --- a/src/lib/moderatePost_wrapped.ts +++ b/src/lib/moderatePost_wrapped.ts @@ -2,18 +2,122 @@ import { AppBskyEmbedRecord, AppBskyEmbedRecordWithMedia, moderatePost, + AppBskyActorDefs, + AppBskyFeedPost, + AppBskyRichtextFacet, + AppBskyEmbedImages, } from '@atproto/api' type ModeratePost = typeof moderatePost type Options = Parameters[1] & { hiddenPosts?: string[] + mutedWords?: AppBskyActorDefs.MutedWord[] +} + +const REGEX = { + LEADING_TRAILING_PUNCTUATION: /(?:^\p{P}+|\p{P}+$)/gu, + ESCAPE: /[[\]{}()*+?.\\^$|\s]/g, + SEPARATORS: /[\/\-\–\—\(\)\[\]\_]+/g, + WORD_BOUNDARY: /[\s\n\t\r\f\v]+?/g, +} + +export function hasMutedWord( + mutedWords: AppBskyActorDefs.MutedWord[], + text: string, + facets?: AppBskyRichtextFacet.Main[], + outlineTags?: string[], +) { + const tags = ([] as string[]) + .concat(outlineTags || []) + .concat( + facets + ?.filter(facet => { + return facet.features.find(feature => + AppBskyRichtextFacet.isTag(feature), + ) + }) + .map(t => t.features[0].tag as string) || [], + ) + .map(t => t.toLowerCase()) + + for (const mute of mutedWords) { + const mutedWord = mute.value.toLowerCase() + const postText = text.toLowerCase() + + // `content` applies to tags as well + if (tags.includes(mutedWord)) return true + // rest of the checks are for `content` only + if (!mute.targets.includes('content')) continue + // single character, has to use includes + if (mutedWord.length === 1 && postText.includes(mutedWord)) return true + // too long + if (mutedWord.length > postText.length) continue + // exact match + if (mutedWord === postText) return true + // any muted phrase with space or punctuation + if (/(?:\s|\p{P})+?/u.test(mutedWord) && postText.includes(mutedWord)) + return true + + // check individual character groups + const words = postText.split(REGEX.WORD_BOUNDARY) + for (const word of words) { + if (word === mutedWord) return true + + // compare word without leading/trailing punctuation, but allow internal + // punctuation (such as `s@ssy`) + const wordTrimmedPunctuation = word.replace( + REGEX.LEADING_TRAILING_PUNCTUATION, + '', + ) + + if (mutedWord === wordTrimmedPunctuation) return true + if (mutedWord.length > wordTrimmedPunctuation.length) continue + + // handle hyphenated, slash separated words, etc + if (REGEX.SEPARATORS.test(wordTrimmedPunctuation)) { + // check against full normalized phrase + const wordNormalizedSeparators = wordTrimmedPunctuation.replace( + REGEX.SEPARATORS, + ' ', + ) + const mutedWordNormalizedSeparators = mutedWord.replace( + REGEX.SEPARATORS, + ' ', + ) + // hyphenated (or other sep) to spaced words + if (wordNormalizedSeparators === mutedWordNormalizedSeparators) + return true + + /* Disabled for now e.g. `super-cool` to `supercool` + const wordNormalizedCompressed = wordNormalizedSeparators.replace( + REGEX.WORD_BOUNDARY, + '', + ) + const mutedWordNormalizedCompressed = + mutedWordNormalizedSeparators.replace(/\s+?/g, '') + // hyphenated (or other sep) to non-hyphenated contiguous word + if (mutedWordNormalizedCompressed === wordNormalizedCompressed) + return true + */ + + // then individual parts of separated phrases/words + const wordParts = wordTrimmedPunctuation.split(REGEX.SEPARATORS) + for (const wp of wordParts) { + // still retain internal punctuation + if (wp === mutedWord) return true + } + } + } + } + + return false } export function moderatePost_wrapped( subject: Parameters[0], opts: Options, ) { - const {hiddenPosts = [], ...options} = opts + const {hiddenPosts = [], mutedWords = [], ...options} = opts const moderations = moderatePost(subject, options) if (hiddenPosts.includes(subject.uri)) { @@ -29,15 +133,65 @@ export function moderatePost_wrapped( } } + if (AppBskyFeedPost.isRecord(subject.record)) { + let muted = hasMutedWord( + mutedWords, + subject.record.text, + subject.record.facets || [], + subject.record.tags || [], + ) + + if ( + subject.record.embed && + AppBskyEmbedImages.isMain(subject.record.embed) + ) { + for (const image of subject.record.embed.images) { + muted = muted || hasMutedWord(mutedWords, image.alt, [], []) + } + } + + if (muted) { + moderations.content.filter = true + moderations.content.blur = true + if (!moderations.content.cause) { + moderations.content.cause = { + // @ts-ignore Temporary extension to the moderation system -prf + type: 'muted-word', + source: {type: 'user'}, + priority: 1, + } + } + } + } + if (subject.embed) { let embedHidden = false if (AppBskyEmbedRecord.isViewRecord(subject.embed.record)) { embedHidden = hiddenPosts.includes(subject.embed.record.uri) + + if (AppBskyFeedPost.isRecord(subject.embed.record.value)) { + embedHidden = + embedHidden || + hasMutedWord( + mutedWords, + subject.embed.record.value.text, + subject.embed.record.value.facets, + subject.embed.record.value.tags, + ) + + if (AppBskyEmbedImages.isMain(subject.embed.record.value.embed)) { + for (const image of subject.embed.record.value.embed.images) { + embedHidden = + embedHidden || hasMutedWord(mutedWords, image.alt, [], []) + } + } + } } if ( AppBskyEmbedRecordWithMedia.isView(subject.embed) && AppBskyEmbedRecord.isViewRecord(subject.embed.record.record) ) { + // TODO what embedHidden = hiddenPosts.includes(subject.embed.record.record.uri) } if (embedHidden) { diff --git a/src/lib/moderation.ts b/src/lib/moderation.ts index bf19c208..b6ebb47a 100644 --- a/src/lib/moderation.ts +++ b/src/lib/moderation.ts @@ -67,6 +67,13 @@ export function describeModerationCause( description: 'You have hidden this post', } } + // @ts-ignore Temporary extension to the moderation system -prf + if (cause.type === 'muted-word') { + return { + name: 'Post hidden by muted word', + description: `You've chosen to hide a word or tag within this post.`, + } + } return cause.labelDef.strings[context].en } diff --git a/src/lib/routes/links.ts b/src/lib/routes/links.ts index 538f30cd..9dfdab90 100644 --- a/src/lib/routes/links.ts +++ b/src/lib/routes/links.ts @@ -25,3 +25,13 @@ export function makeCustomFeedLink( export function makeListLink(did: string, rkey: string, ...segments: string[]) { return [`/profile`, did, 'lists', rkey, ...segments].join('/') } + +export function makeTagLink(did: string) { + return `/search?q=${encodeURIComponent(did)}` +} + +export function makeSearchLink(props: {query: string; from?: 'me' | string}) { + return `/search?q=${encodeURIComponent( + props.query + (props.from ? ` from:${props.from}` : ''), + )}` +} diff --git a/src/lib/routes/types.ts b/src/lib/routes/types.ts index 0fb36fa7..0ec09f61 100644 --- a/src/lib/routes/types.ts +++ b/src/lib/routes/types.ts @@ -33,6 +33,7 @@ export type CommonNavigatorParams = { PreferencesFollowingFeed: undefined PreferencesThreads: undefined PreferencesExternalEmbeds: undefined + Search: {q?: string} } export type BottomTabNavigatorParams = CommonNavigatorParams & { diff --git a/src/state/dialogs/index.tsx b/src/state/dialogs/index.tsx index 4cafaa08..ae762bd9 100644 --- a/src/state/dialogs/index.tsx +++ b/src/state/dialogs/index.tsx @@ -1,5 +1,6 @@ import React from 'react' import {DialogControlProps} from '#/components/Dialog' +import {Provider as GlobalDialogsProvider} from '#/components/dialogs/Context' const DialogContext = React.createContext<{ activeDialogs: React.MutableRefObject< @@ -37,7 +38,7 @@ export function Provider({children}: React.PropsWithChildren<{}>) { return ( - {children} + {children} ) diff --git a/src/state/queries/preferences/const.ts b/src/state/queries/preferences/const.ts index 2d9d0299..25d28499 100644 --- a/src/state/queries/preferences/const.ts +++ b/src/state/queries/preferences/const.ts @@ -49,4 +49,6 @@ export const DEFAULT_LOGGED_OUT_PREFERENCES: UsePreferencesQueryResponse = { threadViewPrefs: DEFAULT_THREAD_VIEW_PREFS, userAge: 13, // TODO(pwi) interests: {tags: []}, + mutedWords: [], + hiddenPosts: [], } diff --git a/src/state/queries/preferences/index.ts b/src/state/queries/preferences/index.ts index 632d31a1..07198de7 100644 --- a/src/state/queries/preferences/index.ts +++ b/src/state/queries/preferences/index.ts @@ -1,6 +1,10 @@ import {useMemo} from 'react' import {useQuery, useMutation, useQueryClient} from '@tanstack/react-query' -import {LabelPreference, BskyFeedViewPreference} from '@atproto/api' +import { + LabelPreference, + BskyFeedViewPreference, + AppBskyActorDefs, +} from '@atproto/api' import {track} from '#/lib/analytics/analytics' import {getAge} from '#/lib/strings/time' @@ -108,6 +112,7 @@ export function useModerationOpts() { return { ...moderationOpts, hiddenPosts, + mutedWords: prefs.data.mutedWords || [], } }, [currentAccount?.did, prefs.data, hiddenPosts]) return opts @@ -278,3 +283,45 @@ export function useUnpinFeedMutation() { }, }) } + +export function useUpsertMutedWordsMutation() { + const queryClient = useQueryClient() + + return useMutation({ + mutationFn: async (mutedWords: AppBskyActorDefs.MutedWord[]) => { + await getAgent().upsertMutedWords(mutedWords) + // triggers a refetch + await queryClient.invalidateQueries({ + queryKey: preferencesQueryKey, + }) + }, + }) +} + +export function useUpdateMutedWordMutation() { + const queryClient = useQueryClient() + + return useMutation({ + mutationFn: async (mutedWord: AppBskyActorDefs.MutedWord) => { + await getAgent().updateMutedWord(mutedWord) + // triggers a refetch + await queryClient.invalidateQueries({ + queryKey: preferencesQueryKey, + }) + }, + }) +} + +export function useRemoveMutedWordMutation() { + const queryClient = useQueryClient() + + return useMutation({ + mutationFn: async (mutedWord: AppBskyActorDefs.MutedWord) => { + await getAgent().removeMutedWord(mutedWord) + // triggers a refetch + await queryClient.invalidateQueries({ + queryKey: preferencesQueryKey, + }) + }, + }) +} diff --git a/src/view/com/composer/text-input/TextInput.tsx b/src/view/com/composer/text-input/TextInput.tsx index 17f9513b..20be585c 100644 --- a/src/view/com/composer/text-input/TextInput.tsx +++ b/src/view/com/composer/text-input/TextInput.tsx @@ -190,12 +190,11 @@ export const TextInput = forwardRef(function TextInputImpl( let i = 0 return Array.from(richtext.segments()).map(segment => { - const isTag = AppBskyRichtextFacet.isTag(segment.facet?.features?.[0]) return ( {segment.text} diff --git a/src/view/com/composer/text-input/TextInput.web.tsx b/src/view/com/composer/text-input/TextInput.web.tsx index 199f1f74..c62d1120 100644 --- a/src/view/com/composer/text-input/TextInput.web.tsx +++ b/src/view/com/composer/text-input/TextInput.web.tsx @@ -23,6 +23,7 @@ import {Portal} from '#/components/Portal' import {Text} from '../../util/text/Text' import {Trans} from '@lingui/macro' import Animated, {FadeIn, FadeOut} from 'react-native-reanimated' +import {TagDecorator} from './web/TagDecorator' export interface TextInputRef { focus: () => void @@ -67,6 +68,7 @@ export const TextInput = React.forwardRef(function TextInputImpl( () => [ Document, LinkDecorator, + TagDecorator, Mention.configure({ HTMLAttributes: { class: 'mention', diff --git a/src/view/com/composer/text-input/web/TagDecorator.ts b/src/view/com/composer/text-input/web/TagDecorator.ts new file mode 100644 index 00000000..d820ec3f --- /dev/null +++ b/src/view/com/composer/text-input/web/TagDecorator.ts @@ -0,0 +1,83 @@ +/** + * TipTap is a stateful rich-text editor, which is extremely useful + * when you _want_ it to be stateful formatting such as bold and italics. + * + * However we also use "stateless" behaviors, specifically for URLs + * where the text itself drives the formatting. + * + * This plugin uses a regex to detect URIs and then applies + * link decorations (a with the "autolink") class. That avoids + * adding any stateful formatting to TipTap's document model. + * + * We then run the URI detection again when constructing the + * RichText object from TipTap's output and merge their features into + * the facet-set. + */ + +import {Mark} from '@tiptap/core' +import {Plugin, PluginKey} from '@tiptap/pm/state' +import {Node as ProsemirrorNode} from '@tiptap/pm/model' +import {Decoration, DecorationSet} from '@tiptap/pm/view' + +function getDecorations(doc: ProsemirrorNode) { + const decorations: Decoration[] = [] + + doc.descendants((node, pos) => { + if (node.isText && node.text) { + const regex = /(?:^|\s)(#[^\d\s]\S*)(?=\s)?/g + const textContent = node.textContent + + let match + while ((match = regex.exec(textContent))) { + const [matchedString, tag] = match + + if (tag.length > 66) continue + + const [trailingPunc = ''] = tag.match(/\p{P}+$/u) || [] + + const from = match.index + matchedString.indexOf(tag) + const to = from + (tag.length - trailingPunc.length) + + decorations.push( + Decoration.inline(pos + from, pos + to, { + class: 'autolink', + }), + ) + } + } + }) + + return DecorationSet.create(doc, decorations) +} + +const tagDecoratorPlugin: Plugin = new Plugin({ + key: new PluginKey('link-decorator'), + + state: { + init: (_, {doc}) => getDecorations(doc), + apply: (transaction, decorationSet) => { + if (transaction.docChanged) { + return getDecorations(transaction.doc) + } + return decorationSet.map(transaction.mapping, transaction.doc) + }, + }, + + props: { + decorations(state) { + return tagDecoratorPlugin.getState(state) + }, + }, +}) + +export const TagDecorator = Mark.create({ + name: 'tag-decorator', + priority: 1000, + keepOnSplit: false, + inclusive() { + return true + }, + addProseMirrorPlugins() { + return [tagDecoratorPlugin] + }, +}) diff --git a/src/view/com/post-thread/PostThreadItem.tsx b/src/view/com/post-thread/PostThreadItem.tsx index ebd73983..949fcfea 100644 --- a/src/view/com/post-thread/PostThreadItem.tsx +++ b/src/view/com/post-thread/PostThreadItem.tsx @@ -327,9 +327,11 @@ let PostThreadItemLoaded = ({ styles.postTextLargeContainer, ]}> ) : undefined} @@ -521,9 +523,11 @@ let PostThreadItemLoaded = ({ {richText?.text ? ( ) : undefined} diff --git a/src/view/com/post/Post.tsx b/src/view/com/post/Post.tsx index aec916ad..5fa4da84 100644 --- a/src/view/com/post/Post.tsx +++ b/src/view/com/post/Post.tsx @@ -184,10 +184,12 @@ function PostInner({ {richText.text ? ( ) : undefined} diff --git a/src/view/com/posts/FeedItem.tsx b/src/view/com/posts/FeedItem.tsx index 6f64de18..47a964ab 100644 --- a/src/view/com/posts/FeedItem.tsx +++ b/src/view/com/posts/FeedItem.tsx @@ -347,10 +347,12 @@ let PostContent = ({ {richText.text ? ( ) : undefined} diff --git a/src/view/com/util/forms/NativeDropdown.web.tsx b/src/view/com/util/forms/NativeDropdown.web.tsx index 9e9888ad..052e7ca1 100644 --- a/src/view/com/util/forms/NativeDropdown.web.tsx +++ b/src/view/com/util/forms/NativeDropdown.web.tsx @@ -21,6 +21,7 @@ export const DropdownMenuItem = (props: ItemProps & {testID?: string}) => { return ( ) : null} {embed && } diff --git a/src/view/com/util/text/RichText.tsx b/src/view/com/util/text/RichText.tsx index b6d46122..0ec3f318 100644 --- a/src/view/com/util/text/RichText.tsx +++ b/src/view/com/util/text/RichText.tsx @@ -7,6 +7,9 @@ import {lh} from 'lib/styles' import {toShortUrl} from 'lib/strings/url-helpers' import {useTheme, TypographyVariant} from 'lib/ThemeContext' import {usePalette} from 'lib/hooks/usePalette' +import {makeTagLink} from 'lib/routes/links' +import {TagMenu, useTagMenuControl} from '#/components/TagMenu' +import {isNative} from '#/platform/detection' const WORD_WRAP = {wordWrap: 1} @@ -82,6 +85,7 @@ export function RichText({ for (const segment of richText.segments()) { const link = segment.link const mention = segment.mention + const tag = segment.tag if ( !noLinks && mention && @@ -115,6 +119,21 @@ export function RichText({ />, ) } + } else if ( + !noLinks && + tag && + AppBskyRichtextFacet.validateTag(tag).success + ) { + els.push( + , + ) } else { els.push(segment.text) } @@ -133,3 +152,50 @@ export function RichText({ ) } + +function RichTextTag({ + text: tag, + type, + style, + lineHeightStyle, + selectable, +}: { + text: string + type?: TypographyVariant + style?: StyleProp + lineHeightStyle?: TextStyle + selectable?: boolean +}) { + const pal = usePalette('default') + const control = useTagMenuControl() + + const open = React.useCallback(() => { + control.open() + }, [control]) + + return ( + + + {isNative ? ( + + ) : ( + + {tag} + + )} + + + ) +} diff --git a/src/view/icons/index.tsx b/src/view/icons/index.tsx index b7bbf160..ede1e633 100644 --- a/src/view/icons/index.tsx +++ b/src/view/icons/index.tsx @@ -103,6 +103,7 @@ import {faUsersSlash} from '@fortawesome/free-solid-svg-icons/faUsersSlash' import {faX} from '@fortawesome/free-solid-svg-icons/faX' import {faXmark} from '@fortawesome/free-solid-svg-icons/faXmark' import {faChevronDown} from '@fortawesome/free-solid-svg-icons/faChevronDown' +import {faFilter} from '@fortawesome/free-solid-svg-icons/faFilter' library.add( faAddressCard, @@ -208,4 +209,5 @@ library.add( faX, faXmark, faChevronDown, + faFilter, ) diff --git a/src/view/screens/Moderation.tsx b/src/view/screens/Moderation.tsx index 8f1fe75b..928766c3 100644 --- a/src/view/screens/Moderation.tsx +++ b/src/view/screens/Moderation.tsx @@ -31,6 +31,7 @@ import { useProfileUpdateMutation, } from '#/state/queries/profile' import {ScrollView} from '../com/util/Views' +import {useGlobalDialogsControlContext} from '#/components/dialogs/Context' type Props = NativeStackScreenProps export function ModerationScreen({}: Props) { @@ -40,6 +41,7 @@ export function ModerationScreen({}: Props) { const {screen, track} = useAnalytics() const {isTabletOrDesktop} = useWebMediaQueries() const {openModal} = useModalControls() + const {mutedWordsDialogControl} = useGlobalDialogsControlContext() useFocusEffect( React.useCallback(() => { @@ -69,8 +71,8 @@ export function ModerationScreen({}: Props) { style={[styles.linkCard, pal.view]} onPress={onPressContentFiltering} accessibilityRole="tab" - accessibilityHint="Content filtering" - accessibilityLabel=""> + accessibilityHint="" + accessibilityLabel={_(msg`Open content filtering settings`)}> Content filtering + mutedWordsDialogControl.open()} + accessibilityRole="tab" + accessibilityHint="" + accessibilityLabel={_(msg`Open muted words settings`)}> + + + + + Muted words & tags + + , ) { + const navigation = useNavigation() const theme = useTheme() const textInput = React.useRef(null) const {_} = useLingui() @@ -472,6 +474,27 @@ export function SearchScreen( React.useState(false) const [searchHistory, setSearchHistory] = React.useState([]) + /** + * The Search screen's `q` param + */ + const queryParam = props.route?.params?.q + + /** + * If `true`, this means we received new instructions from the router. This + * is handled in a effect, and used to update the value of `query` locally + * within this screen. + */ + const routeParamsMismatch = queryParam && queryParam !== query + + React.useEffect(() => { + if (queryParam && routeParamsMismatch) { + // reset immediately and let local state take over + navigation.setParams({q: ''}) + // update query for next search + setQuery(queryParam) + } + }, [queryParam, routeParamsMismatch, navigation]) + React.useEffect(() => { const loadSearchHistory = async () => { try { @@ -774,6 +797,8 @@ export function SearchScreen( )} + ) : routeParamsMismatch ? ( + ) : ( )} diff --git a/src/view/shell/index.tsx b/src/view/shell/index.tsx index 6b0cc680..d895d885 100644 --- a/src/view/shell/index.tsx +++ b/src/view/shell/index.tsx @@ -29,6 +29,7 @@ import {useSession} from '#/state/session' import {useCloseAnyActiveElement} from '#/state/util' import * as notifications from 'lib/notifications/notifications' import {Outlet as PortalOutlet} from '#/components/Portal' +import {MutedWordsDialog} from '#/components/dialogs/MutedWords' function ShellInner() { const isDrawerOpen = useIsDrawerOpen() @@ -94,6 +95,7 @@ function ShellInner() { + diff --git a/src/view/shell/index.web.tsx b/src/view/shell/index.web.tsx index 97c06550..71dccb8c 100644 --- a/src/view/shell/index.web.tsx +++ b/src/view/shell/index.web.tsx @@ -16,6 +16,7 @@ import {useIsDrawerOpen, useSetDrawerOpen} from '#/state/shell' import {useCloseAllActiveElements} from '#/state/util' import {useWebBodyScrollLock} from '#/lib/hooks/useWebBodyScrollLock' import {Outlet as PortalOutlet} from '#/components/Portal' +import {MutedWordsDialog} from '#/components/dialogs/MutedWords' function ShellInner() { const isDrawerOpen = useIsDrawerOpen() @@ -40,6 +41,7 @@ function ShellInner() { + {!isDesktop && isDrawerOpen && ( diff --git a/web/index.html b/web/index.html index 992e69e0..78090591 100644 --- a/web/index.html +++ b/web/index.html @@ -209,6 +209,11 @@ [data-tooltip]:hover::before { display:block; } + + /* NativeDropdown component */ + .nativeDropdown-item:focus { + outline: none; + } diff --git a/yarn.lock b/yarn.lock index a85ea79b..3cec585b 100644 --- a/yarn.lock +++ b/yarn.lock @@ -34,6 +34,20 @@ jsonpointer "^5.0.0" leven "^3.1.0" +"@atproto/api@^0.10.0": + version "0.10.0" + resolved "https://registry.yarnpkg.com/@atproto/api/-/api-0.10.0.tgz#ca34dfa8f9b1e6ba021094c40cb0ff3c4c254044" + integrity sha512-TSVCHh3UUZLtNzh141JwLicfYTc7TvVFvQJSWeOZLHr3Sk+9hqEY+9Itaqp1DAW92r4i25ChaMc/50sg4etAWQ== + dependencies: + "@atproto/common-web" "^0.2.3" + "@atproto/lexicon" "^0.3.1" + "@atproto/syntax" "^0.1.5" + "@atproto/xrpc" "^0.4.1" + multiformats "^9.9.0" + tlds "^1.234.0" + typed-emitter "^2.1.0" + zod "^3.21.4" + "@atproto/api@^0.9.5": version "0.9.5" resolved "https://registry.yarnpkg.com/@atproto/api/-/api-0.9.5.tgz#630e5d9520bba38d0cd348c8028ddbb73bd074f8"