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/TagMenu/index.tsx | 279 +++++++++++++++++++++++++++++++++++++++
 1 file changed, 279 insertions(+)
 create mode 100644 src/components/TagMenu/index.tsx

(limited to 'src/components/TagMenu/index.tsx')

diff --git a/src/components/TagMenu/index.tsx b/src/components/TagMenu/index.tsx
new file mode 100644
index 000000000..2fec7a188
--- /dev/null
+++ b/src/components/TagMenu/index.tsx
@@ -0,0 +1,279 @@
+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 {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 {
+  usePreferencesQuery,
+  useUpsertMutedWordsMutation,
+  useRemoveMutedWordMutation,
+} from '#/state/queries/preferences'
+import {Loader} from '#/components/Loader'
+import {isInvalidHandle} from '#/lib/strings/handles'
+
+export function useTagMenuControl() {
+  return Dialog.useDialogControl()
+}
+
+export function TagMenu({
+  children,
+  control,
+  tag,
+  authorHandle,
+}: React.PropsWithChildren<{
+  control: Dialog.DialogOuterProps['control']
+  tag: string
+  authorHandle?: string
+}>) {
+  const {_} = useLingui()
+  const t = useTheme()
+  const navigation = useNavigation<NavigationProp>()
+  const {isLoading: isPreferencesLoading, data: preferences} =
+    usePreferencesQuery()
+  const {
+    mutateAsync: upsertMutedWord,
+    variables: optimisticUpsert,
+    reset: resetUpsert,
+  } = useUpsertMutedWordsMutation()
+  const {
+    mutateAsync: removeMutedWord,
+    variables: optimisticRemove,
+    reset: resetRemove,
+  } = useRemoveMutedWordMutation()
+
+  const sanitizedTag = tag.replace(/^#/, '')
+  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),
+  )
+
+  return (
+    <>
+      {children}
+
+      <Dialog.Outer control={control}>
+        <Dialog.Handle />
+
+        <Dialog.Inner label={_(msg`Tag menu: ${tag}`)}>
+          {isPreferencesLoading ? (
+            <View style={[a.w_full, a.align_center]}>
+              <Loader size="lg" />
+            </View>
+          ) : (
+            <>
+              <View
+                style={[
+                  a.rounded_md,
+                  a.border,
+                  a.mb_md,
+                  t.atoms.border_contrast_low,
+                  t.atoms.bg_contrast_25,
+                ]}>
+                <Link
+                  label={_(msg`Search for all posts with tag ${tag}`)}
+                  to={makeSearchLink({query: tag})}
+                  onPress={e => {
+                    e.preventDefault()
+
+                    control.close(() => {
+                      // @ts-ignore :ron_swanson: "I know more than you"
+                      navigation.navigate('SearchTab', {
+                        screen: 'Search',
+                        params: {
+                          q: tag,
+                        },
+                      })
+                    })
+
+                    return false
+                  }}>
+                  <View
+                    style={[
+                      a.w_full,
+                      a.flex_row,
+                      a.align_center,
+                      a.justify_start,
+                      a.gap_md,
+                      a.px_lg,
+                      a.py_md,
+                    ]}>
+                    <Search size="lg" style={[t.atoms.text_contrast_medium]} />
+                    <Text
+                      numberOfLines={1}
+                      ellipsizeMode="middle"
+                      style={[
+                        a.flex_1,
+                        a.text_md,
+                        a.font_bold,
+                        native({top: 2}),
+                        t.atoms.text_contrast_medium,
+                      ]}>
+                      <Trans>
+                        See{' '}
+                        <Text style={[a.text_md, a.font_bold, t.atoms.text]}>
+                          {tag}
+                        </Text>{' '}
+                        posts
+                      </Trans>
+                    </Text>
+                  </View>
+                </Link>
+
+                {authorHandle && !isInvalidHandle(authorHandle) && (
+                  <>
+                    <Divider />
+
+                    <Link
+                      label={_(
+                        msg`Search for all posts by @${authorHandle} with tag ${tag}`,
+                      )}
+                      to={makeSearchLink({query: tag, from: authorHandle})}
+                      onPress={e => {
+                        e.preventDefault()
+
+                        control.close(() => {
+                          // @ts-ignore :ron_swanson: "I know more than you"
+                          navigation.navigate('SearchTab', {
+                            screen: 'Search',
+                            params: {
+                              q:
+                                tag +
+                                (authorHandle ? ` from:${authorHandle}` : ''),
+                            },
+                          })
+                        })
+
+                        return false
+                      }}>
+                      <View
+                        style={[
+                          a.w_full,
+                          a.flex_row,
+                          a.align_center,
+                          a.justify_start,
+                          a.gap_md,
+                          a.px_lg,
+                          a.py_md,
+                        ]}>
+                        <Person
+                          size="lg"
+                          style={[t.atoms.text_contrast_medium]}
+                        />
+                        <Text
+                          numberOfLines={1}
+                          ellipsizeMode="middle"
+                          style={[
+                            a.flex_1,
+                            a.text_md,
+                            a.font_bold,
+                            native({top: 2}),
+                            t.atoms.text_contrast_medium,
+                          ]}>
+                          <Trans>
+                            See{' '}
+                            <Text
+                              style={[a.text_md, a.font_bold, t.atoms.text]}>
+                              {tag}
+                            </Text>{' '}
+                            posts by this user
+                          </Trans>
+                        </Text>
+                      </View>
+                    </Link>
+                  </>
+                )}
+
+                {preferences ? (
+                  <>
+                    <Divider />
+
+                    <Button
+                      label={
+                        isMuted
+                          ? _(msg`Unmute all ${tag} posts`)
+                          : _(msg`Mute all ${tag} posts`)
+                      }
+                      onPress={() => {
+                        control.close(() => {
+                          if (isMuted) {
+                            resetUpsert()
+                            removeMutedWord({
+                              value: sanitizedTag,
+                              targets: ['tag'],
+                            })
+                          } else {
+                            resetRemove()
+                            upsertMutedWord([
+                              {value: sanitizedTag, targets: ['tag']},
+                            ])
+                          }
+                        })
+                      }}>
+                      <View
+                        style={[
+                          a.w_full,
+                          a.flex_row,
+                          a.align_center,
+                          a.justify_start,
+                          a.gap_md,
+                          a.px_lg,
+                          a.py_md,
+                        ]}>
+                        <Mute
+                          size="lg"
+                          style={[t.atoms.text_contrast_medium]}
+                        />
+                        <Text
+                          numberOfLines={1}
+                          ellipsizeMode="middle"
+                          style={[
+                            a.flex_1,
+                            a.text_md,
+                            a.font_bold,
+                            native({top: 2}),
+                            t.atoms.text_contrast_medium,
+                          ]}>
+                          {isMuted ? _(msg`Unmute`) : _(msg`Mute`)}{' '}
+                          <Text style={[a.text_md, a.font_bold, t.atoms.text]}>
+                            {tag}
+                          </Text>{' '}
+                          <Trans>posts</Trans>
+                        </Text>
+                      </View>
+                    </Button>
+                  </>
+                ) : null}
+              </View>
+
+              <Button
+                label={_(msg`Close this dialog`)}
+                size="small"
+                variant="ghost"
+                color="secondary"
+                onPress={() => control.close()}>
+                <ButtonText>Cancel</ButtonText>
+              </Button>
+            </>
+          )}
+        </Dialog.Inner>
+      </Dialog.Outer>
+    </>
+  )
+}
-- 
cgit 1.4.1

