about summary refs log tree commit diff
path: root/src/view
diff options
context:
space:
mode:
Diffstat (limited to 'src/view')
-rw-r--r--src/view/com/composer/text-input/TextInput.tsx3
-rw-r--r--src/view/com/composer/text-input/TextInput.web.tsx2
-rw-r--r--src/view/com/composer/text-input/web/TagDecorator.ts83
-rw-r--r--src/view/com/post-thread/PostThreadItem.tsx6
-rw-r--r--src/view/com/post/Post.tsx2
-rw-r--r--src/view/com/posts/FeedItem.tsx2
-rw-r--r--src/view/com/util/forms/NativeDropdown.web.tsx5
-rw-r--r--src/view/com/util/forms/PostDropdownBtn.tsx16
-rw-r--r--src/view/com/util/post-embeds/QuoteEmbed.tsx2
-rw-r--r--src/view/com/util/text/RichText.tsx66
-rw-r--r--src/view/icons/index.tsx2
-rw-r--r--src/view/screens/Moderation.tsx23
-rw-r--r--src/view/screens/Search/Search.tsx27
-rw-r--r--src/view/shell/index.tsx2
-rw-r--r--src/view/shell/index.web.tsx2
15 files changed, 237 insertions, 6 deletions
diff --git a/src/view/com/composer/text-input/TextInput.tsx b/src/view/com/composer/text-input/TextInput.tsx
index 17f9513b7..20be585c2 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 (
         <Text
           key={i++}
           style={[
-            segment.facet && !isTag ? pal.link : pal.text,
+            segment.facet ? pal.link : pal.text,
             styles.textInputFormatting,
           ]}>
           {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 199f1f749..c62d11201 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 000000000..d820ec3f0
--- /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 <span> 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 ebd739839..949fcfea0 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,
                   ]}>
                   <RichText
+                    enableTags
+                    selectable
                     value={richText}
                     style={[a.flex_1, a.text_xl]}
-                    selectable
+                    authorHandle={post.author.handle}
                   />
                 </View>
               ) : undefined}
@@ -521,9 +523,11 @@ let PostThreadItemLoaded = ({
                 {richText?.text ? (
                   <View style={styles.postTextContainer}>
                     <RichText
+                      enableTags
                       value={richText}
                       style={[a.flex_1, a.text_md]}
                       numberOfLines={limitLines ? MAX_POST_LINES : undefined}
+                      authorHandle={post.author.handle}
                     />
                   </View>
                 ) : undefined}
diff --git a/src/view/com/post/Post.tsx b/src/view/com/post/Post.tsx
index aec916adb..5fa4da84e 100644
--- a/src/view/com/post/Post.tsx
+++ b/src/view/com/post/Post.tsx
@@ -184,10 +184,12 @@ function PostInner({
             {richText.text ? (
               <View style={styles.postTextContainer}>
                 <RichText
+                  enableTags
                   testID="postText"
                   value={richText}
                   numberOfLines={limitLines ? MAX_POST_LINES : undefined}
                   style={[a.flex_1, a.text_md]}
+                  authorHandle={post.author.handle}
                 />
               </View>
             ) : undefined}
diff --git a/src/view/com/posts/FeedItem.tsx b/src/view/com/posts/FeedItem.tsx
index 6f64de181..47a964ab1 100644
--- a/src/view/com/posts/FeedItem.tsx
+++ b/src/view/com/posts/FeedItem.tsx
@@ -347,10 +347,12 @@ let PostContent = ({
       {richText.text ? (
         <View style={styles.postTextContainer}>
           <RichText
+            enableTags
             testID="postText"
             value={richText}
             numberOfLines={limitLines ? MAX_POST_LINES : undefined}
             style={[a.flex_1, a.text_md]}
+            authorHandle={postAuthor.handle}
           />
         </View>
       ) : undefined}
diff --git a/src/view/com/util/forms/NativeDropdown.web.tsx b/src/view/com/util/forms/NativeDropdown.web.tsx
index 9e9888ad8..052e7ca13 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 (
     <DropdownMenu.Item
+      className="nativeDropdown-item"
       {...props}
       style={StyleSheet.flatten([
         styles.item,
@@ -232,6 +233,10 @@ const styles = StyleSheet.create({
     paddingLeft: 12,
     paddingRight: 12,
     borderRadius: 8,
+    fontFamily:
+      '-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif',
+    outline: 0,
+    border: 0,
   },
   itemTitle: {
     fontSize: 16,
diff --git a/src/view/com/util/forms/PostDropdownBtn.tsx b/src/view/com/util/forms/PostDropdownBtn.tsx
index 1dfb687df..09850a7f5 100644
--- a/src/view/com/util/forms/PostDropdownBtn.tsx
+++ b/src/view/com/util/forms/PostDropdownBtn.tsx
@@ -34,6 +34,7 @@ import {useLingui} from '@lingui/react'
 import {useSession} from '#/state/session'
 import {isWeb} from '#/platform/detection'
 import {richTextToString} from '#/lib/strings/rich-text-helpers'
+import {useGlobalDialogsControlContext} from '#/components/dialogs/Context'
 
 let PostDropdownBtn = ({
   testID,
@@ -67,6 +68,7 @@ let PostDropdownBtn = ({
   const {hidePost} = useHiddenPostsApi()
   const openLink = useOpenLink()
   const navigation = useNavigation()
+  const {mutedWordsDialogControl} = useGlobalDialogsControlContext()
 
   const rootUri = record.reply?.root?.uri || postUri
   const isThreadMuted = mutedThreads.includes(rootUri)
@@ -210,6 +212,20 @@ let PostDropdownBtn = ({
         web: 'comment-slash',
       },
     },
+    hasSession && {
+      label: _(msg`Mute words & tags`),
+      onPress() {
+        mutedWordsDialogControl.open()
+      },
+      testID: 'postDropdownMuteWordsBtn',
+      icon: {
+        ios: {
+          name: 'speaker.slash',
+        },
+        android: 'ic_lock_silent_mode',
+        web: 'filter',
+      },
+    },
     hasSession &&
       !isAuthor &&
       !isPostHidden && {
diff --git a/src/view/com/util/post-embeds/QuoteEmbed.tsx b/src/view/com/util/post-embeds/QuoteEmbed.tsx
index c128a6f00..35b091269 100644
--- a/src/view/com/util/post-embeds/QuoteEmbed.tsx
+++ b/src/view/com/util/post-embeds/QuoteEmbed.tsx
@@ -128,10 +128,12 @@ export function QuoteEmbed({
       ) : null}
       {richText ? (
         <RichText
+          enableTags
           value={richText}
           style={[a.text_md]}
           numberOfLines={20}
           disableLinks
+          authorHandle={quote.author.handle}
         />
       ) : null}
       {embed && <PostEmbeds embed={embed} moderation={{}} />}
diff --git a/src/view/com/util/text/RichText.tsx b/src/view/com/util/text/RichText.tsx
index b6d461224..0ec3f3181 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(
+        <RichTextTag
+          key={key}
+          text={segment.text}
+          type={type}
+          style={style}
+          lineHeightStyle={lineHeightStyle}
+          selectable={selectable}
+        />,
+      )
     } else {
       els.push(segment.text)
     }
@@ -133,3 +152,50 @@ export function RichText({
     </Text>
   )
 }
+
+function RichTextTag({
+  text: tag,
+  type,
+  style,
+  lineHeightStyle,
+  selectable,
+}: {
+  text: string
+  type?: TypographyVariant
+  style?: StyleProp<TextStyle>
+  lineHeightStyle?: TextStyle
+  selectable?: boolean
+}) {
+  const pal = usePalette('default')
+  const control = useTagMenuControl()
+
+  const open = React.useCallback(() => {
+    control.open()
+  }, [control])
+
+  return (
+    <React.Fragment>
+      <TagMenu control={control} tag={tag}>
+        {isNative ? (
+          <TextLink
+            type={type}
+            text={tag}
+            // segment.text has the leading "#" while tag.tag does not
+            href={makeTagLink(tag)}
+            style={[style, lineHeightStyle, pal.link, {pointerEvents: 'auto'}]}
+            dataSet={WORD_WRAP}
+            selectable={selectable}
+            onPress={open}
+          />
+        ) : (
+          <Text
+            selectable={selectable}
+            type={type}
+            style={[style, lineHeightStyle, pal.link, {pointerEvents: 'auto'}]}>
+            {tag}
+          </Text>
+        )}
+      </TagMenu>
+    </React.Fragment>
+  )
+}
diff --git a/src/view/icons/index.tsx b/src/view/icons/index.tsx
index b7bbf1600..ede1e6335 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 8f1fe75b6..928766c30 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<CommonNavigatorParams, 'Moderation'>
 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`)}>
           <View style={[styles.iconContainer, pal.btn]}>
             <FontAwesomeIcon
               icon="eye"
@@ -81,6 +83,23 @@ export function ModerationScreen({}: Props) {
             <Trans>Content filtering</Trans>
           </Text>
         </TouchableOpacity>
+        <TouchableOpacity
+          testID="mutedWordsBtn"
+          style={[styles.linkCard, pal.view]}
+          onPress={() => mutedWordsDialogControl.open()}
+          accessibilityRole="tab"
+          accessibilityHint=""
+          accessibilityLabel={_(msg`Open muted words settings`)}>
+          <View style={[styles.iconContainer, pal.btn]}>
+            <FontAwesomeIcon
+              icon="filter"
+              style={pal.text as FontAwesomeIconStyle}
+            />
+          </View>
+          <Text type="lg" style={pal.text}>
+            <Trans>Muted words & tags</Trans>
+          </Text>
+        </TouchableOpacity>
         <Link
           testID="moderationlistsBtn"
           style={[styles.linkCard, pal.view]}
diff --git a/src/view/screens/Search/Search.tsx b/src/view/screens/Search/Search.tsx
index 142726701..42eec53d3 100644
--- a/src/view/screens/Search/Search.tsx
+++ b/src/view/screens/Search/Search.tsx
@@ -16,7 +16,7 @@ import {
   FontAwesomeIcon,
   FontAwesomeIconStyle,
 } from '@fortawesome/react-native-fontawesome'
-import {useFocusEffect} from '@react-navigation/native'
+import {useFocusEffect, useNavigation} from '@react-navigation/native'
 
 import {logger} from '#/logger'
 import {
@@ -53,6 +53,7 @@ import {listenSoftReset} from '#/state/events'
 import {s} from '#/lib/styles'
 import AsyncStorage from '@react-native-async-storage/async-storage'
 import {augmentSearchQuery} from '#/lib/strings/helpers'
+import {NavigationProp} from '#/lib/routes/types'
 
 function Loader() {
   const pal = usePalette('default')
@@ -448,6 +449,7 @@ export function SearchScreenInner({
 export function SearchScreen(
   props: NativeStackScreenProps<SearchTabNavigatorParams, 'Search'>,
 ) {
+  const navigation = useNavigation<NavigationProp>()
   const theme = useTheme()
   const textInput = React.useRef<TextInput>(null)
   const {_} = useLingui()
@@ -472,6 +474,27 @@ export function SearchScreen(
     React.useState(false)
   const [searchHistory, setSearchHistory] = React.useState<string[]>([])
 
+  /**
+   * 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(
             )}
           </View>
         </CenteredView>
+      ) : routeParamsMismatch ? (
+        <ActivityIndicator />
       ) : (
         <SearchScreenInner query={query} />
       )}
diff --git a/src/view/shell/index.tsx b/src/view/shell/index.tsx
index 6b0cc6808..d895d8851 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() {
       </View>
       <Composer winHeight={winDim.height} />
       <ModalsContainer />
+      <MutedWordsDialog />
       <PortalOutlet />
       <Lightbox />
     </>
diff --git a/src/view/shell/index.web.tsx b/src/view/shell/index.web.tsx
index 97c065502..71dccb8c4 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() {
       </ErrorBoundary>
       <Composer winHeight={0} />
       <ModalsContainer />
+      <MutedWordsDialog />
       <PortalOutlet />
       <Lightbox />
       {!isDesktop && isDrawerOpen && (