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/constants.ts4
-rw-r--r--src/lib/moderatePost_wrapped.ts384
-rw-r--r--src/lib/moderation.ts196
-rw-r--r--src/lib/moderation/useGlobalLabelStrings.ts52
-rw-r--r--src/lib/moderation/useLabelBehaviorDescription.ts70
-rw-r--r--src/lib/moderation/useLabelInfo.ts100
-rw-r--r--src/lib/moderation/useModerationCauseDescription.ts146
-rw-r--r--src/lib/moderation/useReportOptions.ts94
-rw-r--r--src/lib/notifications/notifications.ts2
-rw-r--r--src/lib/react-query.ts20
-rw-r--r--src/lib/routes/types.ts2
-rw-r--r--src/lib/statsig/events.ts3
-rw-r--r--src/lib/statsig/statsig.tsx20
-rw-r--r--src/lib/strings/display-names.ts3
-rw-r--r--src/lib/strings/embed-player.ts13
-rw-r--r--src/lib/strings/url-helpers.ts38
-rw-r--r--src/lib/themes.ts2
18 files changed, 632 insertions, 1209 deletions
diff --git a/src/lib/__tests__/moderatePost_wrapped.test.ts b/src/lib/__tests__/moderatePost_wrapped.test.ts
deleted file mode 100644
index 45566281a..000000000
--- a/src/lib/__tests__/moderatePost_wrapped.test.ts
+++ /dev/null
@@ -1,692 +0,0 @@
-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/constants.ts b/src/lib/constants.ts
index e86844395..f5a72669a 100644
--- a/src/lib/constants.ts
+++ b/src/lib/constants.ts
@@ -35,6 +35,10 @@ export const MAX_GRAPHEME_LENGTH = 300
 // but increasing limit per user feedback
 export const MAX_ALT_TEXT = 1000
 
+export function IS_TEST_USER(handle?: string) {
+  return handle && handle?.endsWith('.test')
+}
+
 export function IS_PROD_SERVICE(url?: string) {
   return url && url !== STAGING_SERVICE && url !== LOCAL_DEV_SERVICE
 }
diff --git a/src/lib/moderatePost_wrapped.ts b/src/lib/moderatePost_wrapped.ts
index 9f6fa9c07..0ce01368a 100644
--- a/src/lib/moderatePost_wrapped.ts
+++ b/src/lib/moderatePost_wrapped.ts
@@ -1,380 +1,30 @@
-import {
-  AppBskyEmbedRecord,
-  AppBskyEmbedRecordWithMedia,
-  moderatePost,
-  AppBskyActorDefs,
-  AppBskyFeedPost,
-  AppBskyRichtextFacet,
-  AppBskyEmbedImages,
-  AppBskyEmbedExternal,
-} from '@atproto/api'
+import {moderatePost, BSKY_LABELER_DID} 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
-}
+type Options = Parameters<ModeratePost>[1]
 
 export function moderatePost_wrapped(
   subject: Parameters<ModeratePost>[0],
   opts: Options,
 ) {
-  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
-    moderations.content.blur = true
-    if (!moderations.content.cause) {
-      moderations.content.cause = {
-        // @ts-ignore Temporary extension to the moderation system -prf
-        type: 'post-hidden',
-        source: {type: 'user'},
-        priority: 1,
-      }
-    }
-  }
+  // HACK
+  // temporarily translate 'gore' into 'graphic-media' during the transition period
+  // can remove this in a few months
+  // -prf
+  translateOldLabels(subject)
 
-  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
-    let embedMuted = false
-    let externalMuted = false
-
-    if (AppBskyEmbedRecord.isViewRecord(subject.embed.record)) {
-      embedHidden = hiddenPosts.includes(subject.embed.record.uri)
-    }
-    if (
-      AppBskyEmbedRecordWithMedia.isView(subject.embed) &&
-      AppBskyEmbedRecord.isViewRecord(subject.embed.record.record)
-    ) {
-      embedHidden = hiddenPosts.includes(subject.embed.record.record.uri)
-    }
-
-    if (AppBskyEmbedRecord.isViewRecord(subject.embed.record)) {
-      if (AppBskyFeedPost.isRecord(subject.embed.record.value)) {
-        const embeddedPost = subject.embed.record.value
-
-        embedMuted =
-          embedMuted ||
-          hasMutedWord({
-            mutedWords,
-            text: embeddedPost.text,
-            facets: embeddedPost.facets,
-            outlineTags: embeddedPost.tags,
-            languages: embeddedPost.langs,
-            isOwnPost,
-          })
-
-        if (AppBskyEmbedImages.isMain(embeddedPost.embed)) {
-          for (const image of embeddedPost.embed.images) {
-            embedMuted =
-              embedMuted ||
-              hasMutedWord({
-                mutedWords,
-                text: image.alt,
-                facets: [],
-                outlineTags: [],
-                languages: embeddedPost.langs,
-                isOwnPost,
-              })
-          }
-        }
-
-        if (AppBskyEmbedExternal.isMain(embeddedPost.embed)) {
-          const {external} = embeddedPost.embed
-
-          embedMuted =
-            embedMuted ||
-            hasMutedWord({
-              mutedWords,
-              text: external.title + ' ' + external.description,
-              facets: [],
-              outlineTags: [],
-              languages: [],
-              isOwnPost,
-            })
-        }
-
-        if (AppBskyEmbedRecordWithMedia.isMain(embeddedPost.embed)) {
-          if (AppBskyEmbedExternal.isMain(embeddedPost.embed.media)) {
-            const {external} = embeddedPost.embed.media
-
-            embedMuted =
-              embedMuted ||
-              hasMutedWord({
-                mutedWords,
-                text: external.title + ' ' + external.description,
-                facets: [],
-                outlineTags: [],
-                languages: [],
-                isOwnPost,
-              })
-          }
-
-          if (AppBskyEmbedImages.isMain(embeddedPost.embed.media)) {
-            for (const image of embeddedPost.embed.media.images) {
-              embedMuted =
-                embedMuted ||
-                hasMutedWord({
-                  mutedWords,
-                  text: image.alt,
-                  facets: [],
-                  outlineTags: [],
-                  languages: AppBskyFeedPost.isRecord(embeddedPost.record)
-                    ? embeddedPost.langs
-                    : [],
-                  isOwnPost,
-                })
-            }
-          }
-        }
-      }
-    }
-
-    if (AppBskyEmbedExternal.isView(subject.embed)) {
-      const {external} = subject.embed
-
-      externalMuted =
-        externalMuted ||
-        hasMutedWord({
-          mutedWords,
-          text: external.title + ' ' + external.description,
-          facets: [],
-          outlineTags: [],
-          languages: [],
-          isOwnPost,
-        })
-    }
-
-    if (
-      AppBskyEmbedRecordWithMedia.isView(subject.embed) &&
-      AppBskyEmbedRecord.isViewRecord(subject.embed.record.record)
-    ) {
-      if (AppBskyFeedPost.isRecord(subject.embed.record.record.value)) {
-        const post = subject.embed.record.record.value
-        embedMuted =
-          embedMuted ||
-          hasMutedWord({
-            mutedWords,
-            text: post.text,
-            facets: post.facets,
-            outlineTags: post.tags,
-            languages: post.langs,
-            isOwnPost,
-          })
-      }
-
-      if (AppBskyEmbedImages.isView(subject.embed.media)) {
-        for (const image of subject.embed.media.images) {
-          embedMuted =
-            embedMuted ||
-            hasMutedWord({
-              mutedWords,
-              text: image.alt,
-              facets: [],
-              outlineTags: [],
-              languages: AppBskyFeedPost.isRecord(subject.record)
-                ? subject.record.langs
-                : [],
-              isOwnPost,
-            })
-        }
-      }
-    }
+  return moderatePost(subject, opts)
+}
 
-    if (embedHidden) {
-      moderations.embed.filter = true
-      moderations.embed.blur = true
-      if (!moderations.embed.cause) {
-        moderations.embed.cause = {
-          // @ts-ignore Temporary extension to the moderation system -prf
-          type: 'post-hidden',
-          source: {type: 'user'},
-          priority: 1,
-        }
-      }
-    } else if (externalMuted || embedMuted) {
-      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,
-        }
+function translateOldLabels(subject: Parameters<ModeratePost>[0]) {
+  if (subject.labels) {
+    for (const label of subject.labels) {
+      if (
+        label.val === 'gore' &&
+        (!label.src || label.src === BSKY_LABELER_DID)
+      ) {
+        label.val = 'graphic-media'
       }
     }
   }
-
-  return moderations
 }
diff --git a/src/lib/moderation.ts b/src/lib/moderation.ts
index b6ebb47a0..4105c2c2d 100644
--- a/src/lib/moderation.ts
+++ b/src/lib/moderation.ts
@@ -1,149 +1,81 @@
-import {ModerationCause, ProfileModeration, PostModeration} from '@atproto/api'
+import {
+  ModerationCause,
+  ModerationUI,
+  InterpretedLabelValueDefinition,
+  LABELS,
+  AppBskyLabelerDefs,
+  BskyAgent,
+  ModerationOpts,
+} from '@atproto/api'
 
-export interface ModerationCauseDescription {
-  name: string
-  description: string
-}
+import {sanitizeDisplayName} from '#/lib/strings/display-names'
+import {sanitizeHandle} from '#/lib/strings/handles'
 
-export function describeModerationCause(
-  cause: ModerationCause | undefined,
-  context: 'account' | 'content',
-): ModerationCauseDescription {
-  if (!cause) {
-    return {
-      name: 'Content Warning',
-      description:
-        'Moderator has chosen to set a general warning on the content.',
-    }
-  }
-  if (cause.type === 'blocking') {
-    if (cause.source.type === 'list') {
-      return {
-        name: `User Blocked by "${cause.source.list.name}"`,
-        description:
-          'You have blocked this user. You cannot view their content.',
-      }
-    } else {
-      return {
-        name: 'User Blocked',
-        description:
-          'You have blocked this user. You cannot view their content.',
-      }
-    }
-  }
-  if (cause.type === 'blocked-by') {
-    return {
-      name: 'User Blocking You',
-      description: 'This user has blocked you. You cannot view their content.',
-    }
-  }
-  if (cause.type === 'block-other') {
-    return {
-      name: 'Content Not Available',
-      description:
-        'This content is not available because one of the users involved has blocked the other.',
-    }
-  }
-  if (cause.type === 'muted') {
-    if (cause.source.type === 'list') {
-      return {
-        name:
-          context === 'account'
-            ? `Muted by "${cause.source.list.name}"`
-            : `Post by muted user ("${cause.source.list.name}")`,
-        description: 'You have muted this user',
-      }
-    } else {
-      return {
-        name: context === 'account' ? 'Muted User' : 'Post by muted user',
-        description: 'You have muted this user',
-      }
-    }
-  }
-  // @ts-ignore Temporary extension to the moderation system -prf
-  if (cause.type === 'post-hidden') {
-    return {
-      name: 'Post Hidden by You',
-      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.`,
-    }
+export function getModerationCauseKey(cause: ModerationCause): string {
+  const source =
+    cause.source.type === 'labeler'
+      ? cause.source.did
+      : cause.source.type === 'list'
+      ? cause.source.list.uri
+      : 'user'
+  if (cause.type === 'label') {
+    return `label:${cause.label.val}:${source}`
   }
-  return cause.labelDef.strings[context].en
+  return `${cause.type}:${source}`
 }
 
-export function getProfileModerationCauses(
-  moderation: ProfileModeration,
-): ModerationCause[] {
-  /*
-  Gather everything on profile and account that blurs or alerts
-  */
-  return [
-    moderation.decisions.profile.cause,
-    ...moderation.decisions.profile.additionalCauses,
-    moderation.decisions.account.cause,
-    ...moderation.decisions.account.additionalCauses,
-  ].filter(cause => {
-    if (!cause) {
-      return false
-    }
-    if (cause?.type === 'label') {
-      if (
-        cause.labelDef.onwarn === 'blur' ||
-        cause.labelDef.onwarn === 'alert'
-      ) {
-        return true
-      } else {
-        return false
-      }
-    }
-    return true
-  }) as ModerationCause[]
+export function isJustAMute(modui: ModerationUI): boolean {
+  return modui.filters.length === 1 && modui.filters[0].type === 'muted'
 }
 
-export function isPostMediaBlurred(
-  decisions: PostModeration['decisions'],
-): boolean {
-  return decisions.post.blurMedia
+export function getLabelingServiceTitle({
+  displayName,
+  handle,
+}: {
+  displayName?: string
+  handle: string
+}) {
+  return displayName
+    ? sanitizeDisplayName(displayName)
+    : sanitizeHandle(handle, '@')
 }
 
-export function isQuoteBlurred(
-  decisions: PostModeration['decisions'],
-): boolean {
-  return (
-    decisions.quote?.blur ||
-    decisions.quote?.blurMedia ||
-    decisions.quote?.filter ||
-    decisions.quotedAccount?.blur ||
-    decisions.quotedAccount?.filter ||
-    false
-  )
+export function lookupLabelValueDefinition(
+  labelValue: string,
+  customDefs: InterpretedLabelValueDefinition[] | undefined,
+): InterpretedLabelValueDefinition | undefined {
+  let def
+  if (!labelValue.startsWith('!') && customDefs) {
+    def = customDefs.find(d => d.identifier === labelValue)
+  }
+  if (!def) {
+    def = LABELS[labelValue as keyof typeof LABELS]
+  }
+  return def
 }
 
-export function isCauseALabelOnUri(
-  cause: ModerationCause | undefined,
-  uri: string,
+export function isAppLabeler(
+  labeler:
+    | string
+    | AppBskyLabelerDefs.LabelerView
+    | AppBskyLabelerDefs.LabelerViewDetailed,
 ): boolean {
-  if (cause?.type !== 'label') {
-    return false
+  if (typeof labeler === 'string') {
+    return BskyAgent.appLabelers.includes(labeler)
   }
-  return cause.label.uri === uri
+  return BskyAgent.appLabelers.includes(labeler.creator.did)
 }
 
-export function getModerationCauseKey(cause: ModerationCause): string {
-  const source =
-    cause.source.type === 'labeler'
-      ? cause.source.labeler.did
-      : cause.source.type === 'list'
-      ? cause.source.list.uri
-      : 'user'
-  if (cause.type === 'label') {
-    return `label:${cause.label.val}:${source}`
+export function isLabelerSubscribed(
+  labeler:
+    | string
+    | AppBskyLabelerDefs.LabelerView
+    | AppBskyLabelerDefs.LabelerViewDetailed,
+  modOpts: ModerationOpts,
+) {
+  labeler = typeof labeler === 'string' ? labeler : labeler.creator.did
+  if (isAppLabeler(labeler)) {
+    return true
   }
-  return `${cause.type}:${source}`
+  return modOpts.prefs.labelers.find(l => l.did === labeler)
 }
diff --git a/src/lib/moderation/useGlobalLabelStrings.ts b/src/lib/moderation/useGlobalLabelStrings.ts
new file mode 100644
index 000000000..1c5a48231
--- /dev/null
+++ b/src/lib/moderation/useGlobalLabelStrings.ts
@@ -0,0 +1,52 @@
+import {msg} from '@lingui/macro'
+import {useLingui} from '@lingui/react'
+import {useMemo} from 'react'
+
+export type GlobalLabelStrings = Record<
+  string,
+  {
+    name: string
+    description: string
+  }
+>
+
+export function useGlobalLabelStrings(): GlobalLabelStrings {
+  const {_} = useLingui()
+  return useMemo(
+    () => ({
+      '!hide': {
+        name: _(msg`Content Blocked`),
+        description: _(msg`This content has been hidden by the moderators.`),
+      },
+      '!warn': {
+        name: _(msg`Content Warning`),
+        description: _(
+          msg`This content has received a general warning from moderators.`,
+        ),
+      },
+      '!no-unauthenticated': {
+        name: _(msg`Sign-in Required`),
+        description: _(
+          msg`This user has requested that their content only be shown to signed-in users.`,
+        ),
+      },
+      porn: {
+        name: _(msg`Pornography`),
+        description: _(msg`Explicit sexual images.`),
+      },
+      sexual: {
+        name: _(msg`Sexually Suggestive`),
+        description: _(msg`Does not include nudity.`),
+      },
+      nudity: {
+        name: _(msg`Non-sexual Nudity`),
+        description: _(msg`E.g. artistic nudes.`),
+      },
+      'graphic-media': {
+        name: _(msg`Graphic Media`),
+        description: _(msg`Explicit or potentially disturbing media.`),
+      },
+    }),
+    [_],
+  )
+}
diff --git a/src/lib/moderation/useLabelBehaviorDescription.ts b/src/lib/moderation/useLabelBehaviorDescription.ts
new file mode 100644
index 000000000..0250c1bc8
--- /dev/null
+++ b/src/lib/moderation/useLabelBehaviorDescription.ts
@@ -0,0 +1,70 @@
+import {InterpretedLabelValueDefinition, LabelPreference} from '@atproto/api'
+import {useLingui} from '@lingui/react'
+import {msg} from '@lingui/macro'
+
+export function useLabelBehaviorDescription(
+  labelValueDef: InterpretedLabelValueDefinition,
+  pref: LabelPreference,
+) {
+  const {_} = useLingui()
+  if (pref === 'ignore') {
+    return _(msg`Off`)
+  }
+  if (labelValueDef.blurs === 'content' || labelValueDef.blurs === 'media') {
+    if (pref === 'hide') {
+      return _(msg`Hide`)
+    }
+    return _(msg`Warn`)
+  } else if (labelValueDef.severity === 'alert') {
+    if (pref === 'hide') {
+      return _(msg`Hide`)
+    }
+    return _(msg`Warn`)
+  } else if (labelValueDef.severity === 'inform') {
+    if (pref === 'hide') {
+      return _(msg`Hide`)
+    }
+    return _(msg`Show badge`)
+  } else {
+    if (pref === 'hide') {
+      return _(msg`Hide`)
+    }
+    return _(msg`Disabled`)
+  }
+}
+
+export function useLabelLongBehaviorDescription(
+  labelValueDef: InterpretedLabelValueDefinition,
+  pref: LabelPreference,
+) {
+  const {_} = useLingui()
+  if (pref === 'ignore') {
+    return _(msg`Disabled`)
+  }
+  if (labelValueDef.blurs === 'content') {
+    if (pref === 'hide') {
+      return _(msg`Warn content and filter from feeds`)
+    }
+    return _(msg`Warn content`)
+  } else if (labelValueDef.blurs === 'media') {
+    if (pref === 'hide') {
+      return _(msg`Blur images and filter from feeds`)
+    }
+    return _(msg`Blur images`)
+  } else if (labelValueDef.severity === 'alert') {
+    if (pref === 'hide') {
+      return _(msg`Show warning and filter from feeds`)
+    }
+    return _(msg`Show warning`)
+  } else if (labelValueDef.severity === 'inform') {
+    if (pref === 'hide') {
+      return _(msg`Show badge and filter from feeds`)
+    }
+    return _(msg`Show badge`)
+  } else {
+    if (pref === 'hide') {
+      return _(msg`Filter from feeds`)
+    }
+    return _(msg`Disabled`)
+  }
+}
diff --git a/src/lib/moderation/useLabelInfo.ts b/src/lib/moderation/useLabelInfo.ts
new file mode 100644
index 000000000..b1cffe1e7
--- /dev/null
+++ b/src/lib/moderation/useLabelInfo.ts
@@ -0,0 +1,100 @@
+import {
+  ComAtprotoLabelDefs,
+  AppBskyLabelerDefs,
+  LABELS,
+  interpretLabelValueDefinition,
+  InterpretedLabelValueDefinition,
+} from '@atproto/api'
+import {useLingui} from '@lingui/react'
+import * as bcp47Match from 'bcp-47-match'
+
+import {
+  GlobalLabelStrings,
+  useGlobalLabelStrings,
+} from '#/lib/moderation/useGlobalLabelStrings'
+import {useLabelDefinitions} from '#/state/preferences'
+
+export interface LabelInfo {
+  label: ComAtprotoLabelDefs.Label
+  def: InterpretedLabelValueDefinition
+  strings: ComAtprotoLabelDefs.LabelValueDefinitionStrings
+  labeler: AppBskyLabelerDefs.LabelerViewDetailed | undefined
+}
+
+export function useLabelInfo(label: ComAtprotoLabelDefs.Label): LabelInfo {
+  const {i18n} = useLingui()
+  const {labelDefs, labelers} = useLabelDefinitions()
+  const globalLabelStrings = useGlobalLabelStrings()
+  const def = getDefinition(labelDefs, label)
+  return {
+    label,
+    def,
+    strings: getLabelStrings(i18n.locale, globalLabelStrings, def),
+    labeler: labelers.find(labeler => label.src === labeler.creator.did),
+  }
+}
+
+export function getDefinition(
+  labelDefs: Record<string, InterpretedLabelValueDefinition[]>,
+  label: ComAtprotoLabelDefs.Label,
+): InterpretedLabelValueDefinition {
+  // check local definitions
+  const customDef =
+    !label.val.startsWith('!') &&
+    labelDefs[label.src]?.find(
+      def => def.identifier === label.val && def.definedBy === label.src,
+    )
+  if (customDef) {
+    return customDef
+  }
+
+  // check global definitions
+  const globalDef = LABELS[label.val as keyof typeof LABELS]
+  if (globalDef) {
+    return globalDef
+  }
+
+  // fallback to a noop definition
+  return interpretLabelValueDefinition(
+    {
+      identifier: label.val,
+      severity: 'none',
+      blurs: 'none',
+      defaultSetting: 'ignore',
+      locales: [],
+    },
+    label.src,
+  )
+}
+
+export function getLabelStrings(
+  locale: string,
+  globalLabelStrings: GlobalLabelStrings,
+  def: InterpretedLabelValueDefinition,
+): ComAtprotoLabelDefs.LabelValueDefinitionStrings {
+  if (!def.definedBy) {
+    // global definition, look up strings
+    if (def.identifier in globalLabelStrings) {
+      return globalLabelStrings[
+        def.identifier
+      ] as ComAtprotoLabelDefs.LabelValueDefinitionStrings
+    }
+  } else {
+    // try to find locale match in the definition's strings
+    const localeMatch = def.locales.find(
+      strings => bcp47Match.basicFilter(locale, strings.lang).length > 0,
+    )
+    if (localeMatch) {
+      return localeMatch
+    }
+    // fall back to the zero item if no match
+    if (def.locales[0]) {
+      return def.locales[0]
+    }
+  }
+  return {
+    lang: locale,
+    name: def.identifier,
+    description: `Labeled "${def.identifier}"`,
+  }
+}
diff --git a/src/lib/moderation/useModerationCauseDescription.ts b/src/lib/moderation/useModerationCauseDescription.ts
new file mode 100644
index 000000000..46771e958
--- /dev/null
+++ b/src/lib/moderation/useModerationCauseDescription.ts
@@ -0,0 +1,146 @@
+import React from 'react'
+import {
+  BSKY_LABELER_DID,
+  ModerationCause,
+  ModerationCauseSource,
+} from '@atproto/api'
+import {msg} from '@lingui/macro'
+import {useLingui} from '@lingui/react'
+import {getDefinition, getLabelStrings} from './useLabelInfo'
+import {useLabelDefinitions} from '#/state/preferences'
+import {useGlobalLabelStrings} from './useGlobalLabelStrings'
+
+import {Props as SVGIconProps} from '#/components/icons/common'
+import {Warning_Stroke2_Corner0_Rounded as Warning} from '#/components/icons/Warning'
+import {CircleInfo_Stroke2_Corner0_Rounded as CircleInfo} from '#/components/icons/CircleInfo'
+import {EyeSlash_Stroke2_Corner0_Rounded as EyeSlash} from '#/components/icons/EyeSlash'
+import {CircleBanSign_Stroke2_Corner0_Rounded as CircleBanSign} from '#/components/icons/CircleBanSign'
+
+export interface ModerationCauseDescription {
+  icon: React.ComponentType<SVGIconProps>
+  name: string
+  description: string
+  source?: string
+  sourceType?: ModerationCauseSource['type']
+}
+
+export function useModerationCauseDescription(
+  cause: ModerationCause | undefined,
+): ModerationCauseDescription {
+  const {_, i18n} = useLingui()
+  const {labelDefs, labelers} = useLabelDefinitions()
+  const globalLabelStrings = useGlobalLabelStrings()
+
+  return React.useMemo(() => {
+    if (!cause) {
+      return {
+        icon: Warning,
+        name: _(msg`Content Warning`),
+        description: _(
+          msg`Moderator has chosen to set a general warning on the content.`,
+        ),
+      }
+    }
+    if (cause.type === 'blocking') {
+      if (cause.source.type === 'list') {
+        return {
+          icon: CircleBanSign,
+          name: _(msg`User Blocked by "${cause.source.list.name}"`),
+          description: _(
+            msg`You have blocked this user. You cannot view their content.`,
+          ),
+        }
+      } else {
+        return {
+          icon: CircleBanSign,
+          name: _(msg`User Blocked`),
+          description: _(
+            msg`You have blocked this user. You cannot view their content.`,
+          ),
+        }
+      }
+    }
+    if (cause.type === 'blocked-by') {
+      return {
+        icon: CircleBanSign,
+        name: _(msg`User Blocking You`),
+        description: _(
+          msg`This user has blocked you. You cannot view their content.`,
+        ),
+      }
+    }
+    if (cause.type === 'block-other') {
+      return {
+        icon: CircleBanSign,
+        name: _(msg`Content Not Available`),
+        description: _(
+          msg`This content is not available because one of the users involved has blocked the other.`,
+        ),
+      }
+    }
+    if (cause.type === 'muted') {
+      if (cause.source.type === 'list') {
+        return {
+          icon: EyeSlash,
+          name: _(msg`Muted by "${cause.source.list.name}"`),
+          description: _(msg`You have muted this user`),
+        }
+      } else {
+        return {
+          icon: EyeSlash,
+          name: _(msg`Account Muted`),
+          description: _(msg`You have muted this account.`),
+        }
+      }
+    }
+    if (cause.type === 'mute-word') {
+      return {
+        icon: EyeSlash,
+        name: _(msg`Post Hidden by Muted Word`),
+        description: _(
+          msg`You've chosen to hide a word or tag within this post.`,
+        ),
+      }
+    }
+    if (cause.type === 'hidden') {
+      return {
+        icon: EyeSlash,
+        name: _(msg`Post Hidden by You`),
+        description: _(msg`You have hidden this post`),
+      }
+    }
+    if (cause.type === 'label') {
+      const def = cause.labelDef || getDefinition(labelDefs, cause.label)
+      const strings = getLabelStrings(i18n.locale, globalLabelStrings, def)
+      const labeler = labelers.find(l => l.creator.did === cause.label.src)
+      let source =
+        labeler?.creator.displayName ||
+        (labeler?.creator.handle ? '@' + labeler?.creator.handle : undefined)
+      if (!source) {
+        if (cause.label.src === BSKY_LABELER_DID) {
+          source = 'Bluesky Moderation'
+        } else {
+          source = cause.label.src
+        }
+      }
+      return {
+        icon:
+          def.identifier === '!no-unauthenticated'
+            ? EyeSlash
+            : def.severity === 'alert'
+            ? Warning
+            : CircleInfo,
+        name: strings.name,
+        description: strings.description,
+        source,
+        sourceType: cause.source.type,
+      }
+    }
+    // should never happen
+    return {
+      icon: CircleInfo,
+      name: '',
+      description: ``,
+    }
+  }, [labelDefs, labelers, globalLabelStrings, cause, _, i18n.locale])
+}
diff --git a/src/lib/moderation/useReportOptions.ts b/src/lib/moderation/useReportOptions.ts
new file mode 100644
index 000000000..e00170594
--- /dev/null
+++ b/src/lib/moderation/useReportOptions.ts
@@ -0,0 +1,94 @@
+import {msg} from '@lingui/macro'
+import {useLingui} from '@lingui/react'
+import {useMemo} from 'react'
+import {ComAtprotoModerationDefs} from '@atproto/api'
+
+export interface ReportOption {
+  reason: string
+  title: string
+  description: string
+}
+
+interface ReportOptions {
+  account: ReportOption[]
+  post: ReportOption[]
+  list: ReportOption[]
+  feedgen: ReportOption[]
+  other: ReportOption[]
+}
+
+export function useReportOptions(): ReportOptions {
+  const {_} = useLingui()
+  return useMemo(() => {
+    const other = {
+      reason: ComAtprotoModerationDefs.REASONOTHER,
+      title: _(msg`Other`),
+      description: _(msg`An issue not included in these options`),
+    }
+    const common = [
+      {
+        reason: ComAtprotoModerationDefs.REASONRUDE,
+        title: _(msg`Anti-Social Behavior`),
+        description: _(msg`Harassment, trolling, or intolerance`),
+      },
+      {
+        reason: ComAtprotoModerationDefs.REASONVIOLATION,
+        title: _(msg`Illegal and Urgent`),
+        description: _(msg`Glaring violations of law or terms of service`),
+      },
+      other,
+    ]
+    return {
+      account: [
+        {
+          reason: ComAtprotoModerationDefs.REASONMISLEADING,
+          title: _(msg`Misleading Account`),
+          description: _(
+            msg`Impersonation or false claims about identity or affiliation`,
+          ),
+        },
+        {
+          reason: ComAtprotoModerationDefs.REASONSPAM,
+          title: _(msg`Frequently Posts Unwanted Content`),
+          description: _(msg`Spam; excessive mentions or replies`),
+        },
+        {
+          reason: ComAtprotoModerationDefs.REASONVIOLATION,
+          title: _(msg`Name or Description Violates Community Standards`),
+          description: _(msg`Terms used violate community standards`),
+        },
+        other,
+      ],
+      post: [
+        {
+          reason: ComAtprotoModerationDefs.REASONSPAM,
+          title: _(msg`Spam`),
+          description: _(msg`Excessive mentions or replies`),
+        },
+        {
+          reason: ComAtprotoModerationDefs.REASONSEXUAL,
+          title: _(msg`Unwanted Sexual Content`),
+          description: _(msg`Nudity or pornography not labeled as such`),
+        },
+        ...common,
+      ],
+      list: [
+        {
+          reason: ComAtprotoModerationDefs.REASONVIOLATION,
+          title: _(msg`Name or Description Violates Community Standards`),
+          description: _(msg`Terms used violate community standards`),
+        },
+        ...common,
+      ],
+      feedgen: [
+        {
+          reason: ComAtprotoModerationDefs.REASONVIOLATION,
+          title: _(msg`Name or Description Violates Community Standards`),
+          description: _(msg`Terms used violate community standards`),
+        },
+        ...common,
+      ],
+      other: common,
+    }
+  }, [_])
+}
diff --git a/src/lib/notifications/notifications.ts b/src/lib/notifications/notifications.ts
index 62d0bfc4b..e811f690e 100644
--- a/src/lib/notifications/notifications.ts
+++ b/src/lib/notifications/notifications.ts
@@ -7,6 +7,7 @@ import {logger} from '#/logger'
 import {RQKEY as RQKEY_NOTIFS} from '#/state/queries/notifications/feed'
 import {truncateAndInvalidate} from '#/state/queries/util'
 import {SessionAccount, getAgent} from '#/state/session'
+import {logEvent} from '../statsig/statsig'
 
 const SERVICE_DID = (serviceUrl?: string) =>
   serviceUrl?.includes('staging')
@@ -123,6 +124,7 @@ export function init(queryClient: QueryClient) {
         logger.DebugContext.notifications,
       )
       track('Notificatons:OpenApp')
+      logEvent('notifications:openApp', {})
       truncateAndInvalidate(queryClient, RQKEY_NOTIFS())
       resetToTab('NotificationsTab') // open notifications tab
     }
diff --git a/src/lib/react-query.ts b/src/lib/react-query.ts
index 7fe3fe7a4..d6cd3c54b 100644
--- a/src/lib/react-query.ts
+++ b/src/lib/react-query.ts
@@ -1,7 +1,14 @@
 import {AppState, AppStateStatus} from 'react-native'
 import {QueryClient, focusManager} from '@tanstack/react-query'
+import {createAsyncStoragePersister} from '@tanstack/query-async-storage-persister'
+import AsyncStorage from '@react-native-async-storage/async-storage'
+import {PersistQueryClientProviderProps} from '@tanstack/react-query-persist-client'
+
 import {isNative} from '#/platform/detection'
 
+// any query keys in this array will be persisted to AsyncStorage
+const STORED_CACHE_QUERY_KEYS = ['labelers-detailed-info']
+
 focusManager.setEventListener(onFocus => {
   if (isNative) {
     const subscription = AppState.addEventListener(
@@ -48,3 +55,16 @@ export const queryClient = new QueryClient({
     },
   },
 })
+
+export const asyncStoragePersister = createAsyncStoragePersister({
+  storage: AsyncStorage,
+  key: 'queryCache',
+})
+
+export const dehydrateOptions: PersistQueryClientProviderProps['persistOptions']['dehydrateOptions'] =
+  {
+    shouldDehydrateMutation: (_: any) => false,
+    shouldDehydrateQuery: query => {
+      return STORED_CACHE_QUERY_KEYS.includes(String(query.queryKey[0]))
+    },
+  }
diff --git a/src/lib/routes/types.ts b/src/lib/routes/types.ts
index 6756a62a6..95af2f237 100644
--- a/src/lib/routes/types.ts
+++ b/src/lib/routes/types.ts
@@ -21,7 +21,9 @@ export type CommonNavigatorParams = {
   PostRepostedBy: {name: string; rkey: string}
   ProfileFeed: {name: string; rkey: string}
   ProfileFeedLikedBy: {name: string; rkey: string}
+  ProfileLabelerLikedBy: {name: string}
   Debug: undefined
+  DebugMod: undefined
   Log: undefined
   Support: undefined
   PrivacyPolicy: undefined
diff --git a/src/lib/statsig/events.ts b/src/lib/statsig/events.ts
index fa7e597fb..b91a15ecb 100644
--- a/src/lib/statsig/events.ts
+++ b/src/lib/statsig/events.ts
@@ -2,6 +2,9 @@ export type LogEvents = {
   init: {
     initMs: number
   }
+  'notifications:openApp': {}
+  'state:background': {}
+  'state:foreground': {}
   'feed:endReached': {
     feedType: string
     itemCount: number
diff --git a/src/lib/statsig/statsig.tsx b/src/lib/statsig/statsig.tsx
index 5745d204a..3abec5c4f 100644
--- a/src/lib/statsig/statsig.tsx
+++ b/src/lib/statsig/statsig.tsx
@@ -1,9 +1,11 @@
 import React from 'react'
+import {Platform} from 'react-native'
 import {
   Statsig,
   StatsigProvider,
   useGate as useStatsigGate,
 } from 'statsig-react-native-expo'
+import {AppState, AppStateStatus} from 'react-native'
 import {useSession} from '../../state/session'
 import {sha256} from 'js-sha256'
 import {LogEvents} from './events'
@@ -58,9 +60,25 @@ function toStatsigUser(did: string | undefined) {
   if (did) {
     userID = sha256(did)
   }
-  return {userID}
+  return {
+    userID,
+    platform: Platform.OS,
+  }
 }
 
+let lastState: AppStateStatus = AppState.currentState
+AppState.addEventListener('change', (state: AppStateStatus) => {
+  if (state === lastState) {
+    return
+  }
+  lastState = state
+  if (state === 'active') {
+    logEvent('state:foreground', {})
+  } else {
+    logEvent('state:background', {})
+  }
+})
+
 export function Provider({children}: {children: React.ReactNode}) {
   const {currentAccount} = useSession()
   const currentStatsigUser = React.useMemo(
diff --git a/src/lib/strings/display-names.ts b/src/lib/strings/display-names.ts
index 75383dd4f..e0f23fa2c 100644
--- a/src/lib/strings/display-names.ts
+++ b/src/lib/strings/display-names.ts
@@ -1,5 +1,4 @@
 import {ModerationUI} from '@atproto/api'
-import {describeModerationCause} from '../moderation'
 
 // \u2705 = ✅
 // \u2713 = ✓
@@ -14,7 +13,7 @@ export function sanitizeDisplayName(
   moderation?: ModerationUI,
 ): string {
   if (moderation?.blur) {
-    return `⚠${describeModerationCause(moderation.cause, 'account').name}`
+    return ''
   }
   if (typeof str === 'string') {
     return str.replace(CHECK_MARKS_RE, '').replace(CONTROL_CHARS_RE, '').trim()
diff --git a/src/lib/strings/embed-player.ts b/src/lib/strings/embed-player.ts
index 1cf3b1293..ee7328478 100644
--- a/src/lib/strings/embed-player.ts
+++ b/src/lib/strings/embed-player.ts
@@ -2,6 +2,15 @@ import {Dimensions} from 'react-native'
 import {isWeb} from 'platform/detection'
 const {height: SCREEN_HEIGHT} = Dimensions.get('window')
 
+const IFRAME_HOST = isWeb
+  ? // @ts-ignore only for web
+    window.location.host === 'localhost:8100'
+    ? 'http://localhost:8100'
+    : 'https://bsky.app'
+  : __DEV__ && !process.env.JEST_WORKER_ID
+  ? 'http://localhost:8100'
+  : 'https://bsky.app'
+
 export const embedPlayerSources = [
   'youtube',
   'youtubeShorts',
@@ -74,7 +83,7 @@ export function parseEmbedPlayerFromUrl(
       return {
         type: 'youtube_video',
         source: 'youtube',
-        playerUri: `https://bsky.app/iframe/youtube.html?videoId=${videoId}&start=${seek}`,
+        playerUri: `${IFRAME_HOST}/iframe/youtube.html?videoId=${videoId}&start=${seek}`,
       }
     }
   }
@@ -93,7 +102,7 @@ export function parseEmbedPlayerFromUrl(
         type: page === 'shorts' ? 'youtube_short' : 'youtube_video',
         source: page === 'shorts' ? 'youtubeShorts' : 'youtube',
         hideDetails: page === 'shorts' ? true : undefined,
-        playerUri: `https://bsky.app/iframe/youtube.html?videoId=${videoId}&start=${seek}`,
+        playerUri: `${IFRAME_HOST}/iframe/youtube.html?videoId=${videoId}&start=${seek}`,
       }
     }
   }
diff --git a/src/lib/strings/url-helpers.ts b/src/lib/strings/url-helpers.ts
index 820311e4e..70a2b7069 100644
--- a/src/lib/strings/url-helpers.ts
+++ b/src/lib/strings/url-helpers.ts
@@ -4,6 +4,23 @@ import TLDs from 'tlds'
 import psl from 'psl'
 
 export const BSKY_APP_HOST = 'https://bsky.app'
+const BSKY_TRUSTED_HOSTS = [
+  'bsky.app',
+  'bsky.social',
+  'blueskyweb.xyz',
+  'blueskyweb.zendesk.com',
+  ...(__DEV__ ? ['localhost:19006', 'localhost:8100'] : []),
+]
+
+/*
+ * This will allow any BSKY_TRUSTED_HOSTS value by itself or with a subdomain.
+ * It will also allow relative paths like /profile as well as #.
+ */
+const TRUSTED_REGEX = new RegExp(
+  `^(http(s)?://(([\\w-]+\\.)?${BSKY_TRUSTED_HOSTS.join(
+    '|([\\w-]+\\.)?',
+  )})|/|#)`,
+)
 
 export function isValidDomain(str: string): boolean {
   return !!TLDs.find(tld => {
@@ -86,6 +103,10 @@ export function isExternalUrl(url: string): boolean {
   return external || rss
 }
 
+export function isTrustedUrl(url: string): boolean {
+  return TRUSTED_REGEX.test(url)
+}
+
 export function isBskyPostUrl(url: string): boolean {
   if (isBskyAppUrl(url)) {
     try {
@@ -163,8 +184,8 @@ export function feedUriToHref(url: string): string {
 export function linkRequiresWarning(uri: string, label: string) {
   const labelDomain = labelToDomain(label)
 
-  // If the uri started with a / we know it is internal.
-  if (isRelativeUrl(uri)) {
+  // We should trust any relative URL or a # since we know it links to internal content
+  if (isRelativeUrl(uri) || uri === '#') {
     return false
   }
 
@@ -176,18 +197,11 @@ export function linkRequiresWarning(uri: string, label: string) {
   }
 
   const host = urip.hostname.toLowerCase()
-  // Hosts that end with bsky.app or bsky.social should be trusted by default.
-  if (
-    host.endsWith('bsky.app') ||
-    host.endsWith('bsky.social') ||
-    host.endsWith('blueskyweb.xyz')
-  ) {
-    // if this is a link to internal content,
-    // warn if it represents itself as a URL to another app
+  if (isTrustedUrl(uri)) {
+    // if this is a link to internal content, warn if it represents itself as a URL to another app
     return !!labelDomain && labelDomain !== host && isPossiblyAUrl(labelDomain)
   } else {
-    // if this is a link to external content,
-    // warn if the label doesnt match the target
+    // if this is a link to external content, warn if the label doesnt match the target
     if (!labelDomain) {
       return true
     }
diff --git a/src/lib/themes.ts b/src/lib/themes.ts
index bd75aabea..6fada40a7 100644
--- a/src/lib/themes.ts
+++ b/src/lib/themes.ts
@@ -9,7 +9,7 @@ export const defaultTheme: Theme = {
   palette: {
     default: {
       background: lightPalette.white,
-      backgroundLight: lightPalette.contrast_50,
+      backgroundLight: lightPalette.contrast_25,
       text: lightPalette.black,
       textLight: lightPalette.contrast_700,
       textInverted: lightPalette.white,