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/hooks/useAccountSwitcher.ts8
-rw-r--r--src/lib/hooks/useOTAUpdate.ts52
-rw-r--r--src/lib/hooks/useWebBodyScrollLock.ts2
-rw-r--r--src/lib/media/picker.e2e.tsx3
-rw-r--r--src/lib/media/picker.shared.ts21
-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.ts150
-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.ts63
-rw-r--r--src/lib/statsig/statsig.tsx58
-rw-r--r--src/lib/statsig/statsig.web.tsx75
-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
24 files changed, 767 insertions, 1337 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/hooks/useAccountSwitcher.ts b/src/lib/hooks/useAccountSwitcher.ts
index 74b5674d5..eb1685a0a 100644
--- a/src/lib/hooks/useAccountSwitcher.ts
+++ b/src/lib/hooks/useAccountSwitcher.ts
@@ -6,6 +6,7 @@ import {useSessionApi, SessionAccount} from '#/state/session'
 import * as Toast from '#/view/com/util/Toast'
 import {useCloseAllActiveElements} from '#/state/util'
 import {useLoggedOutViewControls} from '#/state/shell/logged-out'
+import {LogEvents} from '../statsig/statsig'
 
 export function useAccountSwitcher() {
   const {track} = useAnalytics()
@@ -14,7 +15,10 @@ export function useAccountSwitcher() {
   const {requestSwitchToAccount} = useLoggedOutViewControls()
 
   const onPressSwitchAccount = useCallback(
-    async (account: SessionAccount) => {
+    async (
+      account: SessionAccount,
+      logContext: LogEvents['account:loggedIn']['logContext'],
+    ) => {
       track('Settings:SwitchAccountButtonClicked')
 
       try {
@@ -28,7 +32,7 @@ export function useAccountSwitcher() {
             // So we change the URL ourselves. The navigator will pick it up on remount.
             history.pushState(null, '', '/')
           }
-          await selectAccount(account)
+          await selectAccount(account, logContext)
           setTimeout(() => {
             Toast.show(`Signed in as @${account.handle}`)
           }, 100)
diff --git a/src/lib/hooks/useOTAUpdate.ts b/src/lib/hooks/useOTAUpdate.ts
index 53eab300e..d35179256 100644
--- a/src/lib/hooks/useOTAUpdate.ts
+++ b/src/lib/hooks/useOTAUpdate.ts
@@ -2,25 +2,9 @@ import * as Updates from 'expo-updates'
 import {useCallback, useEffect} from 'react'
 import {AppState} from 'react-native'
 import {logger} from '#/logger'
-import {useModalControls} from '#/state/modals'
-import {t} from '@lingui/macro'
 
 export function useOTAUpdate() {
-  const {openModal} = useModalControls()
-
   // HELPER FUNCTIONS
-  const showUpdatePopup = useCallback(() => {
-    openModal({
-      name: 'confirm',
-      title: t`Update Available`,
-      message: t`A new version of the app is available. Please update to continue using the app.`,
-      onPressConfirm: async () => {
-        Updates.reloadAsync().catch(err => {
-          throw err
-        })
-      },
-    })
-  }, [openModal])
   const checkForUpdate = useCallback(async () => {
     logger.debug('useOTAUpdate: Checking for update...')
     try {
@@ -32,32 +16,26 @@ export function useOTAUpdate() {
       }
       // Otherwise fetch the update in the background, so even if the user rejects switching to latest version it will be done automatically on next relaunch.
       await Updates.fetchUpdateAsync()
-      // show a popup modal
-      showUpdatePopup()
     } catch (e) {
       logger.error('useOTAUpdate: Error while checking for update', {
         message: e,
       })
     }
-  }, [showUpdatePopup])
-  const updateEventListener = useCallback(
-    (event: Updates.UpdateEvent) => {
-      logger.debug('useOTAUpdate: Listening for update...')
-      if (event.type === Updates.UpdateEventType.ERROR) {
-        logger.error('useOTAUpdate: Error while listening for update', {
-          message: event.message,
-        })
-      } else if (event.type === Updates.UpdateEventType.NO_UPDATE_AVAILABLE) {
-        // Handle no update available
-        // do nothing
-      } else if (event.type === Updates.UpdateEventType.UPDATE_AVAILABLE) {
-        // Handle update available
-        // open modal, ask for user confirmation, and reload the app
-        showUpdatePopup()
-      }
-    },
-    [showUpdatePopup],
-  )
+  }, [])
+  const updateEventListener = useCallback((event: Updates.UpdateEvent) => {
+    logger.debug('useOTAUpdate: Listening for update...')
+    if (event.type === Updates.UpdateEventType.ERROR) {
+      logger.error('useOTAUpdate: Error while listening for update', {
+        message: event.message,
+      })
+    } else if (event.type === Updates.UpdateEventType.NO_UPDATE_AVAILABLE) {
+      // Handle no update available
+      // do nothing
+    } else if (event.type === Updates.UpdateEventType.UPDATE_AVAILABLE) {
+      // Handle update available
+      // open modal, ask for user confirmation, and reload the app
+    }
+  }, [])
 
   useEffect(() => {
     // ADD EVENT LISTENERS
diff --git a/src/lib/hooks/useWebBodyScrollLock.ts b/src/lib/hooks/useWebBodyScrollLock.ts
index 585f193f1..0dcf911fe 100644
--- a/src/lib/hooks/useWebBodyScrollLock.ts
+++ b/src/lib/hooks/useWebBodyScrollLock.ts
@@ -6,6 +6,7 @@ let refCount = 0
 function incrementRefCount() {
   if (refCount === 0) {
     document.body.style.overflow = 'hidden'
+    document.documentElement.style.scrollbarGutter = 'auto'
   }
   refCount++
 }
@@ -14,6 +15,7 @@ function decrementRefCount() {
   refCount--
   if (refCount === 0) {
     document.body.style.overflow = ''
+    document.documentElement.style.scrollbarGutter = ''
   }
 }
 
diff --git a/src/lib/media/picker.e2e.tsx b/src/lib/media/picker.e2e.tsx
index d7b608041..31702ab22 100644
--- a/src/lib/media/picker.e2e.tsx
+++ b/src/lib/media/picker.e2e.tsx
@@ -3,7 +3,6 @@ import RNFS from 'react-native-fs'
 import {CropperOptions} from './types'
 import {compressIfNeeded} from './manip'
 
-let _imageCounter = 0
 async function getFile() {
   let files = await RNFS.readDir(
     RNFS.LibraryDirectoryPath.split('/')
@@ -12,7 +11,7 @@ async function getFile() {
       .join('/'),
   )
   files = files.filter(file => file.path.endsWith('.JPG'))
-  const file = files[_imageCounter++ % files.length]
+  const file = files[0]
   return await compressIfNeeded({
     path: file.path,
     mime: 'image/jpeg',
diff --git a/src/lib/media/picker.shared.ts b/src/lib/media/picker.shared.ts
index 8bade34e2..96e82e4c7 100644
--- a/src/lib/media/picker.shared.ts
+++ b/src/lib/media/picker.shared.ts
@@ -18,11 +18,18 @@ export async function openPicker(opts?: ImagePickerOptions) {
     Toast.show('You may only select up to 4 images')
   }
 
-  return (response.assets ?? []).slice(0, 4).map(image => ({
-    mime: 'image/jpeg',
-    height: image.height,
-    width: image.width,
-    path: image.uri,
-    size: getDataUriSize(image.uri),
-  }))
+  return (response.assets ?? [])
+    .slice(0, 4)
+    .filter(asset => {
+      if (asset.mimeType?.startsWith('image/')) return true
+      Toast.show('Only image files are supported')
+      return false
+    })
+    .map(image => ({
+      mime: 'image/jpeg',
+      height: image.height,
+      width: image.width,
+      path: image.uri,
+      size: getDataUriSize(image.uri),
+    }))
 }
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..57b50d777
--- /dev/null
+++ b/src/lib/moderation/useModerationCauseDescription.ts
@@ -0,0 +1,150 @@
+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 Service'
+        } else {
+          source = cause.label.src
+        }
+      }
+      if (def.identifier === 'porn' || def.identifier === 'sexual') {
+        strings.name = 'Adult Content'
+      }
+
+      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
new file mode 100644
index 000000000..b83095976
--- /dev/null
+++ b/src/lib/statsig/events.ts
@@ -0,0 +1,63 @@
+export type LogEvents = {
+  init: {
+    initMs: number
+  }
+  'account:loggedIn': {
+    logContext: 'LoginForm' | 'SwitchAccount' | 'ChooseAccountForm' | 'Settings'
+    withPassword: boolean
+  }
+  'account:loggedOut': {
+    logContext: 'SwitchAccount' | 'Settings' | 'Deactivated'
+  }
+  'notifications:openApp': {}
+  'state:background': {
+    secondsActive: number
+  }
+  'state:foreground': {}
+  'feed:endReached': {
+    feedType: string
+    itemCount: number
+  }
+  'feed:refresh': {
+    feedType: string
+    reason: 'pull-to-refresh' | 'soft-reset' | 'load-latest'
+  }
+  'post:create': {
+    imageCount: number
+    isReply: boolean
+    hasLink: boolean
+    hasQuote: boolean
+    langs: string
+    logContext: 'Composer'
+  }
+  'post:like': {
+    logContext: 'FeedItem' | 'PostThreadItem' | 'Post'
+  }
+  'post:repost': {
+    logContext: 'FeedItem' | 'PostThreadItem' | 'Post'
+  }
+  'post:unlike': {
+    logContext: 'FeedItem' | 'PostThreadItem' | 'Post'
+  }
+  'post:unrepost': {
+    logContext: 'FeedItem' | 'PostThreadItem' | 'Post'
+  }
+  'profile:follow': {
+    logContext:
+      | 'RecommendedFollowsItem'
+      | 'PostThreadItem'
+      | 'ProfileCard'
+      | 'ProfileHeader'
+      | 'ProfileHeaderSuggestedFollows'
+      | 'ProfileMenu'
+  }
+  'profile:unfollow': {
+    logContext:
+      | 'RecommendedFollowsItem'
+      | 'PostThreadItem'
+      | 'ProfileCard'
+      | 'ProfileHeader'
+      | 'ProfileHeaderSuggestedFollows'
+      | 'ProfileMenu'
+  }
+}
diff --git a/src/lib/statsig/statsig.tsx b/src/lib/statsig/statsig.tsx
index 6d9ebeb09..9fa6cce2d 100644
--- a/src/lib/statsig/statsig.tsx
+++ b/src/lib/statsig/statsig.tsx
@@ -1,11 +1,16 @@
 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'
+
+export type {LogEvents}
 
 const statsigOptions = {
   environment: {
@@ -17,12 +22,28 @@ const statsigOptions = {
   initTimeoutMs: 1,
 }
 
-export function logEvent(
-  eventName: string,
-  value?: string | number | null,
-  metadata?: Record<string, string> | null,
+type FlatJSONRecord = Record<
+  string,
+  string | number | boolean | null | undefined
+>
+
+let getCurrentRouteName: () => string | null | undefined = () => null
+
+export function attachRouteToLogEvents(
+  getRouteName: () => string | null | undefined,
+) {
+  getCurrentRouteName = getRouteName
+}
+
+export function logEvent<E extends keyof LogEvents>(
+  eventName: E & string,
+  rawMetadata: LogEvents[E] & FlatJSONRecord,
 ) {
-  Statsig.logEvent(eventName, value, metadata)
+  const fullMetadata = {
+    ...rawMetadata,
+  } as Record<string, string> // Statsig typings are unnecessarily strict here.
+  fullMetadata.routeName = getCurrentRouteName() ?? '(Uninitialized)'
+  Statsig.logEvent(eventName, null, fullMetadata)
 }
 
 export function useGate(gateName: string) {
@@ -39,9 +60,34 @@ function toStatsigUser(did: string | undefined) {
   if (did) {
     userID = sha256(did)
   }
-  return {userID}
+  return {
+    userID,
+    platform: Platform.OS,
+  }
 }
 
+let lastState: AppStateStatus = AppState.currentState
+let lastActive = lastState === 'active' ? performance.now() : null
+AppState.addEventListener('change', (state: AppStateStatus) => {
+  if (state === lastState) {
+    return
+  }
+  lastState = state
+  if (state === 'active') {
+    lastActive = performance.now()
+    logEvent('state:foreground', {})
+  } else {
+    let secondsActive = 0
+    if (lastActive != null) {
+      secondsActive = Math.round((performance.now() - lastActive) / 1e3)
+    }
+    lastActive = null
+    logEvent('state:background', {
+      secondsActive,
+    })
+  }
+})
+
 export function Provider({children}: {children: React.ReactNode}) {
   const {currentAccount} = useSession()
   const currentStatsigUser = React.useMemo(
diff --git a/src/lib/statsig/statsig.web.tsx b/src/lib/statsig/statsig.web.tsx
deleted file mode 100644
index d1c912019..000000000
--- a/src/lib/statsig/statsig.web.tsx
+++ /dev/null
@@ -1,75 +0,0 @@
-import React from 'react'
-import {
-  Statsig,
-  StatsigProvider,
-  useGate as useStatsigGate,
-} from 'statsig-react'
-import {useSession} from '../../state/session'
-import {sha256} from 'js-sha256'
-
-const statsigOptions = {
-  environment: {
-    tier: process.env.NODE_ENV === 'development' ? 'development' : 'production',
-  },
-  // Don't block on waiting for network. The fetched config will kick in on next load.
-  // This ensures the UI is always consistent and doesn't update mid-session.
-  // Note this makes cold load (no local storage) and private mode return `false` for all gates.
-  initTimeoutMs: 1,
-}
-
-export function logEvent(
-  eventName: string,
-  value?: string | number | null,
-  metadata?: Record<string, string> | null,
-) {
-  Statsig.logEvent(eventName, value, metadata)
-}
-
-export function useGate(gateName: string) {
-  const {isLoading, value} = useStatsigGate(gateName)
-  if (isLoading) {
-    // This should not happen because of waitForInitialization={true}.
-    console.error('Did not expected isLoading to ever be true.')
-  }
-  return value
-}
-
-function toStatsigUser(did: string | undefined) {
-  let userID: string | undefined
-  if (did) {
-    userID = sha256(did)
-  }
-  return {userID}
-}
-
-export function Provider({children}: {children: React.ReactNode}) {
-  const {currentAccount} = useSession()
-  const currentStatsigUser = React.useMemo(
-    () => toStatsigUser(currentAccount?.did),
-    [currentAccount?.did],
-  )
-
-  React.useEffect(() => {
-    function refresh() {
-      // Intentionally refetching the config using the JS SDK rather than React SDK
-      // so that the new config is stored in cache but isn't used during this session.
-      // It will kick in for the next reload.
-      Statsig.updateUser(currentStatsigUser)
-    }
-    const id = setInterval(refresh, 3 * 60e3 /* 3 min */)
-    return () => clearInterval(id)
-  }, [currentStatsigUser])
-
-  return (
-    <StatsigProvider
-      sdkKey="client-SXJakO39w9vIhl3D44u8UupyzFl4oZ2qPIkjwcvuPsV"
-      mountKey={currentStatsigUser.userID}
-      user={currentStatsigUser}
-      // This isn't really blocking due to short initTimeoutMs above.
-      // However, it ensures `isLoading` is always `false`.
-      waitForInitialization={true}
-      options={statsigOptions}>
-      {children}
-    </StatsigProvider>
-  )
-}
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,