diff options
author | Eric Bailey <git@esb.lol> | 2024-02-26 22:33:48 -0600 |
---|---|---|
committer | GitHub <noreply@github.com> | 2024-02-26 20:33:48 -0800 |
commit | 58aaad704aa971c5ebbf5a5f330a2e2129b557f6 (patch) | |
tree | 74a448e61e83ca9292b0c6bf8d638bcfabd11eec /src/components/dialogs | |
parent | c8582924e2421e5383050c4f60a80d2e74287c07 (diff) | |
download | voidsky-58aaad704aa971c5ebbf5a5f330a2e2129b557f6.tar.zst |
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 <mail@kisaragi-hiu.com>
Diffstat (limited to 'src/components/dialogs')
-rw-r--r-- | src/components/dialogs/Context.tsx | 29 | ||||
-rw-r--r-- | src/components/dialogs/MutedWords.tsx | 328 |
2 files changed, 357 insertions, 0 deletions
diff --git a/src/components/dialogs/Context.tsx b/src/components/dialogs/Context.tsx new file mode 100644 index 000000000..d86c90a92 --- /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 ( + <ControlsContext.Provider value={ctx}>{children}</ControlsContext.Provider> + ) +} diff --git a/src/components/dialogs/MutedWords.tsx b/src/components/dialogs/MutedWords.tsx new file mode 100644 index 000000000..138cc5330 --- /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 ( + <Dialog.Outer control={control}> + <Dialog.Handle /> + <MutedWordsInner control={control} /> + </Dialog.Outer> + ) +} + +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 ( + <Dialog.ScrollableInner label={_(msg`Manage your muted words and tags`)}> + <Text + style={[a.text_md, a.font_bold, a.pb_sm, t.atoms.text_contrast_high]}> + <Trans>Add muted words and tags</Trans> + </Text> + <Text style={[a.pb_lg, a.leading_snug, t.atoms.text_contrast_medium]}> + <Trans> + Posts can be muted based on their text, their tags, or both. + </Trans> + </Text> + + <View style={[a.pb_lg]}> + <Dialog.Input + autoCorrect={false} + autoCapitalize="none" + autoComplete="off" + label={_(msg`Enter a word or tag`)} + placeholder={_(msg`Enter a word or tag`)} + value={field} + onChangeText={setField} + onSubmitEditing={submit} + /> + + <Toggle.Group + label={_(msg`Toggle between muted word options.`)} + type="radio" + values={options} + onChange={setOptions}> + <View + style={[ + a.pt_sm, + a.pb_md, + a.flex_row, + a.align_center, + a.gap_sm, + a.flex_wrap, + ]}> + <Toggle.Item + label={_(msg`Mute this word in post text and tags`)} + name="content" + style={[a.flex_1, !gtMobile && [a.w_full, a.flex_0]]}> + <TargetToggle> + <View style={[a.flex_row, a.align_center, a.gap_sm]}> + <Toggle.Radio /> + <Toggle.Label> + <Trans>Mute in text & tags</Trans> + </Toggle.Label> + </View> + <PageText size="sm" /> + </TargetToggle> + </Toggle.Item> + + <Toggle.Item + label={_(msg`Mute this word in tags only`)} + name="tag" + style={[a.flex_1, !gtMobile && [a.w_full, a.flex_0]]}> + <TargetToggle> + <View style={[a.flex_row, a.align_center, a.gap_sm]}> + <Toggle.Radio /> + <Toggle.Label> + <Trans>Mute in tags only</Trans> + </Toggle.Label> + </View> + <Hashtag size="sm" /> + </TargetToggle> + </Toggle.Item> + + <Button + disabled={isPending || !field} + label={_(msg`Add mute word for configured settings`)} + size="small" + color="primary" + variant="solid" + style={[!gtMobile && [a.w_full, a.flex_0]]} + onPress={submit}> + <ButtonText> + <Trans>Add</Trans> + </ButtonText> + <ButtonIcon icon={isPending ? Loader : Plus} /> + </Button> + </View> + </Toggle.Group> + + <Text + style={[ + 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> + + <Divider /> + + <View style={[a.pt_2xl]}> + <Text + style={[a.text_md, a.font_bold, a.pb_md, t.atoms.text_contrast_high]}> + <Trans>Your muted words</Trans> + </Text> + + {isPreferencesLoading ? ( + <Loader /> + ) : preferencesError || !preferences ? ( + <View + style={[a.py_md, a.px_lg, a.rounded_md, t.atoms.bg_contrast_25]}> + <Text style={[a.italic, t.atoms.text_contrast_high]}> + <Trans> + We're sorry, but we weren't able to load your muted words at + this time. Please try again. + </Trans> + </Text> + </View> + ) : preferences.mutedWords.length ? ( + [...preferences.mutedWords] + .reverse() + .map((word, i) => ( + <MutedWordRow + key={word.value + i} + word={word} + style={[i % 2 === 0 && t.atoms.bg_contrast_25]} + /> + )) + ) : ( + <View + style={[a.py_md, a.px_lg, a.rounded_md, t.atoms.bg_contrast_25]}> + <Text style={[a.italic, t.atoms.text_contrast_high]}> + <Trans>You haven't muted any words or tags yet</Trans> + </Text> + </View> + )} + </View> + + {isNative && <View style={{height: 20}} />} + + <Dialog.Close /> + </Dialog.ScrollableInner> + ) +} + +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 ( + <> + <Prompt.Outer control={control}> + <Prompt.Title> + <Trans>Are you sure?</Trans> + </Prompt.Title> + <Prompt.Description> + <Trans> + This will delete {word.value} from your muted words. You can always + add it back later. + </Trans> + </Prompt.Description> + <Prompt.Actions> + <Prompt.Cancel> + <ButtonText> + <Trans>Nevermind</Trans> + </ButtonText> + </Prompt.Cancel> + <Prompt.Action onPress={remove}> + <ButtonText> + <Trans>Remove</Trans> + </ButtonText> + </Prompt.Action> + </Prompt.Actions> + </Prompt.Outer> + + <View + style={[ + a.py_md, + a.px_lg, + a.flex_row, + a.align_center, + a.justify_between, + a.rounded_md, + style, + ]}> + <Text style={[a.font_bold, t.atoms.text_contrast_high]}> + {word.value} + </Text> + + <View style={[a.flex_row, a.align_center, a.justify_end, a.gap_sm]}> + {word.targets.map(target => ( + <View + key={target} + style={[a.py_xs, a.px_sm, a.rounded_sm, t.atoms.bg_contrast_100]}> + <Text + style={[a.text_xs, a.font_bold, t.atoms.text_contrast_medium]}> + {target === 'content' ? _(msg`text`) : _(msg`tag`)} + </Text> + </View> + ))} + + <Button + label={_(msg`Remove mute word from your list`)} + size="tiny" + shape="round" + variant="ghost" + color="secondary" + onPress={() => control.open()} + style={[a.ml_sm]}> + <ButtonIcon icon={isPending ? Loader : X} /> + </Button> + </View> + </View> + </> + ) +} + +function TargetToggle({children}: React.PropsWithChildren<{}>) { + const t = useTheme() + const ctx = Toggle.useItemContext() + const {gtMobile} = useBreakpoints() + return ( + <View + style={[ + a.flex_row, + a.align_center, + a.justify_between, + a.gap_xs, + a.flex_1, + a.py_sm, + a.px_sm, + gtMobile && a.px_md, + a.rounded_sm, + t.atoms.bg_contrast_50, + (ctx.hovered || ctx.focused) && t.atoms.bg_contrast_100, + ctx.selected && [ + { + backgroundColor: + t.name === 'light' ? t.palette.primary_50 : t.palette.primary_975, + }, + ], + ctx.disabled && { + opacity: 0.8, + }, + ]}> + {children} + </View> + ) +} |