From 58aaad704aa971c5ebbf5a5f330a2e2129b557f6 Mon Sep 17 00:00:00 2001
From: Eric Bailey <git@esb.lol>
Date: Mon, 26 Feb 2024 22:33:48 -0600
Subject: 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>
---
 src/components/dialogs/MutedWords.tsx | 328 ++++++++++++++++++++++++++++++++++
 1 file changed, 328 insertions(+)
 create mode 100644 src/components/dialogs/MutedWords.tsx

(limited to 'src/components/dialogs/MutedWords.tsx')

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>
+  )
+}
-- 
cgit 1.4.1

