about summary refs log tree commit diff
path: root/src/lib
diff options
context:
space:
mode:
Diffstat (limited to 'src/lib')
-rw-r--r--src/lib/__tests__/moderatePost_wrapped.test.ts692
-rw-r--r--src/lib/api/index.ts6
-rw-r--r--src/lib/constants.ts6
-rw-r--r--src/lib/hooks/useIntentHandler.ts91
-rw-r--r--src/lib/moderatePost_wrapped.ts205
-rw-r--r--src/lib/moderation.ts7
-rw-r--r--src/lib/routes/links.ts10
-rw-r--r--src/lib/routes/types.ts1
-rw-r--r--src/lib/strings/helpers.ts21
9 files changed, 1033 insertions, 6 deletions
diff --git a/src/lib/__tests__/moderatePost_wrapped.test.ts b/src/lib/__tests__/moderatePost_wrapped.test.ts
new file mode 100644
index 000000000..45566281a
--- /dev/null
+++ b/src/lib/__tests__/moderatePost_wrapped.test.ts
@@ -0,0 +1,692 @@
+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({
+        mutedWords: [{value: 'outlineTag', targets: ['tag']}],
+        text: rt.text,
+        facets: rt.facets,
+        outlineTags: ['outlineTag'],
+        isOwnPost: false,
+      })
+
+      expect(match).toBe(true)
+    })
+
+    it(`match: inline tag`, () => {
+      const rt = new RichText({
+        text: `This is a post #inlineTag`,
+      })
+      rt.detectFacetsWithoutResolution()
+
+      const match = hasMutedWord({
+        mutedWords: [{value: 'inlineTag', targets: ['tag']}],
+        text: rt.text,
+        facets: rt.facets,
+        outlineTags: ['outlineTag'],
+        isOwnPost: false,
+      })
+
+      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({
+        mutedWords: [{value: 'inlineTag', targets: ['content']}],
+        text: rt.text,
+        facets: rt.facets,
+        outlineTags: ['outlineTag'],
+        isOwnPost: false,
+      })
+
+      expect(match).toBe(true)
+    })
+
+    it(`no match: only tag targets`, () => {
+      const rt = new RichText({
+        text: `This is a post`,
+      })
+      rt.detectFacetsWithoutResolution()
+
+      const match = hasMutedWord({
+        mutedWords: [{value: 'inlineTag', targets: ['tag']}],
+        text: rt.text,
+        facets: rt.facets,
+        outlineTags: [],
+        isOwnPost: false,
+      })
+
+      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({
+        mutedWords: [{value: '希', targets: ['content']}],
+        text: rt.text,
+        facets: rt.facets,
+        outlineTags: [],
+        isOwnPost: false,
+      })
+
+      expect(match).toBe(true)
+    })
+
+    it(`no match: long muted word, short post`, () => {
+      const rt = new RichText({
+        text: `hey`,
+      })
+      rt.detectFacetsWithoutResolution()
+
+      const match = hasMutedWord({
+        mutedWords: [{value: 'politics', targets: ['content']}],
+        text: rt.text,
+        facets: rt.facets,
+        outlineTags: [],
+        isOwnPost: false,
+      })
+
+      expect(match).toBe(false)
+    })
+
+    it(`match: exact text`, () => {
+      const rt = new RichText({
+        text: `javascript`,
+      })
+      rt.detectFacetsWithoutResolution()
+
+      const match = hasMutedWord({
+        mutedWords: [{value: 'javascript', targets: ['content']}],
+        text: rt.text,
+        facets: rt.facets,
+        outlineTags: [],
+        isOwnPost: false,
+      })
+
+      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({
+        mutedWords: [{value: 'javascript', targets: ['content']}],
+        text: rt.text,
+        facets: rt.facets,
+        outlineTags: [],
+        isOwnPost: false,
+      })
+
+      expect(match).toBe(true)
+    })
+
+    it(`no match: partial word`, () => {
+      const rt = new RichText({
+        text: `Use your brain, Eric`,
+      })
+      rt.detectFacetsWithoutResolution()
+
+      const match = hasMutedWord({
+        mutedWords: [{value: 'ai', targets: ['content']}],
+        text: rt.text,
+        facets: rt.facets,
+        outlineTags: [],
+        isOwnPost: false,
+      })
+
+      expect(match).toBe(false)
+    })
+
+    it(`match: multiline`, () => {
+      const rt = new RichText({
+        text: `Use your\n\tbrain, Eric`,
+      })
+      rt.detectFacetsWithoutResolution()
+
+      const match = hasMutedWord({
+        mutedWords: [{value: 'brain', targets: ['content']}],
+        text: rt.text,
+        facets: rt.facets,
+        outlineTags: [],
+        isOwnPost: false,
+      })
+
+      expect(match).toBe(true)
+    })
+
+    it(`match: :)`, () => {
+      const rt = new RichText({
+        text: `So happy :)`,
+      })
+      rt.detectFacetsWithoutResolution()
+
+      const match = hasMutedWord({
+        mutedWords: [{value: `:)`, targets: ['content']}],
+        text: rt.text,
+        facets: rt.facets,
+        outlineTags: [],
+        isOwnPost: false,
+      })
+
+      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({
+          mutedWords: [{value: 'yay!', targets: ['content']}],
+          text: rt.text,
+          facets: rt.facets,
+          outlineTags: [],
+          isOwnPost: false,
+        })
+
+        expect(match).toBe(true)
+      })
+
+      it(`match: yay`, () => {
+        const match = hasMutedWord({
+          mutedWords: [{value: 'yay', targets: ['content']}],
+          text: rt.text,
+          facets: rt.facets,
+          outlineTags: [],
+          isOwnPost: false,
+        })
+
+        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({
+          mutedWords: [{value: 'y!ppee', targets: ['content']}],
+          text: rt.text,
+          facets: rt.facets,
+          outlineTags: [],
+          isOwnPost: false,
+        })
+
+        expect(match).toBe(true)
+      })
+
+      // single exclamation point, source has double
+      it(`no match: y!ppee!`, () => {
+        const match = hasMutedWord({
+          mutedWords: [{value: 'y!ppee!', targets: ['content']}],
+          text: rt.text,
+          facets: rt.facets,
+          outlineTags: [],
+          isOwnPost: false,
+        })
+
+        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({
+          mutedWords: [{value: 'S@assy', targets: ['content']}],
+          text: rt.text,
+          facets: rt.facets,
+          outlineTags: [],
+          isOwnPost: false,
+        })
+
+        expect(match).toBe(true)
+      })
+
+      it(`match: s@assy`, () => {
+        const match = hasMutedWord({
+          mutedWords: [{value: 's@assy', targets: ['content']}],
+          text: rt.text,
+          facets: rt.facets,
+          outlineTags: [],
+          isOwnPost: false,
+        })
+
+        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({
+          mutedWords: [{value: 'new york times', targets: ['content']}],
+          text: rt.text,
+          facets: rt.facets,
+          outlineTags: [],
+          isOwnPost: false,
+        })
+
+        expect(match).toBe(true)
+      })
+    })
+
+    describe(`!command`, () => {
+      const rt = new RichText({
+        text: `Idk maybe a bot !command`,
+      })
+      rt.detectFacetsWithoutResolution()
+
+      it(`match: !command`, () => {
+        const match = hasMutedWord({
+          mutedWords: [{value: `!command`, targets: ['content']}],
+          text: rt.text,
+          facets: rt.facets,
+          outlineTags: [],
+          isOwnPost: false,
+        })
+
+        expect(match).toBe(true)
+      })
+
+      it(`match: command`, () => {
+        const match = hasMutedWord({
+          mutedWords: [{value: `command`, targets: ['content']}],
+          text: rt.text,
+          facets: rt.facets,
+          outlineTags: [],
+          isOwnPost: false,
+        })
+
+        expect(match).toBe(true)
+      })
+
+      it(`no match: !command`, () => {
+        const rt = new RichText({
+          text: `Idk maybe a bot command`,
+        })
+        rt.detectFacetsWithoutResolution()
+
+        const match = hasMutedWord({
+          mutedWords: [{value: `!command`, targets: ['content']}],
+          text: rt.text,
+          facets: rt.facets,
+          outlineTags: [],
+          isOwnPost: false,
+        })
+
+        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({
+          mutedWords: [{value: `e/acc`, targets: ['content']}],
+          text: rt.text,
+          facets: rt.facets,
+          outlineTags: [],
+          isOwnPost: false,
+        })
+
+        expect(match).toBe(true)
+      })
+
+      it(`match: acc`, () => {
+        const match = hasMutedWord({
+          mutedWords: [{value: `acc`, targets: ['content']}],
+          text: rt.text,
+          facets: rt.facets,
+          outlineTags: [],
+          isOwnPost: false,
+        })
+
+        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({
+          mutedWords: [{value: `super-bad`, targets: ['content']}],
+          text: rt.text,
+          facets: rt.facets,
+          outlineTags: [],
+          isOwnPost: false,
+        })
+
+        expect(match).toBe(true)
+      })
+
+      it(`match: super`, () => {
+        const match = hasMutedWord({
+          mutedWords: [{value: `super`, targets: ['content']}],
+          text: rt.text,
+          facets: rt.facets,
+          outlineTags: [],
+          isOwnPost: false,
+        })
+
+        expect(match).toBe(true)
+      })
+
+      it(`match: super bad`, () => {
+        const match = hasMutedWord({
+          mutedWords: [{value: `super bad`, targets: ['content']}],
+          text: rt.text,
+          facets: rt.facets,
+          outlineTags: [],
+          isOwnPost: false,
+        })
+
+        expect(match).toBe(true)
+      })
+
+      it(`match: superbad`, () => {
+        const match = hasMutedWord({
+          mutedWords: [{value: `superbad`, targets: ['content']}],
+          text: rt.text,
+          facets: rt.facets,
+          outlineTags: [],
+          isOwnPost: false,
+        })
+
+        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({
+          mutedWords: [{value: `idk what this would be`, targets: ['content']}],
+          text: rt.text,
+          facets: rt.facets,
+          outlineTags: [],
+          isOwnPost: false,
+        })
+
+        expect(match).toBe(true)
+      })
+
+      it(`no match: idk what this would be for`, () => {
+        // extra word
+        const match = hasMutedWord({
+          mutedWords: [
+            {value: `idk what this would be for`, targets: ['content']},
+          ],
+          text: rt.text,
+          facets: rt.facets,
+          outlineTags: [],
+          isOwnPost: false,
+        })
+
+        expect(match).toBe(false)
+      })
+
+      it(`match: idk`, () => {
+        // extra word
+        const match = hasMutedWord({
+          mutedWords: [{value: `idk`, targets: ['content']}],
+          text: rt.text,
+          facets: rt.facets,
+          outlineTags: [],
+          isOwnPost: false,
+        })
+
+        expect(match).toBe(true)
+      })
+
+      it(`match: idkwhatthiswouldbe`, () => {
+        const match = hasMutedWord({
+          mutedWords: [{value: `idkwhatthiswouldbe`, targets: ['content']}],
+          text: rt.text,
+          facets: rt.facets,
+          outlineTags: [],
+          isOwnPost: false,
+        })
+
+        expect(match).toBe(false)
+      })
+    })
+
+    describe(`parentheses`, () => {
+      const rt = new RichText({
+        text: `Post with context(iykyk)`,
+      })
+      rt.detectFacetsWithoutResolution()
+
+      it(`match: context(iykyk)`, () => {
+        const match = hasMutedWord({
+          mutedWords: [{value: `context(iykyk)`, targets: ['content']}],
+          text: rt.text,
+          facets: rt.facets,
+          outlineTags: [],
+          isOwnPost: false,
+        })
+
+        expect(match).toBe(true)
+      })
+
+      it(`match: context`, () => {
+        const match = hasMutedWord({
+          mutedWords: [{value: `context`, targets: ['content']}],
+          text: rt.text,
+          facets: rt.facets,
+          outlineTags: [],
+          isOwnPost: false,
+        })
+
+        expect(match).toBe(true)
+      })
+
+      it(`match: iykyk`, () => {
+        const match = hasMutedWord({
+          mutedWords: [{value: `iykyk`, targets: ['content']}],
+          text: rt.text,
+          facets: rt.facets,
+          outlineTags: [],
+          isOwnPost: false,
+        })
+
+        expect(match).toBe(true)
+      })
+
+      it(`match: (iykyk)`, () => {
+        const match = hasMutedWord({
+          mutedWords: [{value: `(iykyk)`, targets: ['content']}],
+          text: rt.text,
+          facets: rt.facets,
+          outlineTags: [],
+          isOwnPost: false,
+        })
+
+        expect(match).toBe(true)
+      })
+    })
+
+    describe(`🦋`, () => {
+      const rt = new RichText({
+        text: `Post with 🦋`,
+      })
+      rt.detectFacetsWithoutResolution()
+
+      it(`match: 🦋`, () => {
+        const match = hasMutedWord({
+          mutedWords: [{value: `🦋`, targets: ['content']}],
+          text: rt.text,
+          facets: rt.facets,
+          outlineTags: [],
+          isOwnPost: false,
+        })
+
+        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({
+          mutedWords: [{value: 'stop worrying', targets: ['content']}],
+          text: rt.text,
+          facets: rt.facets,
+          outlineTags: [],
+          isOwnPost: false,
+        })
+
+        expect(match).toBe(true)
+      })
+
+      it(`match: turtles, or how`, () => {
+        const match = hasMutedWord({
+          mutedWords: [{value: 'turtles, or how', targets: ['content']}],
+          text: rt.text,
+          facets: rt.facets,
+          outlineTags: [],
+          isOwnPost: false,
+        })
+
+        expect(match).toBe(true)
+      })
+    })
+  })
+
+  describe(`languages without spaces`, () => {
+    // I love turtles, or how I learned to stop worrying and love the internet
+    describe(`私はカメが好きです、またはどのようにして心配するのをやめてインターネットを愛するようになったのか`, () => {
+      const rt = new RichText({
+        text: `私はカメが好きです、またはどのようにして心配するのをやめてインターネットを愛するようになったのか`,
+      })
+      rt.detectFacetsWithoutResolution()
+
+      // internet
+      it(`match: インターネット`, () => {
+        const match = hasMutedWord({
+          mutedWords: [{value: 'インターネット', targets: ['content']}],
+          text: rt.text,
+          facets: rt.facets,
+          outlineTags: [],
+          languages: ['ja'],
+          isOwnPost: false,
+        })
+
+        expect(match).toBe(true)
+      })
+    })
+  })
+
+  describe(`doesn't mute own post`, () => {
+    it(`does mute if it isn't own post`, () => {
+      const rt = new RichText({
+        text: `Mute words!`,
+      })
+
+      const match = hasMutedWord({
+        mutedWords: [{value: 'words', targets: ['content']}],
+        text: rt.text,
+        facets: rt.facets,
+        outlineTags: [],
+        isOwnPost: false,
+      })
+
+      expect(match).toBe(true)
+    })
+
+    it(`doesn't mute own post when muted word is in text`, () => {
+      const rt = new RichText({
+        text: `Mute words!`,
+      })
+
+      const match = hasMutedWord({
+        mutedWords: [{value: 'words', targets: ['content']}],
+        text: rt.text,
+        facets: rt.facets,
+        outlineTags: [],
+        isOwnPost: true,
+      })
+
+      expect(match).toBe(false)
+    })
+
+    it(`doesn't mute own post when muted word is in tags`, () => {
+      const rt = new RichText({
+        text: `Mute #words!`,
+      })
+
+      const match = hasMutedWord({
+        mutedWords: [{value: 'words', targets: ['tags']}],
+        text: rt.text,
+        facets: rt.facets,
+        outlineTags: [],
+        isOwnPost: true,
+      })
+
+      expect(match).toBe(false)
+    })
+  })
+})
diff --git a/src/lib/api/index.ts b/src/lib/api/index.ts
index 440dfa5ee..5fb7fe50e 100644
--- a/src/lib/api/index.ts
+++ b/src/lib/api/index.ts
@@ -104,18 +104,18 @@ export async function post(agent: BskyAgent, opts: PostOpts) {
 
   // add image embed if present
   if (opts.images?.length) {
-    logger.info(`Uploading images`, {
+    logger.debug(`Uploading images`, {
       count: opts.images.length,
     })
 
     const images: AppBskyEmbedImages.Image[] = []
     for (const image of opts.images) {
       opts.onStateChange?.(`Uploading image #${images.length + 1}...`)
-      logger.info(`Compressing image`)
+      logger.debug(`Compressing image`)
       await image.compress()
       const path = image.compressed?.path ?? image.path
       const {width, height} = image.compressed || image
-      logger.info(`Uploading image`)
+      logger.debug(`Uploading image`)
       const res = await uploadBlob(agent, path, 'image/jpeg')
       images.push({
         image: res.data.blob,
diff --git a/src/lib/constants.ts b/src/lib/constants.ts
index c8e5273d4..e86844395 100644
--- a/src/lib/constants.ts
+++ b/src/lib/constants.ts
@@ -75,3 +75,9 @@ export const HITSLOP_20 = createHitslop(20)
 export const HITSLOP_30 = createHitslop(30)
 export const BACK_HITSLOP = HITSLOP_30
 export const MAX_POST_LINES = 25
+
+export const BSKY_FEED_OWNER_DIDS = [
+  'did:plc:z72i7hdynmk6r22z27h6tvur',
+  'did:plc:vpkhqolt662uhesyj6nxm7ys',
+  'did:plc:q6gjnaw2blty4crticxkmujt',
+]
diff --git a/src/lib/hooks/useIntentHandler.ts b/src/lib/hooks/useIntentHandler.ts
new file mode 100644
index 000000000..d1e2de31d
--- /dev/null
+++ b/src/lib/hooks/useIntentHandler.ts
@@ -0,0 +1,91 @@
+import React from 'react'
+import * as Linking from 'expo-linking'
+import {isNative} from 'platform/detection'
+import {useComposerControls} from 'state/shell'
+import {useSession} from 'state/session'
+import {useCloseAllActiveElements} from 'state/util'
+
+type IntentType = 'compose'
+
+const VALID_IMAGE_REGEX = /^[\w.:\-_/]+\|\d+(\.\d+)?\|\d+(\.\d+)?$/
+
+export function useIntentHandler() {
+  const incomingUrl = Linking.useURL()
+  const composeIntent = useComposeIntent()
+
+  React.useEffect(() => {
+    const handleIncomingURL = (url: string) => {
+      const urlp = new URL(url)
+      const [_, intentTypeNative, intentTypeWeb] = urlp.pathname.split('/')
+
+      // On native, our links look like bluesky://intent/SomeIntent, so we have to check the hostname for the
+      // intent check. On web, we have to check the first part of the path since we have an actual hostname
+      const intentType = isNative ? intentTypeNative : intentTypeWeb
+      const isIntent = isNative
+        ? urlp.hostname === 'intent'
+        : intentTypeNative === 'intent'
+      const params = urlp.searchParams
+
+      if (!isIntent) return
+
+      switch (intentType as IntentType) {
+        case 'compose': {
+          composeIntent({
+            text: params.get('text'),
+            imageUrisStr: params.get('imageUris'),
+          })
+        }
+      }
+    }
+
+    if (incomingUrl) handleIncomingURL(incomingUrl)
+  }, [incomingUrl, composeIntent])
+}
+
+function useComposeIntent() {
+  const closeAllActiveElements = useCloseAllActiveElements()
+  const {openComposer} = useComposerControls()
+  const {hasSession} = useSession()
+
+  return React.useCallback(
+    ({
+      text,
+      imageUrisStr,
+    }: {
+      text: string | null
+      imageUrisStr: string | null // unused for right now, will be used later with intents
+    }) => {
+      if (!hasSession) return
+
+      closeAllActiveElements()
+
+      const imageUris = imageUrisStr
+        ?.split(',')
+        .filter(part => {
+          // For some security, we're going to filter out any image uri that is external. We don't want someone to
+          // be able to provide some link like "bluesky://intent/compose?imageUris=https://IHaveYourIpNow.com/image.jpeg
+          // and we load that image
+          if (part.includes('https://') || part.includes('http://')) {
+            return false
+          }
+          // We also should just filter out cases that don't have all the info we need
+          if (!VALID_IMAGE_REGEX.test(part)) {
+            return false
+          }
+          return true
+        })
+        .map(part => {
+          const [uri, width, height] = part.split('|')
+          return {uri, width: Number(width), height: Number(height)}
+        })
+
+      setTimeout(() => {
+        openComposer({
+          text: text ?? undefined,
+          imageUris: isNative ? imageUris : undefined,
+        })
+      }, 500)
+    },
+    [hasSession, closeAllActiveElements, openComposer],
+  )
+}
diff --git a/src/lib/moderatePost_wrapped.ts b/src/lib/moderatePost_wrapped.ts
index 2195b2304..92543b42c 100644
--- a/src/lib/moderatePost_wrapped.ts
+++ b/src/lib/moderatePost_wrapped.ts
@@ -2,19 +2,151 @@ import {
   AppBskyEmbedRecord,
   AppBskyEmbedRecordWithMedia,
   moderatePost,
+  AppBskyActorDefs,
+  AppBskyFeedPost,
+  AppBskyRichtextFacet,
+  AppBskyEmbedImages,
 } from '@atproto/api'
 
 type ModeratePost = typeof moderatePost
 type Options = Parameters<ModeratePost>[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,
+}
+
+/**
+ * List of 2-letter lang codes for languages that either don't use spaces, or
+ * don't use spaces in a way conducive to word-based filtering.
+ *
+ * For these, we use a simple `String.includes` to check for a match.
+ */
+const LANGUAGE_EXCEPTIONS = [
+  'ja', // Japanese
+  'zh', // Chinese
+  'ko', // Korean
+  'th', // Thai
+  'vi', // Vietnamese
+]
+
+export function hasMutedWord({
+  mutedWords,
+  text,
+  facets,
+  outlineTags,
+  languages,
+  isOwnPost,
+}: {
+  mutedWords: AppBskyActorDefs.MutedWord[]
+  text: string
+  facets?: AppBskyRichtextFacet.Main[]
+  outlineTags?: string[]
+  languages?: string[]
+  isOwnPost: boolean
+}) {
+  if (isOwnPost) return false
+
+  const exception = LANGUAGE_EXCEPTIONS.includes(languages?.[0] || '')
+  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 or other exception, has to use includes
+    if ((mutedWord.length === 1 || exception) && 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<ModeratePost>[0],
   opts: Options,
 ) {
-  const {hiddenPosts = [], ...options} = opts
+  const {hiddenPosts = [], mutedWords = [], ...options} = opts
   const moderations = moderatePost(subject, options)
+  const isOwnPost = subject.author.did === opts.userDid
 
   if (hiddenPosts.includes(subject.uri)) {
     moderations.content.filter = true
@@ -29,15 +161,86 @@ export function moderatePost_wrapped(
     }
   }
 
+  if (AppBskyFeedPost.isRecord(subject.record)) {
+    let muted = hasMutedWord({
+      mutedWords,
+      text: subject.record.text,
+      facets: subject.record.facets || [],
+      outlineTags: subject.record.tags || [],
+      languages: subject.record.langs,
+      isOwnPost,
+    })
+
+    if (
+      subject.record.embed &&
+      AppBskyEmbedImages.isMain(subject.record.embed)
+    ) {
+      for (const image of subject.record.embed.images) {
+        muted =
+          muted ||
+          hasMutedWord({
+            mutedWords,
+            text: image.alt,
+            facets: [],
+            outlineTags: [],
+            languages: subject.record.langs,
+            isOwnPost,
+          })
+      }
+    }
+
+    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,
+            text: subject.embed.record.value.text,
+            facets: subject.embed.record.value.facets,
+            outlineTags: subject.embed.record.value.tags,
+            languages: subject.embed.record.value.langs,
+            isOwnPost,
+          })
+
+        if (AppBskyEmbedImages.isMain(subject.embed.record.value.embed)) {
+          for (const image of subject.embed.record.value.embed.images) {
+            embedHidden =
+              embedHidden ||
+              hasMutedWord({
+                mutedWords,
+                text: image.alt,
+                facets: [],
+                outlineTags: [],
+                languages: subject.embed.record.value.langs,
+                isOwnPost,
+              })
+          }
+        }
+      }
     }
     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 bf19c208a..b6ebb47a0 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 538f30cd3..9dfdab909 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 0fb36fa7c..0ec09f610 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/lib/strings/helpers.ts b/src/lib/strings/helpers.ts
index e2abe9019..de4562d2c 100644
--- a/src/lib/strings/helpers.ts
+++ b/src/lib/strings/helpers.ts
@@ -8,10 +8,27 @@ export function pluralize(n: number, base: string, plural?: string): string {
   return base + 's'
 }
 
-export function enforceLen(str: string, len: number, ellipsis = false): string {
+export function enforceLen(
+  str: string,
+  len: number,
+  ellipsis = false,
+  mode: 'end' | 'middle' = 'end',
+): string {
   str = str || ''
   if (str.length > len) {
-    return str.slice(0, len) + (ellipsis ? '...' : '')
+    if (ellipsis) {
+      if (mode === 'end') {
+        return str.slice(0, len) + '…'
+      } else if (mode === 'middle') {
+        const half = Math.floor(len / 2)
+        return str.slice(0, half) + '…' + str.slice(-half)
+      } else {
+        // fallback
+        return str.slice(0, len)
+      }
+    } else {
+      return str.slice(0, len)
+    }
   }
   return str
 }