about summary refs log tree commit diff
path: root/src/state
diff options
context:
space:
mode:
Diffstat (limited to 'src/state')
-rw-r--r--src/state/cache/post-shadow.ts18
-rw-r--r--src/state/cache/profile-shadow.ts15
-rw-r--r--src/state/dialogs/index.tsx85
-rw-r--r--src/state/modals/index.tsx56
-rw-r--r--src/state/models/media/gallery.ts25
-rw-r--r--src/state/persisted/__tests__/migrate.test.ts6
-rw-r--r--src/state/persisted/index.ts6
-rw-r--r--src/state/persisted/legacy.ts8
-rw-r--r--src/state/preferences/in-app-browser.tsx9
-rw-r--r--src/state/preferences/index.tsx1
-rw-r--r--src/state/preferences/label-defs.tsx25
-rw-r--r--src/state/queries/actor-autocomplete.ts27
-rw-r--r--src/state/queries/feed.ts129
-rw-r--r--src/state/queries/labeler.ts89
-rw-r--r--src/state/queries/notifications/feed.ts17
-rw-r--r--src/state/queries/notifications/util.ts30
-rw-r--r--src/state/queries/post-feed.ts138
-rw-r--r--src/state/queries/post-liked-by.ts4
-rw-r--r--src/state/queries/post-thread.ts66
-rw-r--r--src/state/queries/post.ts81
-rw-r--r--src/state/queries/preferences/const.ts18
-rw-r--r--src/state/queries/preferences/index.ts162
-rw-r--r--src/state/queries/preferences/moderation.ts218
-rw-r--r--src/state/queries/preferences/types.ts33
-rw-r--r--src/state/queries/preferences/util.ts16
-rw-r--r--src/state/queries/profile-extra-info.ts34
-rw-r--r--src/state/queries/profile.ts80
-rw-r--r--src/state/queries/suggested-follows.ts3
-rw-r--r--src/state/queries/util.ts1
-rw-r--r--src/state/session/agent-config.ts12
-rw-r--r--src/state/session/index.tsx132
-rw-r--r--src/state/shell/composer.tsx13
-rw-r--r--src/state/util.ts17
33 files changed, 828 insertions, 746 deletions
diff --git a/src/state/cache/post-shadow.ts b/src/state/cache/post-shadow.ts
index 7cf72fae4..6225cbdba 100644
--- a/src/state/cache/post-shadow.ts
+++ b/src/state/cache/post-shadow.ts
@@ -1,13 +1,14 @@
-import {useEffect, useState, useMemo} from 'react'
-import EventEmitter from 'eventemitter3'
+import {useEffect, useMemo, useState} from 'react'
 import {AppBskyFeedDefs} from '@atproto/api'
+import {QueryClient} from '@tanstack/react-query'
+import EventEmitter from 'eventemitter3'
+
 import {batchedUpdates} from '#/lib/batchedUpdates'
-import {Shadow, castAsShadow} from './types'
 import {findAllPostsInQueryData as findAllPostsInNotifsQueryData} from '../queries/notifications/feed'
 import {findAllPostsInQueryData as findAllPostsInFeedQueryData} from '../queries/post-feed'
 import {findAllPostsInQueryData as findAllPostsInThreadQueryData} from '../queries/post-thread'
 import {findAllPostsInQueryData as findAllPostsInSearchQueryData} from '../queries/search-posts'
-import {queryClient} from 'lib/react-query'
+import {castAsShadow, Shadow} from './types'
 export type {Shadow} from './types'
 
 export interface PostShadow {
@@ -93,8 +94,12 @@ function mergeShadow(
   })
 }
 
-export function updatePostShadow(uri: string, value: Partial<PostShadow>) {
-  const cachedPosts = findPostsInCache(uri)
+export function updatePostShadow(
+  queryClient: QueryClient,
+  uri: string,
+  value: Partial<PostShadow>,
+) {
+  const cachedPosts = findPostsInCache(queryClient, uri)
   for (let post of cachedPosts) {
     shadows.set(post, {...shadows.get(post), ...value})
   }
@@ -104,6 +109,7 @@ export function updatePostShadow(uri: string, value: Partial<PostShadow>) {
 }
 
 function* findPostsInCache(
+  queryClient: QueryClient,
   uri: string,
 ): Generator<AppBskyFeedDefs.PostView, void> {
   for (let post of findAllPostsInFeedQueryData(queryClient, uri)) {
diff --git a/src/state/cache/profile-shadow.ts b/src/state/cache/profile-shadow.ts
index 34fe5995d..ca791bc9e 100644
--- a/src/state/cache/profile-shadow.ts
+++ b/src/state/cache/profile-shadow.ts
@@ -1,7 +1,10 @@
-import {useEffect, useState, useMemo} from 'react'
-import EventEmitter from 'eventemitter3'
+import {useEffect, useMemo, useState} from 'react'
 import {AppBskyActorDefs} from '@atproto/api'
+import {QueryClient} from '@tanstack/react-query'
+import EventEmitter from 'eventemitter3'
+
 import {batchedUpdates} from '#/lib/batchedUpdates'
+import {findAllProfilesInQueryData as findAllProfilesInActorSearchQueryData} from '../queries/actor-search'
 import {findAllProfilesInQueryData as findAllProfilesInListMembersQueryData} from '../queries/list-members'
 import {findAllProfilesInQueryData as findAllProfilesInMyBlockedAccountsQueryData} from '../queries/my-blocked-accounts'
 import {findAllProfilesInQueryData as findAllProfilesInMyMutedAccountsQueryData} from '../queries/my-muted-accounts'
@@ -11,9 +14,7 @@ import {findAllProfilesInQueryData as findAllProfilesInProfileQueryData} from '.
 import {findAllProfilesInQueryData as findAllProfilesInProfileFollowersQueryData} from '../queries/profile-followers'
 import {findAllProfilesInQueryData as findAllProfilesInProfileFollowsQueryData} from '../queries/profile-follows'
 import {findAllProfilesInQueryData as findAllProfilesInSuggestedFollowsQueryData} from '../queries/suggested-follows'
-import {findAllProfilesInQueryData as findAllProfilesInActorSearchQueryData} from '../queries/actor-search'
-import {Shadow, castAsShadow} from './types'
-import {queryClient} from 'lib/react-query'
+import {castAsShadow, Shadow} from './types'
 export type {Shadow} from './types'
 
 export interface ProfileShadow {
@@ -58,10 +59,11 @@ export function useProfileShadow<
 }
 
 export function updateProfileShadow(
+  queryClient: QueryClient,
   did: string,
   value: Partial<ProfileShadow>,
 ) {
-  const cachedProfiles = findProfilesInCache(did)
+  const cachedProfiles = findProfilesInCache(queryClient, did)
   for (let post of cachedProfiles) {
     shadows.set(post, {...shadows.get(post), ...value})
   }
@@ -90,6 +92,7 @@ function mergeShadow<TProfileView extends AppBskyActorDefs.ProfileView>(
 }
 
 function* findProfilesInCache(
+  queryClient: QueryClient,
   did: string,
 ): Generator<AppBskyActorDefs.ProfileView, void> {
   yield* findAllProfilesInListMembersQueryData(queryClient, did)
diff --git a/src/state/dialogs/index.tsx b/src/state/dialogs/index.tsx
index 4cafaa086..26bb6792f 100644
--- a/src/state/dialogs/index.tsx
+++ b/src/state/dialogs/index.tsx
@@ -1,20 +1,39 @@
 import React from 'react'
-import {DialogControlProps} from '#/components/Dialog'
+import {SharedValue, useSharedValue} from 'react-native-reanimated'
 
-const DialogContext = React.createContext<{
+import {DialogControlRefProps} from '#/components/Dialog'
+import {Provider as GlobalDialogsProvider} from '#/components/dialogs/Context'
+
+interface IDialogContext {
+  /**
+   * The currently active `useDialogControl` hooks.
+   */
   activeDialogs: React.MutableRefObject<
-    Map<string, React.MutableRefObject<DialogControlProps>>
+    Map<string, React.MutableRefObject<DialogControlRefProps>>
   >
-}>({
-  activeDialogs: {
-    current: new Map(),
-  },
-})
+  /**
+   * The currently open dialogs, referenced by their IDs, generated from
+   * `useId`.
+   */
+  openDialogs: React.MutableRefObject<Set<string>>
+  /**
+   * The counterpart to `accessibilityViewIsModal` for Android. This property
+   * applies to the parent of all non-modal views, and prevents TalkBack from
+   * navigating within content beneath an open dialog.
+   *
+   * @see https://reactnative.dev/docs/accessibility#importantforaccessibility-android
+   */
+  importantForAccessibility: SharedValue<'auto' | 'no-hide-descendants'>
+}
+
+const DialogContext = React.createContext<IDialogContext>({} as IDialogContext)
 
 const DialogControlContext = React.createContext<{
-  closeAllDialogs(): void
+  closeAllDialogs(): boolean
+  setDialogIsOpen(id: string, isOpen: boolean): void
 }>({
-  closeAllDialogs: () => {},
+  closeAllDialogs: () => false,
+  setDialogIsOpen: () => {},
 })
 
 export function useDialogStateContext() {
@@ -27,17 +46,53 @@ export function useDialogStateControlContext() {
 
 export function Provider({children}: React.PropsWithChildren<{}>) {
   const activeDialogs = React.useRef<
-    Map<string, React.MutableRefObject<DialogControlProps>>
+    Map<string, React.MutableRefObject<DialogControlRefProps>>
   >(new Map())
+  const openDialogs = React.useRef<Set<string>>(new Set())
+  const importantForAccessibility = useSharedValue<
+    'auto' | 'no-hide-descendants'
+  >('auto')
+
   const closeAllDialogs = React.useCallback(() => {
-    activeDialogs.current.forEach(dialog => dialog.current.close())
+    openDialogs.current.forEach(id => {
+      const dialog = activeDialogs.current.get(id)
+      if (dialog) dialog.current.close()
+    })
+    return openDialogs.current.size > 0
   }, [])
-  const context = React.useMemo(() => ({activeDialogs}), [])
-  const controls = React.useMemo(() => ({closeAllDialogs}), [closeAllDialogs])
+
+  const setDialogIsOpen = React.useCallback(
+    (id: string, isOpen: boolean) => {
+      if (isOpen) {
+        openDialogs.current.add(id)
+        importantForAccessibility.value = 'no-hide-descendants'
+      } else {
+        openDialogs.current.delete(id)
+        if (openDialogs.current.size < 1) {
+          importantForAccessibility.value = 'auto'
+        }
+      }
+    },
+    [importantForAccessibility],
+  )
+
+  const context = React.useMemo<IDialogContext>(
+    () => ({
+      activeDialogs,
+      openDialogs,
+      importantForAccessibility,
+    }),
+    [importantForAccessibility, activeDialogs, openDialogs],
+  )
+  const controls = React.useMemo(
+    () => ({closeAllDialogs, setDialogIsOpen}),
+    [closeAllDialogs, setDialogIsOpen],
+  )
+
   return (
     <DialogContext.Provider value={context}>
       <DialogControlContext.Provider value={controls}>
-        {children}
+        <GlobalDialogsProvider>{children}</GlobalDialogsProvider>
       </DialogControlContext.Provider>
     </DialogContext.Provider>
   )
diff --git a/src/state/modals/index.tsx b/src/state/modals/index.tsx
index 691add005..524dcb1ba 100644
--- a/src/state/modals/index.tsx
+++ b/src/state/modals/index.tsx
@@ -1,6 +1,5 @@
 import React from 'react'
-import {AppBskyActorDefs, AppBskyGraphDefs, ModerationUI} from '@atproto/api'
-import {StyleProp, ViewStyle} from 'react-native'
+import {AppBskyActorDefs, AppBskyGraphDefs} from '@atproto/api'
 import {Image as RNImage} from 'react-native-image-crop-picker'
 
 import {ImageModel} from '#/state/models/media/image'
@@ -9,49 +8,12 @@ import {useNonReactiveCallback} from '#/lib/hooks/useNonReactiveCallback'
 import {EmbedPlayerSource} from '#/lib/strings/embed-player'
 import {ThreadgateSetting} from '../queries/threadgate'
 
-export interface ConfirmModal {
-  name: 'confirm'
-  title: string
-  message: string | (() => JSX.Element)
-  onPressConfirm: () => void | Promise<void>
-  onPressCancel?: () => void | Promise<void>
-  confirmBtnText?: string
-  confirmBtnStyle?: StyleProp<ViewStyle>
-  cancelBtnText?: string
-}
-
 export interface EditProfileModal {
   name: 'edit-profile'
   profile: AppBskyActorDefs.ProfileViewDetailed
   onUpdate?: () => void
 }
 
-export interface ModerationDetailsModal {
-  name: 'moderation-details'
-  context: 'account' | 'content'
-  moderation: ModerationUI
-}
-
-export type ReportModal = {
-  name: 'report'
-} & (
-  | {
-      uri: string
-      cid: string
-    }
-  | {did: string}
-)
-
-export type AppealLabelModal = {
-  name: 'appeal-label'
-} & (
-  | {
-      uri: string
-      cid: string
-    }
-  | {did: string}
-)
-
 export interface CreateOrEditListModal {
   name: 'create-or-edit-list'
   purpose?: string
@@ -135,10 +97,6 @@ export interface AddAppPasswordModal {
   name: 'add-app-password'
 }
 
-export interface ContentFilteringSettingsModal {
-  name: 'content-filtering-settings'
-}
-
 export interface ContentLanguagesSettingsModal {
   name: 'content-languages-settings'
 }
@@ -147,10 +105,6 @@ export interface PostLanguagesSettingsModal {
   name: 'post-languages-settings'
 }
 
-export interface BirthDateSettingsModal {
-  name: 'birth-date-settings'
-}
-
 export interface VerifyEmailModal {
   name: 'verify-email'
   showReminder?: boolean
@@ -191,22 +145,15 @@ export type Modal =
   | ChangeHandleModal
   | DeleteAccountModal
   | EditProfileModal
-  | BirthDateSettingsModal
   | VerifyEmailModal
   | ChangeEmailModal
   | ChangePasswordModal
   | SwitchAccountModal
 
   // Curation
-  | ContentFilteringSettingsModal
   | ContentLanguagesSettingsModal
   | PostLanguagesSettingsModal
 
-  // Moderation
-  | ModerationDetailsModal
-  | ReportModal
-  | AppealLabelModal
-
   // Lists
   | CreateOrEditListModal
   | UserAddRemoveListsModal
@@ -225,7 +172,6 @@ export type Modal =
   | InviteCodesModal
 
   // Generic
-  | ConfirmModal
   | LinkWarningModal
   | EmbedConsentModal
   | InAppBrowserConsentModal
diff --git a/src/state/models/media/gallery.ts b/src/state/models/media/gallery.ts
index 04023bf82..9c8c13010 100644
--- a/src/state/models/media/gallery.ts
+++ b/src/state/models/media/gallery.ts
@@ -4,11 +4,21 @@ import {Image as RNImage} from 'react-native-image-crop-picker'
 import {openPicker} from 'lib/media/picker'
 import {getImageDim} from 'lib/media/manip'
 
+interface InitialImageUri {
+  uri: string
+  width: number
+  height: number
+}
+
 export class GalleryModel {
   images: ImageModel[] = []
 
-  constructor() {
+  constructor(uris?: {uri: string; width: number; height: number}[]) {
     makeAutoObservable(this)
+
+    if (uris) {
+      this.addFromUris(uris)
+    }
   }
 
   get isEmpty() {
@@ -23,7 +33,7 @@ export class GalleryModel {
     return this.images.some(image => image.altText.trim() === '')
   }
 
-  async add(image_: Omit<RNImage, 'size'>) {
+  *add(image_: Omit<RNImage, 'size'>) {
     if (this.size >= 4) {
       return
     }
@@ -86,4 +96,15 @@ export class GalleryModel {
       }),
     )
   }
+
+  async addFromUris(uris: InitialImageUri[]) {
+    for (const uriObj of uris) {
+      this.add({
+        mime: 'image/jpeg',
+        height: uriObj.height,
+        width: uriObj.width,
+        path: uriObj.uri,
+      })
+    }
+  }
 }
diff --git a/src/state/persisted/__tests__/migrate.test.ts b/src/state/persisted/__tests__/migrate.test.ts
index e4b55d5da..97767e273 100644
--- a/src/state/persisted/__tests__/migrate.test.ts
+++ b/src/state/persisted/__tests__/migrate.test.ts
@@ -26,7 +26,7 @@ test('migrate: fresh install', async () => {
 
   expect(AsyncStorage.getItem).toHaveBeenCalledWith('root')
   expect(read).toHaveBeenCalledTimes(1)
-  expect(logger.info).toHaveBeenCalledWith(
+  expect(logger.debug).toHaveBeenCalledWith(
     'persisted state: no migration needed',
   )
 })
@@ -38,7 +38,7 @@ test('migrate: fresh install, existing new storage', async () => {
 
   expect(AsyncStorage.getItem).toHaveBeenCalledWith('root')
   expect(read).toHaveBeenCalledTimes(1)
-  expect(logger.info).toHaveBeenCalledWith(
+  expect(logger.debug).toHaveBeenCalledWith(
     'persisted state: no migration needed',
   )
 })
@@ -68,7 +68,7 @@ test('migrate: has legacy data', async () => {
   await migrate()
 
   expect(write).toHaveBeenCalledWith(transform(fixtures.LEGACY_DATA_DUMP))
-  expect(logger.info).toHaveBeenCalledWith(
+  expect(logger.debug).toHaveBeenCalledWith(
     'persisted state: migrated legacy storage',
   )
 })
diff --git a/src/state/persisted/index.ts b/src/state/persisted/index.ts
index 2f34c2dbf..f57172d2f 100644
--- a/src/state/persisted/index.ts
+++ b/src/state/persisted/index.ts
@@ -19,7 +19,7 @@ const _emitter = new EventEmitter()
  * the Provider.
  */
 export async function init() {
-  logger.info('persisted state: initializing')
+  logger.debug('persisted state: initializing')
 
   broadcast.onmessage = onBroadcastMessage
 
@@ -27,11 +27,11 @@ export async function init() {
     await migrate() // migrate old store
     const stored = await store.read() // check for new store
     if (!stored) {
-      logger.info('persisted state: initializing default storage')
+      logger.debug('persisted state: initializing default storage')
       await store.write(defaults) // opt: init new store
     }
     _state = stored || defaults // return new store
-    logger.log('persisted state: initialized')
+    logger.debug('persisted state: initialized')
   } catch (e) {
     logger.error('persisted state: failed to load root state from storage', {
       message: e,
diff --git a/src/state/persisted/legacy.ts b/src/state/persisted/legacy.ts
index cce080c84..fd94a96a2 100644
--- a/src/state/persisted/legacy.ts
+++ b/src/state/persisted/legacy.ts
@@ -121,7 +121,7 @@ export function transform(legacy: Partial<LegacySchema>): Schema {
  * local storage AND old storage exists.
  */
 export async function migrate() {
-  logger.info('persisted state: check need to migrate')
+  logger.debug('persisted state: check need to migrate')
 
   try {
     const rawLegacyData = await AsyncStorage.getItem(
@@ -131,7 +131,7 @@ export async function migrate() {
     const alreadyMigrated = Boolean(newData)
 
     if (!alreadyMigrated && rawLegacyData) {
-      logger.info('persisted state: migrating legacy storage')
+      logger.debug('persisted state: migrating legacy storage')
 
       const legacyData = JSON.parse(rawLegacyData)
       const newData = transform(legacyData)
@@ -139,14 +139,14 @@ export async function migrate() {
 
       if (validate.success) {
         await write(newData)
-        logger.info('persisted state: migrated legacy storage')
+        logger.debug('persisted state: migrated legacy storage')
       } else {
         logger.error('persisted state: legacy data failed validation', {
           message: validate.error,
         })
       }
     } else {
-      logger.info('persisted state: no migration needed')
+      logger.debug('persisted state: no migration needed')
     }
   } catch (e: any) {
     logger.error(e, {
diff --git a/src/state/preferences/in-app-browser.tsx b/src/state/preferences/in-app-browser.tsx
index 4f033db65..2398f1f81 100644
--- a/src/state/preferences/in-app-browser.tsx
+++ b/src/state/preferences/in-app-browser.tsx
@@ -5,6 +5,11 @@ import * as WebBrowser from 'expo-web-browser'
 import {isNative} from '#/platform/detection'
 import {useModalControls} from '../modals'
 import {usePalette} from 'lib/hooks/usePalette'
+import {
+  isBskyRSSUrl,
+  isRelativeUrl,
+  createBskyAppAbsoluteUrl,
+} from 'lib/strings/url-helpers'
 
 type StateContext = persisted.Schema['useInAppBrowser']
 type SetContext = (v: persisted.Schema['useInAppBrowser']) => void
@@ -57,6 +62,10 @@ export function useOpenLink() {
 
   const openLink = React.useCallback(
     (url: string, override?: boolean) => {
+      if (isBskyRSSUrl(url) && isRelativeUrl(url)) {
+        url = createBskyAppAbsoluteUrl(url)
+      }
+
       if (isNative && !url.startsWith('mailto:')) {
         if (override === undefined && enabled === undefined) {
           openModal({
diff --git a/src/state/preferences/index.tsx b/src/state/preferences/index.tsx
index a442b763a..cf1d90151 100644
--- a/src/state/preferences/index.tsx
+++ b/src/state/preferences/index.tsx
@@ -15,6 +15,7 @@ export {
   useSetExternalEmbedPref,
 } from './external-embeds-prefs'
 export * from './hidden-posts'
+export {useLabelDefinitions} from './label-defs'
 
 export function Provider({children}: React.PropsWithChildren<{}>) {
   return (
diff --git a/src/state/preferences/label-defs.tsx b/src/state/preferences/label-defs.tsx
new file mode 100644
index 000000000..d60f8ccb8
--- /dev/null
+++ b/src/state/preferences/label-defs.tsx
@@ -0,0 +1,25 @@
+import React from 'react'
+import {InterpretedLabelValueDefinition, AppBskyLabelerDefs} from '@atproto/api'
+import {useLabelDefinitionsQuery} from '../queries/preferences'
+
+interface StateContext {
+  labelDefs: Record<string, InterpretedLabelValueDefinition[]>
+  labelers: AppBskyLabelerDefs.LabelerViewDetailed[]
+}
+
+const stateContext = React.createContext<StateContext>({
+  labelDefs: {},
+  labelers: [],
+})
+
+export function Provider({children}: React.PropsWithChildren<{}>) {
+  const {labelDefs, labelers} = useLabelDefinitionsQuery()
+
+  const state = {labelDefs, labelers}
+
+  return <stateContext.Provider value={state}>{children}</stateContext.Provider>
+}
+
+export function useLabelDefinitions() {
+  return React.useContext(stateContext)
+}
diff --git a/src/state/queries/actor-autocomplete.ts b/src/state/queries/actor-autocomplete.ts
index 3159ad7aa..e6bf04ba3 100644
--- a/src/state/queries/actor-autocomplete.ts
+++ b/src/state/queries/actor-autocomplete.ts
@@ -6,17 +6,14 @@ import {logger} from '#/logger'
 import {getAgent} from '#/state/session'
 import {useMyFollowsQuery} from '#/state/queries/my-follows'
 import {STALE} from '#/state/queries'
-import {
-  DEFAULT_LOGGED_OUT_PREFERENCES,
-  getModerationOpts,
-  useModerationOpts,
-} from './preferences'
+import {DEFAULT_LOGGED_OUT_PREFERENCES, useModerationOpts} from './preferences'
 import {isInvalidHandle} from '#/lib/strings/handles'
+import {isJustAMute} from '#/lib/moderation'
 
-const DEFAULT_MOD_OPTS = getModerationOpts({
-  userDid: '',
-  preferences: DEFAULT_LOGGED_OUT_PREFERENCES,
-})
+const DEFAULT_MOD_OPTS = {
+  userDid: undefined,
+  prefs: DEFAULT_LOGGED_OUT_PREFERENCES.moderationPrefs,
+}
 
 export const RQKEY = (prefix: string) => ['actor-autocomplete', prefix]
 
@@ -104,18 +101,12 @@ function computeSuggestions(
   }
   for (const item of searched) {
     if (!items.find(item2 => item2.handle === item.handle)) {
-      items.push({
-        did: item.did,
-        handle: item.handle,
-        displayName: item.displayName,
-        avatar: item.avatar,
-        labels: item.labels,
-      })
+      items.push(item)
     }
   }
   return items.filter(profile => {
-    const mod = moderateProfile(profile, moderationOpts)
-    return !mod.account.filter && mod.account.cause?.type !== 'muted'
+    const modui = moderateProfile(profile, moderationOpts).ui('profileList')
+    return !modui.filter || isJustAMute(modui)
   })
 }
 
diff --git a/src/state/queries/feed.ts b/src/state/queries/feed.ts
index 67294ece2..1fa92c291 100644
--- a/src/state/queries/feed.ts
+++ b/src/state/queries/feed.ts
@@ -1,11 +1,9 @@
-import React from 'react'
 import {
   useQuery,
   useInfiniteQuery,
   InfiniteData,
   QueryKey,
   useMutation,
-  useQueryClient,
 } from '@tanstack/react-query'
 import {
   AtUri,
@@ -15,7 +13,6 @@ import {
   AppBskyUnspeccedGetPopularFeedGenerators,
 } from '@atproto/api'
 
-import {logger} from '#/logger'
 import {router} from '#/routes'
 import {sanitizeDisplayName} from '#/lib/strings/display-names'
 import {sanitizeHandle} from '#/lib/strings/handles'
@@ -219,83 +216,59 @@ const FOLLOWING_FEED_STUB: FeedSourceInfo = {
   likeUri: '',
 }
 
-export function usePinnedFeedsInfos(): {
-  feeds: FeedSourceInfo[]
-  hasPinnedCustom: boolean
-  isLoading: boolean
-} {
-  const queryClient = useQueryClient()
-  const [tabs, setTabs] = React.useState<FeedSourceInfo[]>([
-    FOLLOWING_FEED_STUB,
-  ])
-  const [isLoading, setLoading] = React.useState(true)
-  const {data: preferences} = usePreferencesQuery()
+export function usePinnedFeedsInfos() {
+  const {data: preferences, isLoading: isLoadingPrefs} = usePreferencesQuery()
+  const pinnedUris = preferences?.feeds?.pinned ?? []
 
-  const hasPinnedCustom = React.useMemo<boolean>(() => {
-    return tabs.some(tab => tab !== FOLLOWING_FEED_STUB)
-  }, [tabs])
-
-  React.useEffect(() => {
-    if (!preferences?.feeds?.pinned) return
-    const uris = preferences.feeds.pinned
-
-    async function fetchFeedInfo() {
-      const reqs = []
-
-      for (const uri of uris) {
-        const cached = queryClient.getQueryData<FeedSourceInfo>(
-          feedSourceInfoQueryKey({uri}),
-        )
-
-        if (cached) {
-          reqs.push(cached)
-        } else {
-          reqs.push(
-            (async () => {
-              // these requests can fail, need to filter those out
-              try {
-                return await queryClient.fetchQuery({
-                  staleTime: STALE.SECONDS.FIFTEEN,
-                  queryKey: feedSourceInfoQueryKey({uri}),
-                  queryFn: async () => {
-                    const type = getFeedTypeFromUri(uri)
+  return useQuery({
+    staleTime: STALE.INFINITY,
+    enabled: !isLoadingPrefs,
+    queryKey: ['pinnedFeedsInfos', pinnedUris.join(',')],
+    queryFn: async () => {
+      let resolved = new Map()
+
+      // Get all feeds. We can do this in a batch.
+      const feedUris = pinnedUris.filter(
+        uri => getFeedTypeFromUri(uri) === 'feed',
+      )
+      let feedsPromise = Promise.resolve()
+      if (feedUris.length > 0) {
+        feedsPromise = getAgent()
+          .app.bsky.feed.getFeedGenerators({
+            feeds: feedUris,
+          })
+          .then(res => {
+            for (let feedView of res.data.feeds) {
+              resolved.set(feedView.uri, hydrateFeedGenerator(feedView))
+            }
+          })
+      }
 
-                    if (type === 'feed') {
-                      const res =
-                        await getAgent().app.bsky.feed.getFeedGenerator({
-                          feed: uri,
-                        })
-                      return hydrateFeedGenerator(res.data.view)
-                    } else {
-                      const res = await getAgent().app.bsky.graph.getList({
-                        list: uri,
-                        limit: 1,
-                      })
-                      return hydrateList(res.data.list)
-                    }
-                  },
-                })
-              } catch (e) {
-                // expected failure
-                logger.info(`usePinnedFeedsInfos: failed to fetch ${uri}`, {
-                  error: e,
-                })
-              }
-            })(),
-          )
+      // Get all lists. This currently has to be done individually.
+      const listUris = pinnedUris.filter(
+        uri => getFeedTypeFromUri(uri) === 'list',
+      )
+      const listsPromises = listUris.map(listUri =>
+        getAgent()
+          .app.bsky.graph.getList({
+            list: listUri,
+            limit: 1,
+          })
+          .then(res => {
+            const listView = res.data.list
+            resolved.set(listView.uri, hydrateList(listView))
+          }),
+      )
+
+      // The returned result will have the original order.
+      const result = [FOLLOWING_FEED_STUB]
+      await Promise.allSettled([feedsPromise, ...listsPromises])
+      for (let pinnedUri of pinnedUris) {
+        if (resolved.has(pinnedUri)) {
+          result.push(resolved.get(pinnedUri))
         }
       }
-
-      const views = (await Promise.all(reqs)).filter(
-        Boolean,
-      ) as FeedSourceInfo[]
-
-      setTabs([FOLLOWING_FEED_STUB].concat(views))
-      setLoading(false)
-    }
-
-    fetchFeedInfo()
-  }, [queryClient, setTabs, preferences?.feeds?.pinned])
-
-  return {feeds: tabs, hasPinnedCustom, isLoading}
+      return result
+    },
+  })
 }
diff --git a/src/state/queries/labeler.ts b/src/state/queries/labeler.ts
new file mode 100644
index 000000000..b2f93c4a4
--- /dev/null
+++ b/src/state/queries/labeler.ts
@@ -0,0 +1,89 @@
+import {z} from 'zod'
+import {useQuery, useMutation, useQueryClient} from '@tanstack/react-query'
+import {AppBskyLabelerDefs} from '@atproto/api'
+
+import {getAgent} from '#/state/session'
+import {preferencesQueryKey} from '#/state/queries/preferences'
+import {STALE} from '#/state/queries'
+
+export const labelerInfoQueryKey = (did: string) => ['labeler-info', did]
+export const labelersInfoQueryKey = (dids: string[]) => [
+  'labelers-info',
+  dids.sort(),
+]
+export const labelersDetailedInfoQueryKey = (dids: string[]) => [
+  'labelers-detailed-info',
+  dids,
+]
+
+export function useLabelerInfoQuery({
+  did,
+  enabled,
+}: {
+  did?: string
+  enabled?: boolean
+}) {
+  return useQuery({
+    enabled: !!did && enabled !== false,
+    queryKey: labelerInfoQueryKey(did as string),
+    queryFn: async () => {
+      const res = await getAgent().app.bsky.labeler.getServices({
+        dids: [did as string],
+        detailed: true,
+      })
+      return res.data.views[0] as AppBskyLabelerDefs.LabelerViewDetailed
+    },
+  })
+}
+
+export function useLabelersInfoQuery({dids}: {dids: string[]}) {
+  return useQuery({
+    enabled: !!dids.length,
+    queryKey: labelersInfoQueryKey(dids),
+    queryFn: async () => {
+      const res = await getAgent().app.bsky.labeler.getServices({dids})
+      return res.data.views as AppBskyLabelerDefs.LabelerView[]
+    },
+  })
+}
+
+export function useLabelersDetailedInfoQuery({dids}: {dids: string[]}) {
+  return useQuery({
+    enabled: !!dids.length,
+    queryKey: labelersDetailedInfoQueryKey(dids),
+    gcTime: 1000 * 60 * 60 * 6, // 6 hours
+    staleTime: STALE.MINUTES.ONE,
+    queryFn: async () => {
+      const res = await getAgent().app.bsky.labeler.getServices({
+        dids,
+        detailed: true,
+      })
+      return res.data.views as AppBskyLabelerDefs.LabelerViewDetailed[]
+    },
+  })
+}
+
+export function useLabelerSubscriptionMutation() {
+  const queryClient = useQueryClient()
+
+  return useMutation({
+    async mutationFn({did, subscribe}: {did: string; subscribe: boolean}) {
+      // TODO
+      z.object({
+        did: z.string(),
+        subscribe: z.boolean(),
+      }).parse({did, subscribe})
+
+      if (subscribe) {
+        await getAgent().addLabeler(did)
+      } else {
+        await getAgent().removeLabeler(did)
+      }
+    },
+    onSuccess() {
+      queryClient.invalidateQueries({
+        queryKey: preferencesQueryKey,
+      })
+    },
+  })
+}
diff --git a/src/state/queries/notifications/feed.ts b/src/state/queries/notifications/feed.ts
index b91db9237..405d054d4 100644
--- a/src/state/queries/notifications/feed.ts
+++ b/src/state/queries/notifications/feed.ts
@@ -133,23 +133,6 @@ export function useNotificationFeedQuery(opts?: {enabled?: boolean}) {
   return query
 }
 
-/**
- * This helper is used by the post-thread placeholder function to
- * find a post in the query-data cache
- */
-export function findPostInQueryData(
-  queryClient: QueryClient,
-  uri: string,
-): AppBskyFeedDefs.PostView | undefined {
-  const generator = findAllPostsInQueryData(queryClient, uri)
-  const result = generator.next()
-  if (result.done) {
-    return undefined
-  } else {
-    return result.value
-  }
-}
-
 export function* findAllPostsInQueryData(
   queryClient: QueryClient,
   uri: string,
diff --git a/src/state/queries/notifications/util.ts b/src/state/queries/notifications/util.ts
index 626d3e911..97fc57dc1 100644
--- a/src/state/queries/notifications/util.ts
+++ b/src/state/queries/notifications/util.ts
@@ -1,14 +1,13 @@
 import {
   AppBskyNotificationListNotifications,
   ModerationOpts,
-  moderateProfile,
+  moderateNotification,
   AppBskyFeedDefs,
   AppBskyFeedPost,
   AppBskyFeedRepost,
   AppBskyFeedLike,
   AppBskyEmbedRecord,
 } from '@atproto/api'
-import {moderatePost_wrapped as moderatePost} from '#/lib/moderatePost_wrapped'
 import chunk from 'lodash.chunk'
 import {QueryClient} from '@tanstack/react-query'
 import {getAgent} from '../../session'
@@ -88,37 +87,20 @@ export async function fetchPage({
 // internal methods
 // =
 
-// TODO this should be in the sdk as moderateNotification -prf
-function shouldFilterNotif(
+export function shouldFilterNotif(
   notif: AppBskyNotificationListNotifications.Notification,
   moderationOpts: ModerationOpts | undefined,
 ): boolean {
   if (!moderationOpts) {
     return false
   }
-  const profile = moderateProfile(notif.author, moderationOpts)
-  if (
-    profile.account.filter ||
-    profile.profile.filter ||
-    notif.author.viewer?.muted
-  ) {
-    return true
-  }
-  if (
-    notif.type === 'reply' ||
-    notif.type === 'quote' ||
-    notif.type === 'mention'
-  ) {
-    // NOTE: the notification overlaps the post enough for this to work
-    const post = moderatePost(notif, moderationOpts)
-    if (post.content.filter) {
-      return true
-    }
+  if (notif.author.viewer?.following) {
+    return false
   }
-  return false
+  return moderateNotification(notif, moderationOpts).ui('contentList').filter
 }
 
-function groupNotifications(
+export function groupNotifications(
   notifs: AppBskyNotificationListNotifications.Notification[],
 ): FeedNotification[] {
   const groupedNotifs: FeedNotification[] = []
diff --git a/src/state/queries/post-feed.ts b/src/state/queries/post-feed.ts
index 320009089..b89888197 100644
--- a/src/state/queries/post-feed.ts
+++ b/src/state/queries/post-feed.ts
@@ -1,34 +1,39 @@
 import React, {useCallback, useEffect, useRef} from 'react'
 import {AppState} from 'react-native'
-import {AppBskyFeedDefs, AppBskyFeedPost, PostModeration} from '@atproto/api'
 import {
-  useInfiniteQuery,
+  AppBskyFeedDefs,
+  AppBskyFeedPost,
+  AtUri,
+  ModerationDecision,
+} from '@atproto/api'
+import {
   InfiniteData,
-  QueryKey,
   QueryClient,
+  QueryKey,
+  useInfiniteQuery,
   useQueryClient,
 } from '@tanstack/react-query'
+
+import {HomeFeedAPI} from '#/lib/api/feed/home'
 import {moderatePost_wrapped as moderatePost} from '#/lib/moderatePost_wrapped'
-import {useFeedTuners} from '../preferences/feed-tuners'
-import {FeedTuner, FeedTunerFn, NoopFeedTuner} from 'lib/api/feed-manip'
-import {FeedAPI, ReasonFeedSource} from 'lib/api/feed/types'
-import {FollowingFeedAPI} from 'lib/api/feed/following'
+import {logger} from '#/logger'
+import {STALE} from '#/state/queries'
+import {DEFAULT_LOGGED_OUT_PREFERENCES} from '#/state/queries/preferences/const'
+import {getAgent} from '#/state/session'
 import {AuthorFeedAPI} from 'lib/api/feed/author'
-import {LikesFeedAPI} from 'lib/api/feed/likes'
 import {CustomFeedAPI} from 'lib/api/feed/custom'
+import {FollowingFeedAPI} from 'lib/api/feed/following'
+import {LikesFeedAPI} from 'lib/api/feed/likes'
 import {ListFeedAPI} from 'lib/api/feed/list'
 import {MergeFeedAPI} from 'lib/api/feed/merge'
-import {HomeFeedAPI} from '#/lib/api/feed/home'
-import {logger} from '#/logger'
-import {STALE} from '#/state/queries'
-import {precacheFeedPostProfiles} from './profile'
-import {getAgent} from '#/state/session'
-import {DEFAULT_LOGGED_OUT_PREFERENCES} from '#/state/queries/preferences/const'
-import {getModerationOpts} from '#/state/queries/preferences/moderation'
+import {FeedAPI, ReasonFeedSource} from 'lib/api/feed/types'
+import {FeedTuner, FeedTunerFn, NoopFeedTuner} from 'lib/api/feed-manip'
+import {BSKY_FEED_OWNER_DIDS} from 'lib/constants'
 import {KnownError} from '#/view/com/posts/FeedErrorMessage'
-import {embedViewRecordToPostView, getEmbeddedPost} from './util'
+import {useFeedTuners} from '../preferences/feed-tuners'
 import {useModerationOpts} from './preferences'
-import {queryClient} from 'lib/react-query'
+import {precacheFeedPostProfiles} from './profile'
+import {embedViewRecordToPostView, getEmbeddedPost} from './util'
 
 type ActorDid = string
 type AuthorFilter =
@@ -63,7 +68,7 @@ export interface FeedPostSliceItem {
   post: AppBskyFeedDefs.PostView
   record: AppBskyFeedPost.Record
   reason?: AppBskyFeedDefs.ReasonRepost | ReasonFeedSource
-  moderation: PostModeration
+  moderation: ModerationDecision
 }
 
 export interface FeedPostSlice {
@@ -137,24 +142,41 @@ export function usePostFeedQuery(
             cursor: undefined,
           }
 
-      const res = await api.fetch({cursor, limit: PAGE_SIZE})
-      precacheFeedPostProfiles(queryClient, res.feed)
-
-      /*
-       * If this is a public view, we need to check if posts fail moderation.
-       * If all fail, we throw an error. If only some fail, we continue and let
-       * moderations happen later, which results in some posts being shown and
-       * some not.
-       */
-      if (!getAgent().session) {
-        assertSomePostsPassModeration(res.feed)
-      }
+      try {
+        const res = await api.fetch({cursor, limit: PAGE_SIZE})
+        precacheFeedPostProfiles(queryClient, res.feed)
+
+        /*
+         * If this is a public view, we need to check if posts fail moderation.
+         * If all fail, we throw an error. If only some fail, we continue and let
+         * moderations happen later, which results in some posts being shown and
+         * some not.
+         */
+        if (!getAgent().session) {
+          assertSomePostsPassModeration(res.feed)
+        }
+
+        return {
+          api,
+          cursor: res.cursor,
+          feed: res.feed,
+          fetchedAt: Date.now(),
+        }
+      } catch (e) {
+        const feedDescParts = feedDesc.split('|')
+        const feedOwnerDid = new AtUri(feedDescParts[1]).hostname
 
-      return {
-        api,
-        cursor: res.cursor,
-        feed: res.feed,
-        fetchedAt: Date.now(),
+        if (
+          feedDescParts[0] === 'feedgen' &&
+          BSKY_FEED_OWNER_DIDS.includes(feedOwnerDid)
+        ) {
+          logger.error(`Bluesky feed may be offline: ${feedOwnerDid}`, {
+            feedDesc,
+            jsError: e,
+          })
+        }
+
+        throw e
       }
     },
     initialPageParam: undefined,
@@ -227,9 +249,17 @@ export function usePostFeedQuery(
 
                   // apply moderation filter
                   for (let i = 0; i < slice.items.length; i++) {
+                    const ignoreFilter =
+                      slice.items[i].post.author.did === ignoreFilterFor
+                    if (ignoreFilter) {
+                      // remove mutes to avoid confused UIs
+                      moderations[i].causes = moderations[i].causes.filter(
+                        cause => cause.type !== 'muted',
+                      )
+                    }
                     if (
-                      moderations[i]?.content.filter &&
-                      slice.items[i].post.author.did !== ignoreFilterFor
+                      !ignoreFilter &&
+                      moderations[i]?.ui('contentList').filter
                     ) {
                       return undefined
                     }
@@ -253,7 +283,7 @@ export function usePostFeedQuery(
                             .success
                         ) {
                           return {
-                            _reactKey: `${slice._reactKey}-${i}`,
+                            _reactKey: `${slice._reactKey}-${i}-${item.post.uri}`,
                             uri: item.post.uri,
                             post: item.post,
                             record: item.post.record,
@@ -365,23 +395,6 @@ function createApi(
   }
 }
 
-/**
- * This helper is used by the post-thread placeholder function to
- * find a post in the query-data cache
- */
-export function findPostInQueryData(
-  queryClient: QueryClient,
-  uri: string,
-): AppBskyFeedDefs.PostView | undefined {
-  const generator = findAllPostsInQueryData(queryClient, uri)
-  const result = generator.next()
-  if (result.done) {
-    return undefined
-  } else {
-    return result.value
-  }
-}
-
 export function* findAllPostsInQueryData(
   queryClient: QueryClient,
   uri: string,
@@ -429,13 +442,12 @@ function assertSomePostsPassModeration(feed: AppBskyFeedDefs.FeedViewPost[]) {
   let somePostsPassModeration = false
 
   for (const item of feed) {
-    const moderationOpts = getModerationOpts({
-      userDid: '',
-      preferences: DEFAULT_LOGGED_OUT_PREFERENCES,
+    const moderation = moderatePost(item.post, {
+      userDid: undefined,
+      prefs: DEFAULT_LOGGED_OUT_PREFERENCES.moderationPrefs,
     })
-    const moderation = moderatePost(item.post, moderationOpts)
 
-    if (!moderation.content.filter) {
+    if (!moderation.ui('contentList').filter) {
       // we have a sfw post
       somePostsPassModeration = true
     }
@@ -446,7 +458,11 @@ function assertSomePostsPassModeration(feed: AppBskyFeedDefs.FeedViewPost[]) {
   }
 }
 
-export function resetProfilePostsQueries(did: string, timeout = 0) {
+export function resetProfilePostsQueries(
+  queryClient: QueryClient,
+  did: string,
+  timeout = 0,
+) {
   setTimeout(() => {
     queryClient.resetQueries({
       predicate: query =>
diff --git a/src/state/queries/post-liked-by.ts b/src/state/queries/post-liked-by.ts
index 2cde07f28..a0498ada4 100644
--- a/src/state/queries/post-liked-by.ts
+++ b/src/state/queries/post-liked-by.ts
@@ -12,9 +12,9 @@ const PAGE_SIZE = 30
 type RQPageParam = string | undefined
 
 // TODO refactor invalidate on mutate?
-export const RQKEY = (resolvedUri: string) => ['post-liked-by', resolvedUri]
+export const RQKEY = (resolvedUri: string) => ['liked-by', resolvedUri]
 
-export function usePostLikedByQuery(resolvedUri: string | undefined) {
+export function useLikedByQuery(resolvedUri: string | undefined) {
   return useInfiniteQuery<
     AppBskyFeedGetLikes.OutputSchema,
     Error,
diff --git a/src/state/queries/post-thread.ts b/src/state/queries/post-thread.ts
index ba4243163..26d40599c 100644
--- a/src/state/queries/post-thread.ts
+++ b/src/state/queries/post-thread.ts
@@ -8,8 +8,8 @@ import {useQuery, useQueryClient, QueryClient} from '@tanstack/react-query'
 
 import {getAgent} from '#/state/session'
 import {UsePreferencesQueryResponse} from '#/state/queries/preferences/types'
-import {findPostInQueryData as findPostInFeedQueryData} from './post-feed'
-import {findPostInQueryData as findPostInNotifsQueryData} from './notifications/feed'
+import {findAllPostsInQueryData as findAllPostsInFeedQueryData} from './post-feed'
+import {findAllPostsInQueryData as findAllPostsInNotifsQueryData} from './notifications/feed'
 import {precacheThreadPostProfiles} from './profile'
 import {getEmbeddedPost} from './util'
 
@@ -82,21 +82,9 @@ export function usePostThreadQuery(uri: string | undefined) {
         return undefined
       }
       {
-        const item = findPostInQueryData(queryClient, uri)
-        if (item) {
-          return threadNodeToPlaceholderThread(item)
-        }
-      }
-      {
-        const item = findPostInFeedQueryData(queryClient, uri)
-        if (item) {
-          return postViewToPlaceholderThread(item)
-        }
-      }
-      {
-        const item = findPostInNotifsQueryData(queryClient, uri)
-        if (item) {
-          return postViewToPlaceholderThread(item)
+        const post = findPostInQueryData(queryClient, uri)
+        if (post) {
+          return post
         }
       }
       return undefined
@@ -171,11 +159,18 @@ function responseToThreadNodes(
     AppBskyFeedPost.isRecord(node.post.record) &&
     AppBskyFeedPost.validateRecord(node.post.record).success
   ) {
+    const post = node.post
+    // These should normally be present. They're missing only for
+    // posts that were *just* created. Ideally, the backend would
+    // know to return zeros. Fill them in manually to compensate.
+    post.replyCount ??= 0
+    post.likeCount ??= 0
+    post.repostCount ??= 0
     return {
       type: 'post',
       _reactKey: node.post.uri,
       uri: node.post.uri,
-      post: node.post,
+      post: post,
       record: node.post.record,
       parent:
         node.parent && direction !== 'down'
@@ -213,14 +208,24 @@ function responseToThreadNodes(
 function findPostInQueryData(
   queryClient: QueryClient,
   uri: string,
-): ThreadNode | undefined {
-  const generator = findAllPostsInQueryData(queryClient, uri)
-  const result = generator.next()
-  if (result.done) {
-    return undefined
-  } else {
-    return result.value
+): ThreadNode | void {
+  let partial
+  for (let item of findAllPostsInQueryData(queryClient, uri)) {
+    if (item.type === 'post') {
+      // Currently, the backend doesn't send full post info in some cases
+      // (for example, for quoted posts). We use missing `likeCount`
+      // as a way to detect that. In the future, we should fix this on
+      // the backend, which will let us always stop on the first result.
+      const hasAllInfo = item.post.likeCount != null
+      if (hasAllInfo) {
+        return item
+      } else {
+        partial = item
+        // Keep searching, we might still find a full post in the cache.
+      }
+    }
   }
+  return partial
 }
 
 export function* findAllPostsInQueryData(
@@ -236,7 +241,10 @@ export function* findAllPostsInQueryData(
     }
     for (const item of traverseThread(queryData)) {
       if (item.uri === uri) {
-        yield item
+        const placeholder = threadNodeToPlaceholderThread(item)
+        if (placeholder) {
+          yield placeholder
+        }
       }
       const quotedPost =
         item.type === 'post' ? getEmbeddedPost(item.post.embed) : undefined
@@ -245,6 +253,12 @@ export function* findAllPostsInQueryData(
       }
     }
   }
+  for (let post of findAllPostsInFeedQueryData(queryClient, uri)) {
+    yield postViewToPlaceholderThread(post)
+  }
+  for (let post of findAllPostsInNotifsQueryData(queryClient, uri)) {
+    yield postViewToPlaceholderThread(post)
+  }
 }
 
 function* traverseThread(node: ThreadNode): Generator<ThreadNode, void> {
diff --git a/src/state/queries/post.ts b/src/state/queries/post.ts
index eb59f7da4..b868a1dac 100644
--- a/src/state/queries/post.ts
+++ b/src/state/queries/post.ts
@@ -1,11 +1,13 @@
 import {useCallback} from 'react'
 import {AppBskyFeedDefs, AtUri} from '@atproto/api'
-import {useQuery, useMutation, useQueryClient} from '@tanstack/react-query'
-import {Shadow} from '#/state/cache/types'
-import {getAgent} from '#/state/session'
-import {updatePostShadow} from '#/state/cache/post-shadow'
+import {useMutation, useQuery, useQueryClient} from '@tanstack/react-query'
+
 import {track} from '#/lib/analytics/analytics'
 import {useToggleMutationQueue} from '#/lib/hooks/useToggleMutationQueue'
+import {logEvent, LogEvents} from '#/lib/statsig/statsig'
+import {updatePostShadow} from '#/state/cache/post-shadow'
+import {Shadow} from '#/state/cache/types'
+import {getAgent} from '#/state/session'
 
 export const RQKEY = (postUri: string) => ['post', postUri]
 
@@ -58,12 +60,15 @@ export function useGetPost() {
 
 export function usePostLikeMutationQueue(
   post: Shadow<AppBskyFeedDefs.PostView>,
+  logContext: LogEvents['post:like']['logContext'] &
+    LogEvents['post:unlike']['logContext'],
 ) {
+  const queryClient = useQueryClient()
   const postUri = post.uri
   const postCid = post.cid
   const initialLikeUri = post.viewer?.like
-  const likeMutation = usePostLikeMutation()
-  const unlikeMutation = usePostUnlikeMutation()
+  const likeMutation = usePostLikeMutation(logContext)
+  const unlikeMutation = usePostUnlikeMutation(logContext)
 
   const queueToggle = useToggleMutationQueue({
     initialState: initialLikeUri,
@@ -86,7 +91,7 @@ export function usePostLikeMutationQueue(
     },
     onSuccess(finalLikeUri) {
       // finalize
-      updatePostShadow(postUri, {
+      updatePostShadow(queryClient, postUri, {
         likeUri: finalLikeUri,
       })
     },
@@ -94,39 +99,47 @@ export function usePostLikeMutationQueue(
 
   const queueLike = useCallback(() => {
     // optimistically update
-    updatePostShadow(postUri, {
+    updatePostShadow(queryClient, postUri, {
       likeUri: 'pending',
     })
     return queueToggle(true)
-  }, [postUri, queueToggle])
+  }, [queryClient, postUri, queueToggle])
 
   const queueUnlike = useCallback(() => {
     // optimistically update
-    updatePostShadow(postUri, {
+    updatePostShadow(queryClient, postUri, {
       likeUri: undefined,
     })
     return queueToggle(false)
-  }, [postUri, queueToggle])
+  }, [queryClient, postUri, queueToggle])
 
   return [queueLike, queueUnlike]
 }
 
-function usePostLikeMutation() {
+function usePostLikeMutation(logContext: LogEvents['post:like']['logContext']) {
   return useMutation<
     {uri: string}, // responds with the uri of the like
     Error,
     {uri: string; cid: string} // the post's uri and cid
   >({
-    mutationFn: post => getAgent().like(post.uri, post.cid),
+    mutationFn: post => {
+      logEvent('post:like', {logContext})
+      return getAgent().like(post.uri, post.cid)
+    },
     onSuccess() {
       track('Post:Like')
     },
   })
 }
 
-function usePostUnlikeMutation() {
+function usePostUnlikeMutation(
+  logContext: LogEvents['post:unlike']['logContext'],
+) {
   return useMutation<void, Error, {postUri: string; likeUri: string}>({
-    mutationFn: ({likeUri}) => getAgent().deleteLike(likeUri),
+    mutationFn: ({likeUri}) => {
+      logEvent('post:unlike', {logContext})
+      return getAgent().deleteLike(likeUri)
+    },
     onSuccess() {
       track('Post:Unlike')
     },
@@ -135,12 +148,15 @@ function usePostUnlikeMutation() {
 
 export function usePostRepostMutationQueue(
   post: Shadow<AppBskyFeedDefs.PostView>,
+  logContext: LogEvents['post:repost']['logContext'] &
+    LogEvents['post:unrepost']['logContext'],
 ) {
+  const queryClient = useQueryClient()
   const postUri = post.uri
   const postCid = post.cid
   const initialRepostUri = post.viewer?.repost
-  const repostMutation = usePostRepostMutation()
-  const unrepostMutation = usePostUnrepostMutation()
+  const repostMutation = usePostRepostMutation(logContext)
+  const unrepostMutation = usePostUnrepostMutation(logContext)
 
   const queueToggle = useToggleMutationQueue({
     initialState: initialRepostUri,
@@ -163,7 +179,7 @@ export function usePostRepostMutationQueue(
     },
     onSuccess(finalRepostUri) {
       // finalize
-      updatePostShadow(postUri, {
+      updatePostShadow(queryClient, postUri, {
         repostUri: finalRepostUri,
       })
     },
@@ -171,39 +187,49 @@ export function usePostRepostMutationQueue(
 
   const queueRepost = useCallback(() => {
     // optimistically update
-    updatePostShadow(postUri, {
+    updatePostShadow(queryClient, postUri, {
       repostUri: 'pending',
     })
     return queueToggle(true)
-  }, [postUri, queueToggle])
+  }, [queryClient, postUri, queueToggle])
 
   const queueUnrepost = useCallback(() => {
     // optimistically update
-    updatePostShadow(postUri, {
+    updatePostShadow(queryClient, postUri, {
       repostUri: undefined,
     })
     return queueToggle(false)
-  }, [postUri, queueToggle])
+  }, [queryClient, postUri, queueToggle])
 
   return [queueRepost, queueUnrepost]
 }
 
-function usePostRepostMutation() {
+function usePostRepostMutation(
+  logContext: LogEvents['post:repost']['logContext'],
+) {
   return useMutation<
     {uri: string}, // responds with the uri of the repost
     Error,
     {uri: string; cid: string} // the post's uri and cid
   >({
-    mutationFn: post => getAgent().repost(post.uri, post.cid),
+    mutationFn: post => {
+      logEvent('post:repost', {logContext})
+      return getAgent().repost(post.uri, post.cid)
+    },
     onSuccess() {
       track('Post:Repost')
     },
   })
 }
 
-function usePostUnrepostMutation() {
+function usePostUnrepostMutation(
+  logContext: LogEvents['post:unrepost']['logContext'],
+) {
   return useMutation<void, Error, {postUri: string; repostUri: string}>({
-    mutationFn: ({repostUri}) => getAgent().deleteRepost(repostUri),
+    mutationFn: ({repostUri}) => {
+      logEvent('post:unrepost', {logContext})
+      return getAgent().deleteRepost(repostUri)
+    },
     onSuccess() {
       track('Post:Unrepost')
     },
@@ -211,12 +237,13 @@ function usePostUnrepostMutation() {
 }
 
 export function usePostDeleteMutation() {
+  const queryClient = useQueryClient()
   return useMutation<void, Error, {uri: string}>({
     mutationFn: async ({uri}) => {
       await getAgent().deletePost(uri)
     },
     onSuccess(data, variables) {
-      updatePostShadow(variables.uri, {isDeleted: true})
+      updatePostShadow(queryClient, variables.uri, {isDeleted: true})
       track('Post:Delete')
     },
   })
diff --git a/src/state/queries/preferences/const.ts b/src/state/queries/preferences/const.ts
index 2d9d02994..4cb4d1e96 100644
--- a/src/state/queries/preferences/const.ts
+++ b/src/state/queries/preferences/const.ts
@@ -7,7 +7,7 @@ import {DEFAULT_LOGGED_OUT_LABEL_PREFERENCES} from '#/state/queries/preferences/
 export const DEFAULT_HOME_FEED_PREFS: UsePreferencesQueryResponse['feedViewPrefs'] =
   {
     hideReplies: false,
-    hideRepliesByUnfollowed: false,
+    hideRepliesByUnfollowed: true,
     hideRepliesByLikeCount: 0,
     hideReposts: false,
     hideQuotePosts: false,
@@ -29,21 +29,17 @@ export const DEFAULT_PROD_FEEDS = {
 
 export const DEFAULT_LOGGED_OUT_PREFERENCES: UsePreferencesQueryResponse = {
   birthDate: new Date('2022-11-17'), // TODO(pwi)
-  adultContentEnabled: false,
   feeds: {
     saved: [],
     pinned: [],
     unpinned: [],
   },
-  // labels are undefined until set by user
-  contentLabels: {
-    nsfw: DEFAULT_LOGGED_OUT_LABEL_PREFERENCES.nsfw,
-    nudity: DEFAULT_LOGGED_OUT_LABEL_PREFERENCES.nudity,
-    suggestive: DEFAULT_LOGGED_OUT_LABEL_PREFERENCES.suggestive,
-    gore: DEFAULT_LOGGED_OUT_LABEL_PREFERENCES.gore,
-    hate: DEFAULT_LOGGED_OUT_LABEL_PREFERENCES.hate,
-    spam: DEFAULT_LOGGED_OUT_LABEL_PREFERENCES.spam,
-    impersonation: DEFAULT_LOGGED_OUT_LABEL_PREFERENCES.impersonation,
+  moderationPrefs: {
+    adultContentEnabled: false,
+    labels: DEFAULT_LOGGED_OUT_LABEL_PREFERENCES,
+    labelers: [],
+    mutedWords: [],
+    hiddenPosts: [],
   },
   feedViewPrefs: DEFAULT_HOME_FEED_PREFS,
   threadViewPrefs: DEFAULT_THREAD_VIEW_PREFS,
diff --git a/src/state/queries/preferences/index.ts b/src/state/queries/preferences/index.ts
index 632d31a13..f9cd59cda 100644
--- a/src/state/queries/preferences/index.ts
+++ b/src/state/queries/preferences/index.ts
@@ -1,25 +1,29 @@
-import {useMemo} from 'react'
+import {useMemo, createContext, useContext} from 'react'
 import {useQuery, useMutation, useQueryClient} from '@tanstack/react-query'
-import {LabelPreference, BskyFeedViewPreference} from '@atproto/api'
+import {
+  LabelPreference,
+  BskyFeedViewPreference,
+  ModerationOpts,
+  AppBskyActorDefs,
+  BSKY_LABELER_DID,
+} from '@atproto/api'
 
 import {track} from '#/lib/analytics/analytics'
 import {getAge} from '#/lib/strings/time'
-import {useSession, getAgent} from '#/state/session'
-import {DEFAULT_LABEL_PREFERENCES} from '#/state/queries/preferences/moderation'
+import {getAgent, useSession} from '#/state/session'
 import {
-  ConfigurableLabelGroup,
   UsePreferencesQueryResponse,
   ThreadViewPreferences,
 } from '#/state/queries/preferences/types'
-import {temp__migrateLabelPref} from '#/state/queries/preferences/util'
 import {
   DEFAULT_HOME_FEED_PREFS,
   DEFAULT_THREAD_VIEW_PREFS,
   DEFAULT_LOGGED_OUT_PREFERENCES,
 } from '#/state/queries/preferences/const'
-import {getModerationOpts} from '#/state/queries/preferences/moderation'
+import {DEFAULT_LOGGED_OUT_LABEL_PREFERENCES} from '#/state/queries/preferences/moderation'
 import {STALE} from '#/state/queries'
-import {useHiddenPosts} from '#/state/preferences/hidden-posts'
+import {useHiddenPosts, useLabelDefinitions} from '#/state/preferences'
+import {saveLabelers} from '#/state/session/agent-config'
 
 export * from '#/state/queries/preferences/types'
 export * from '#/state/queries/preferences/moderation'
@@ -40,6 +44,13 @@ export function usePreferencesQuery() {
         return DEFAULT_LOGGED_OUT_PREFERENCES
       } else {
         const res = await agent.getPreferences()
+
+        // save to local storage to ensure there are labels on initial requests
+        saveLabelers(
+          agent.session.did,
+          res.moderationPrefs.labelers.map(l => l.did),
+        )
+
         const preferences: UsePreferencesQueryResponse = {
           ...res,
           feeds: {
@@ -50,32 +61,6 @@ export function usePreferencesQuery() {
                 return !res.feeds.pinned?.includes(f)
               }) || [],
           },
-          // labels are undefined until set by user
-          contentLabels: {
-            nsfw: temp__migrateLabelPref(
-              res.contentLabels?.nsfw || DEFAULT_LABEL_PREFERENCES.nsfw,
-            ),
-            nudity: temp__migrateLabelPref(
-              res.contentLabels?.nudity || DEFAULT_LABEL_PREFERENCES.nudity,
-            ),
-            suggestive: temp__migrateLabelPref(
-              res.contentLabels?.suggestive ||
-                DEFAULT_LABEL_PREFERENCES.suggestive,
-            ),
-            gore: temp__migrateLabelPref(
-              res.contentLabels?.gore || DEFAULT_LABEL_PREFERENCES.gore,
-            ),
-            hate: temp__migrateLabelPref(
-              res.contentLabels?.hate || DEFAULT_LABEL_PREFERENCES.hate,
-            ),
-            spam: temp__migrateLabelPref(
-              res.contentLabels?.spam || DEFAULT_LABEL_PREFERENCES.spam,
-            ),
-            impersonation: temp__migrateLabelPref(
-              res.contentLabels?.impersonation ||
-                DEFAULT_LABEL_PREFERENCES.impersonation,
-            ),
-          },
           feedViewPrefs: {
             ...DEFAULT_HOME_FEED_PREFS,
             ...(res.feedViewPrefs.home || {}),
@@ -92,24 +77,41 @@ export function usePreferencesQuery() {
   })
 }
 
+// used in the moderation state devtool
+export const moderationOptsOverrideContext = createContext<
+  ModerationOpts | undefined
+>(undefined)
+
 export function useModerationOpts() {
+  const override = useContext(moderationOptsOverrideContext)
   const {currentAccount} = useSession()
   const prefs = usePreferencesQuery()
-  const hiddenPosts = useHiddenPosts()
-  const opts = useMemo(() => {
+  const {labelDefs} = useLabelDefinitions()
+  const hiddenPosts = useHiddenPosts() // TODO move this into pds-stored prefs
+  const opts = useMemo<ModerationOpts | undefined>(() => {
+    if (override) {
+      return override
+    }
     if (!prefs.data) {
       return
     }
-    const moderationOpts = getModerationOpts({
-      userDid: currentAccount?.did || '',
-      preferences: prefs.data,
-    })
-
     return {
-      ...moderationOpts,
-      hiddenPosts,
+      userDid: currentAccount?.did,
+      prefs: {
+        ...prefs.data.moderationPrefs,
+        labelers: prefs.data.moderationPrefs.labelers.length
+          ? prefs.data.moderationPrefs.labelers
+          : [
+              {
+                did: BSKY_LABELER_DID,
+                labels: DEFAULT_LOGGED_OUT_LABEL_PREFERENCES,
+              },
+            ],
+        hiddenPosts: hiddenPosts || [],
+      },
+      labelDefs,
     }
-  }, [currentAccount?.did, prefs.data, hiddenPosts])
+  }, [override, currentAccount, labelDefs, prefs.data, hiddenPosts])
   return opts
 }
 
@@ -133,10 +135,32 @@ export function usePreferencesSetContentLabelMutation() {
   return useMutation<
     void,
     unknown,
-    {labelGroup: ConfigurableLabelGroup; visibility: LabelPreference}
+    {label: string; visibility: LabelPreference; labelerDid: string | undefined}
   >({
-    mutationFn: async ({labelGroup, visibility}) => {
-      await getAgent().setContentLabelPref(labelGroup, visibility)
+    mutationFn: async ({label, visibility, labelerDid}) => {
+      await getAgent().setContentLabelPref(label, visibility, labelerDid)
+      // triggers a refetch
+      await queryClient.invalidateQueries({
+        queryKey: preferencesQueryKey,
+      })
+    },
+  })
+}
+
+export function useSetContentLabelMutation() {
+  const queryClient = useQueryClient()
+
+  return useMutation({
+    mutationFn: async ({
+      label,
+      visibility,
+      labelerDid,
+    }: {
+      label: string
+      visibility: LabelPreference
+      labelerDid?: string
+    }) => {
+      await getAgent().setContentLabelPref(label, visibility, labelerDid)
       // triggers a refetch
       await queryClient.invalidateQueries({
         queryKey: preferencesQueryKey,
@@ -164,7 +188,7 @@ export function usePreferencesSetBirthDateMutation() {
 
   return useMutation<void, unknown, {birthDate: Date}>({
     mutationFn: async ({birthDate}: {birthDate: Date}) => {
-      await getAgent().setPersonalDetails({birthDate})
+      await getAgent().setPersonalDetails({birthDate: birthDate.toISOString()})
       // triggers a refetch
       await queryClient.invalidateQueries({
         queryKey: preferencesQueryKey,
@@ -278,3 +302,45 @@ export function useUnpinFeedMutation() {
     },
   })
 }
+
+export function useUpsertMutedWordsMutation() {
+  const queryClient = useQueryClient()
+
+  return useMutation({
+    mutationFn: async (mutedWords: AppBskyActorDefs.MutedWord[]) => {
+      await getAgent().upsertMutedWords(mutedWords)
+      // triggers a refetch
+      await queryClient.invalidateQueries({
+        queryKey: preferencesQueryKey,
+      })
+    },
+  })
+}
+
+export function useUpdateMutedWordMutation() {
+  const queryClient = useQueryClient()
+
+  return useMutation({
+    mutationFn: async (mutedWord: AppBskyActorDefs.MutedWord) => {
+      await getAgent().updateMutedWord(mutedWord)
+      // triggers a refetch
+      await queryClient.invalidateQueries({
+        queryKey: preferencesQueryKey,
+      })
+    },
+  })
+}
+
+export function useRemoveMutedWordMutation() {
+  const queryClient = useQueryClient()
+
+  return useMutation({
+    mutationFn: async (mutedWord: AppBskyActorDefs.MutedWord) => {
+      await getAgent().removeMutedWord(mutedWord)
+      // triggers a refetch
+      await queryClient.invalidateQueries({
+        queryKey: preferencesQueryKey,
+      })
+    },
+  })
+}
diff --git a/src/state/queries/preferences/moderation.ts b/src/state/queries/preferences/moderation.ts
index cdae52937..9cd183e8b 100644
--- a/src/state/queries/preferences/moderation.ts
+++ b/src/state/queries/preferences/moderation.ts
@@ -1,181 +1,53 @@
+import React from 'react'
 import {
-  LabelPreference,
-  ComAtprotoLabelDefs,
-  ModerationOpts,
+  DEFAULT_LABEL_SETTINGS,
+  BskyAgent,
+  interpretLabelValueDefinitions,
 } from '@atproto/api'
 
-import {
-  LabelGroup,
-  ConfigurableLabelGroup,
-  UsePreferencesQueryResponse,
-} from '#/state/queries/preferences/types'
-
-export type Label = ComAtprotoLabelDefs.Label
-
-export type LabelGroupConfig = {
-  id: LabelGroup
-  title: string
-  isAdultImagery?: boolean
-  subtitle?: string
-  warning: string
-  values: string[]
-}
-
-export const DEFAULT_LABEL_PREFERENCES: Record<
-  ConfigurableLabelGroup,
-  LabelPreference
-> = {
-  nsfw: 'hide',
-  nudity: 'warn',
-  suggestive: 'warn',
-  gore: 'warn',
-  hate: 'hide',
-  spam: 'hide',
-  impersonation: 'hide',
-}
+import {usePreferencesQuery} from './index'
+import {useLabelersDetailedInfoQuery} from '../labeler'
 
 /**
  * More strict than our default settings for logged in users.
- *
- * TODO(pwi)
  */
-export const DEFAULT_LOGGED_OUT_LABEL_PREFERENCES: Record<
-  ConfigurableLabelGroup,
-  LabelPreference
-> = {
-  nsfw: 'hide',
-  nudity: 'hide',
-  suggestive: 'hide',
-  gore: 'hide',
-  hate: 'hide',
-  spam: 'hide',
-  impersonation: 'hide',
-}
-
-export const ILLEGAL_LABEL_GROUP: LabelGroupConfig = {
-  id: 'illegal',
-  title: 'Illegal Content',
-  warning: 'Illegal Content',
-  values: ['csam', 'dmca-violation', 'nudity-nonconsensual'],
-}
-
-export const ALWAYS_FILTER_LABEL_GROUP: LabelGroupConfig = {
-  id: 'always-filter',
-  title: 'Content Warning',
-  warning: 'Content Warning',
-  values: ['!filter'],
-}
-
-export const ALWAYS_WARN_LABEL_GROUP: LabelGroupConfig = {
-  id: 'always-warn',
-  title: 'Content Warning',
-  warning: 'Content Warning',
-  values: ['!warn', 'account-security'],
-}
-
-export const UNKNOWN_LABEL_GROUP: LabelGroupConfig = {
-  id: 'unknown',
-  title: 'Unknown Label',
-  warning: 'Content Warning',
-  values: [],
-}
-
-export const CONFIGURABLE_LABEL_GROUPS: Record<
-  ConfigurableLabelGroup,
-  LabelGroupConfig
-> = {
-  nsfw: {
-    id: 'nsfw',
-    title: 'Explicit Sexual Images',
-    subtitle: 'i.e. pornography',
-    warning: 'Sexually Explicit',
-    values: ['porn', 'nsfl'],
-    isAdultImagery: true,
-  },
-  nudity: {
-    id: 'nudity',
-    title: 'Other Nudity',
-    subtitle: 'Including non-sexual and artistic',
-    warning: 'Nudity',
-    values: ['nudity'],
-    isAdultImagery: true,
-  },
-  suggestive: {
-    id: 'suggestive',
-    title: 'Sexually Suggestive',
-    subtitle: 'Does not include nudity',
-    warning: 'Sexually Suggestive',
-    values: ['sexual'],
-    isAdultImagery: true,
-  },
-  gore: {
-    id: 'gore',
-    title: 'Violent / Bloody',
-    subtitle: 'Gore, self-harm, torture',
-    warning: 'Violence',
-    values: ['gore', 'self-harm', 'torture', 'nsfl', 'corpse'],
-    isAdultImagery: true,
-  },
-  hate: {
-    id: 'hate',
-    title: 'Hate Group Iconography',
-    subtitle: 'Images of terror groups, articles covering events, etc.',
-    warning: 'Hate Groups',
-    values: ['icon-kkk', 'icon-nazi', 'icon-intolerant', 'behavior-intolerant'],
-  },
-  spam: {
-    id: 'spam',
-    title: 'Spam',
-    subtitle: 'Excessive unwanted interactions',
-    warning: 'Spam',
-    values: ['spam'],
-  },
-  impersonation: {
-    id: 'impersonation',
-    title: 'Impersonation',
-    subtitle: 'Accounts falsely claiming to be people or orgs',
-    warning: 'Impersonation',
-    values: ['impersonation'],
-  },
-}
-
-export function getModerationOpts({
-  userDid,
-  preferences,
-}: {
-  userDid: string
-  preferences: UsePreferencesQueryResponse
-}): ModerationOpts {
-  return {
-    userDid: userDid,
-    adultContentEnabled: preferences.adultContentEnabled,
-    labels: {
-      porn: preferences.contentLabels.nsfw,
-      sexual: preferences.contentLabels.suggestive,
-      nudity: preferences.contentLabels.nudity,
-      nsfl: preferences.contentLabels.gore,
-      corpse: preferences.contentLabels.gore,
-      gore: preferences.contentLabels.gore,
-      torture: preferences.contentLabels.gore,
-      'self-harm': preferences.contentLabels.gore,
-      'intolerant-race': preferences.contentLabels.hate,
-      'intolerant-gender': preferences.contentLabels.hate,
-      'intolerant-sexual-orientation': preferences.contentLabels.hate,
-      'intolerant-religion': preferences.contentLabels.hate,
-      intolerant: preferences.contentLabels.hate,
-      'icon-intolerant': preferences.contentLabels.hate,
-      spam: preferences.contentLabels.spam,
-      impersonation: preferences.contentLabels.impersonation,
-      scam: 'warn',
-    },
-    labelers: [
-      {
-        labeler: {
-          did: '',
-          displayName: 'Bluesky Social',
-        },
-        labels: {},
-      },
-    ],
-  }
+export const DEFAULT_LOGGED_OUT_LABEL_PREFERENCES: typeof DEFAULT_LABEL_SETTINGS =
+  Object.fromEntries(
+    Object.entries(DEFAULT_LABEL_SETTINGS).map(([key, _pref]) => [key, 'hide']),
+  )
+
+export function useMyLabelersQuery() {
+  const prefs = usePreferencesQuery()
+  const dids = Array.from(
+    new Set(
+      BskyAgent.appLabelers.concat(
+        prefs.data?.moderationPrefs.labelers.map(l => l.did) || [],
+      ),
+    ),
+  )
+  const labelers = useLabelersDetailedInfoQuery({dids})
+  const isLoading = prefs.isLoading || labelers.isLoading
+  const error = prefs.error || labelers.error
+  return React.useMemo(() => {
+    return {
+      isLoading,
+      error,
+      data: labelers.data,
+    }
+  }, [labelers, isLoading, error])
+}
+
+export function useLabelDefinitionsQuery() {
+  const labelers = useMyLabelersQuery()
+  return React.useMemo(() => {
+    return {
+      labelDefs: Object.fromEntries(
+        (labelers.data || []).map(labeler => [
+          labeler.creator.did,
+          interpretLabelValueDefinitions(labeler),
+        ]),
+      ),
+      labelers: labelers.data || [],
+    }
+  }, [labelers])
 }
diff --git a/src/state/queries/preferences/types.ts b/src/state/queries/preferences/types.ts
index 45c9eed7d..96da16f1a 100644
--- a/src/state/queries/preferences/types.ts
+++ b/src/state/queries/preferences/types.ts
@@ -1,46 +1,13 @@
 import {
   BskyPreferences,
-  LabelPreference,
   BskyThreadViewPreference,
   BskyFeedViewPreference,
 } from '@atproto/api'
 
-export const configurableAdultLabelGroups = [
-  'nsfw',
-  'nudity',
-  'suggestive',
-  'gore',
-] as const
-
-export const configurableOtherLabelGroups = [
-  'hate',
-  'spam',
-  'impersonation',
-] as const
-
-export const configurableLabelGroups = [
-  ...configurableAdultLabelGroups,
-  ...configurableOtherLabelGroups,
-] as const
-export type ConfigurableLabelGroup = (typeof configurableLabelGroups)[number]
-
-export type LabelGroup =
-  | ConfigurableLabelGroup
-  | 'illegal'
-  | 'always-filter'
-  | 'always-warn'
-  | 'unknown'
-
 export type UsePreferencesQueryResponse = Omit<
   BskyPreferences,
   'contentLabels' | 'feedViewPrefs' | 'feeds'
 > & {
-  /*
-   * Content labels previously included 'show', which has been deprecated in
-   * favor of 'ignore'. The API can return legacy data from the database, and
-   * we clean up the data in `usePreferencesQuery`.
-   */
-  contentLabels: Record<ConfigurableLabelGroup, LabelPreference>
   feedViewPrefs: BskyFeedViewPreference & {
     lab_mergeFeedEnabled?: boolean
   }
diff --git a/src/state/queries/preferences/util.ts b/src/state/queries/preferences/util.ts
deleted file mode 100644
index 7b8160c28..000000000
--- a/src/state/queries/preferences/util.ts
+++ /dev/null
@@ -1,16 +0,0 @@
-import {LabelPreference} from '@atproto/api'
-
-/**
- * Content labels previously included 'show', which has been deprecated in
- * favor of 'ignore'. The API can return legacy data from the database, and
- * we clean up the data in `usePreferencesQuery`.
- *
- * @deprecated
- */
-export function temp__migrateLabelPref(
-  pref: LabelPreference | 'show',
-): LabelPreference {
-  // @ts-ignore
-  if (pref === 'show') return 'ignore'
-  return pref
-}
diff --git a/src/state/queries/profile-extra-info.ts b/src/state/queries/profile-extra-info.ts
deleted file mode 100644
index 8fc32c33e..000000000
--- a/src/state/queries/profile-extra-info.ts
+++ /dev/null
@@ -1,34 +0,0 @@
-import {useQuery} from '@tanstack/react-query'
-
-import {getAgent} from '#/state/session'
-import {STALE} from '#/state/queries'
-
-// TODO refactor invalidate on mutate?
-export const RQKEY = (did: string) => ['profile-extra-info', did]
-
-/**
- * Fetches some additional information for the profile screen which
- * is not available in the API's ProfileView
- */
-export function useProfileExtraInfoQuery(did: string) {
-  return useQuery({
-    staleTime: STALE.MINUTES.ONE,
-    queryKey: RQKEY(did),
-    async queryFn() {
-      const [listsRes, feedsRes] = await Promise.all([
-        getAgent().app.bsky.graph.getLists({
-          actor: did,
-          limit: 1,
-        }),
-        getAgent().app.bsky.feed.getActorFeeds({
-          actor: did,
-          limit: 1,
-        }),
-      ])
-      return {
-        hasLists: listsRes.data.lists.length > 0,
-        hasFeedgens: feedsRes.data.feeds.length > 0,
-      }
-    },
-  })
-}
diff --git a/src/state/queries/profile.ts b/src/state/queries/profile.ts
index e81ea0f3f..19492cf66 100644
--- a/src/state/queries/profile.ts
+++ b/src/state/queries/profile.ts
@@ -1,31 +1,33 @@
 import {useCallback} from 'react'
+import {Image as RNImage} from 'react-native-image-crop-picker'
 import {
-  AtUri,
   AppBskyActorDefs,
-  AppBskyActorProfile,
   AppBskyActorGetProfile,
-  AppBskyFeedDefs,
+  AppBskyActorProfile,
   AppBskyEmbedRecord,
   AppBskyEmbedRecordWithMedia,
+  AppBskyFeedDefs,
+  AtUri,
 } from '@atproto/api'
 import {
+  QueryClient,
+  useMutation,
   useQuery,
   useQueryClient,
-  useMutation,
-  QueryClient,
 } from '@tanstack/react-query'
-import {Image as RNImage} from 'react-native-image-crop-picker'
-import {useSession, getAgent} from '../session'
-import {updateProfileShadow} from '../cache/profile-shadow'
+
+import {track} from '#/lib/analytics/analytics'
 import {uploadBlob} from '#/lib/api'
 import {until} from '#/lib/async/until'
+import {useToggleMutationQueue} from '#/lib/hooks/useToggleMutationQueue'
+import {logEvent, LogEvents} from '#/lib/statsig/statsig'
 import {Shadow} from '#/state/cache/types'
+import {STALE} from '#/state/queries'
 import {resetProfilePostsQueries} from '#/state/queries/post-feed'
-import {useToggleMutationQueue} from '#/lib/hooks/useToggleMutationQueue'
-import {RQKEY as RQKEY_MY_MUTED} from './my-muted-accounts'
+import {updateProfileShadow} from '../cache/profile-shadow'
+import {getAgent, useSession} from '../session'
 import {RQKEY as RQKEY_MY_BLOCKED} from './my-blocked-accounts'
-import {STALE} from '#/state/queries'
-import {track} from '#/lib/analytics/analytics'
+import {RQKEY as RQKEY_MY_MUTED} from './my-muted-accounts'
 import {ThreadNode} from './post-thread'
 
 export const RQKEY = (did: string) => ['profile', did]
@@ -186,11 +188,14 @@ export function useProfileUpdateMutation() {
 
 export function useProfileFollowMutationQueue(
   profile: Shadow<AppBskyActorDefs.ProfileViewDetailed>,
+  logContext: LogEvents['profile:follow']['logContext'] &
+    LogEvents['profile:unfollow']['logContext'],
 ) {
+  const queryClient = useQueryClient()
   const did = profile.did
   const initialFollowingUri = profile.viewer?.following
-  const followMutation = useProfileFollowMutation()
-  const unfollowMutation = useProfileUnfollowMutation()
+  const followMutation = useProfileFollowMutation(logContext)
+  const unfollowMutation = useProfileUnfollowMutation(logContext)
 
   const queueToggle = useToggleMutationQueue({
     initialState: initialFollowingUri,
@@ -212,7 +217,7 @@ export function useProfileFollowMutationQueue(
     },
     onSuccess(finalFollowingUri) {
       // finalize
-      updateProfileShadow(did, {
+      updateProfileShadow(queryClient, did, {
         followingUri: finalFollowingUri,
       })
     },
@@ -220,26 +225,29 @@ export function useProfileFollowMutationQueue(
 
   const queueFollow = useCallback(() => {
     // optimistically update
-    updateProfileShadow(did, {
+    updateProfileShadow(queryClient, did, {
       followingUri: 'pending',
     })
     return queueToggle(true)
-  }, [did, queueToggle])
+  }, [queryClient, did, queueToggle])
 
   const queueUnfollow = useCallback(() => {
     // optimistically update
-    updateProfileShadow(did, {
+    updateProfileShadow(queryClient, did, {
       followingUri: undefined,
     })
     return queueToggle(false)
-  }, [did, queueToggle])
+  }, [queryClient, did, queueToggle])
 
   return [queueFollow, queueUnfollow]
 }
 
-function useProfileFollowMutation() {
+function useProfileFollowMutation(
+  logContext: LogEvents['profile:follow']['logContext'],
+) {
   return useMutation<{uri: string; cid: string}, Error, {did: string}>({
     mutationFn: async ({did}) => {
+      logEvent('profile:follow', {logContext})
       return await getAgent().follow(did)
     },
     onSuccess(data, variables) {
@@ -248,9 +256,12 @@ function useProfileFollowMutation() {
   })
 }
 
-function useProfileUnfollowMutation() {
+function useProfileUnfollowMutation(
+  logContext: LogEvents['profile:unfollow']['logContext'],
+) {
   return useMutation<void, Error, {did: string; followUri: string}>({
     mutationFn: async ({followUri}) => {
+      logEvent('profile:unfollow', {logContext})
       track('Profile:Unfollow', {username: followUri})
       return await getAgent().deleteFollow(followUri)
     },
@@ -260,6 +271,7 @@ function useProfileUnfollowMutation() {
 export function useProfileMuteMutationQueue(
   profile: Shadow<AppBskyActorDefs.ProfileViewDetailed>,
 ) {
+  const queryClient = useQueryClient()
   const did = profile.did
   const initialMuted = profile.viewer?.muted
   const muteMutation = useProfileMuteMutation()
@@ -282,25 +294,25 @@ export function useProfileMuteMutationQueue(
     },
     onSuccess(finalMuted) {
       // finalize
-      updateProfileShadow(did, {muted: finalMuted})
+      updateProfileShadow(queryClient, did, {muted: finalMuted})
     },
   })
 
   const queueMute = useCallback(() => {
     // optimistically update
-    updateProfileShadow(did, {
+    updateProfileShadow(queryClient, did, {
       muted: true,
     })
     return queueToggle(true)
-  }, [did, queueToggle])
+  }, [queryClient, did, queueToggle])
 
   const queueUnmute = useCallback(() => {
     // optimistically update
-    updateProfileShadow(did, {
+    updateProfileShadow(queryClient, did, {
       muted: false,
     })
     return queueToggle(false)
-  }, [did, queueToggle])
+  }, [queryClient, did, queueToggle])
 
   return [queueMute, queueUnmute]
 }
@@ -332,6 +344,7 @@ function useProfileUnmuteMutation() {
 export function useProfileBlockMutationQueue(
   profile: Shadow<AppBskyActorDefs.ProfileViewDetailed>,
 ) {
+  const queryClient = useQueryClient()
   const did = profile.did
   const initialBlockingUri = profile.viewer?.blocking
   const blockMutation = useProfileBlockMutation()
@@ -357,7 +370,7 @@ export function useProfileBlockMutationQueue(
     },
     onSuccess(finalBlockingUri) {
       // finalize
-      updateProfileShadow(did, {
+      updateProfileShadow(queryClient, did, {
         blockingUri: finalBlockingUri,
       })
     },
@@ -365,19 +378,19 @@ export function useProfileBlockMutationQueue(
 
   const queueBlock = useCallback(() => {
     // optimistically update
-    updateProfileShadow(did, {
+    updateProfileShadow(queryClient, did, {
       blockingUri: 'pending',
     })
     return queueToggle(true)
-  }, [did, queueToggle])
+  }, [queryClient, did, queueToggle])
 
   const queueUnblock = useCallback(() => {
     // optimistically update
-    updateProfileShadow(did, {
+    updateProfileShadow(queryClient, did, {
       blockingUri: undefined,
     })
     return queueToggle(false)
-  }, [did, queueToggle])
+  }, [queryClient, did, queueToggle])
 
   return [queueBlock, queueUnblock]
 }
@@ -397,13 +410,14 @@ function useProfileBlockMutation() {
     },
     onSuccess(_, {did}) {
       queryClient.invalidateQueries({queryKey: RQKEY_MY_BLOCKED()})
-      resetProfilePostsQueries(did, 1000)
+      resetProfilePostsQueries(queryClient, did, 1000)
     },
   })
 }
 
 function useProfileUnblockMutation() {
   const {currentAccount} = useSession()
+  const queryClient = useQueryClient()
   return useMutation<void, Error, {did: string; blockUri: string}>({
     mutationFn: async ({blockUri}) => {
       if (!currentAccount) {
@@ -416,7 +430,7 @@ function useProfileUnblockMutation() {
       })
     },
     onSuccess(_, {did}) {
-      resetProfilePostsQueries(did, 1000)
+      resetProfilePostsQueries(queryClient, did, 1000)
     },
   })
 }
diff --git a/src/state/queries/suggested-follows.ts b/src/state/queries/suggested-follows.ts
index 932226b75..45b3ebb62 100644
--- a/src/state/queries/suggested-follows.ts
+++ b/src/state/queries/suggested-follows.ts
@@ -46,7 +46,8 @@ export function useSuggestedFollowsQuery() {
 
       res.data.actors = res.data.actors
         .filter(
-          actor => !moderateProfile(actor, moderationOpts!).account.filter,
+          actor =>
+            !moderateProfile(actor, moderationOpts!).ui('profileList').filter,
         )
         .filter(actor => {
           const viewer = actor.viewer
diff --git a/src/state/queries/util.ts b/src/state/queries/util.ts
index f3a87ae5d..54752b332 100644
--- a/src/state/queries/util.ts
+++ b/src/state/queries/util.ts
@@ -53,5 +53,6 @@ export function embedViewRecordToPostView(
     record: v.value,
     indexedAt: v.indexedAt,
     labels: v.labels,
+    embed: v.embeds?.[0],
   }
 }
diff --git a/src/state/session/agent-config.ts b/src/state/session/agent-config.ts
new file mode 100644
index 000000000..3ee2718a3
--- /dev/null
+++ b/src/state/session/agent-config.ts
@@ -0,0 +1,12 @@
+import AsyncStorage from '@react-native-async-storage/async-storage'
+
+const PREFIX = 'agent-labelers'
+
+export async function saveLabelers(did: string, value: string[]) {
+  await AsyncStorage.setItem(`${PREFIX}:${did}`, JSON.stringify(value))
+}
+
+export async function readLabelers(did: string): Promise<string[] | undefined> {
+  const rawData = await AsyncStorage.getItem(`${PREFIX}:${did}`)
+  return rawData ? JSON.parse(rawData) : undefined
+}
diff --git a/src/state/session/index.tsx b/src/state/session/index.tsx
index b555a997b..c7dba3089 100644
--- a/src/state/session/index.tsx
+++ b/src/state/session/index.tsx
@@ -1,18 +1,26 @@
 import React from 'react'
-import {BskyAgent, AtpPersistSessionHandler} from '@atproto/api'
+import {
+  AtpPersistSessionHandler,
+  BSKY_LABELER_DID,
+  BskyAgent,
+} from '@atproto/api'
 import {useQueryClient} from '@tanstack/react-query'
 import {jwtDecode} from 'jwt-decode'
 
+import {track} from '#/lib/analytics/analytics'
 import {networkRetry} from '#/lib/async/retry'
+import {IS_TEST_USER} from '#/lib/constants'
+import {logEvent, LogEvents} from '#/lib/statsig/statsig'
+import {hasProp} from '#/lib/type-guards'
 import {logger} from '#/logger'
+import {isWeb} from '#/platform/detection'
 import * as persisted from '#/state/persisted'
 import {PUBLIC_BSKY_AGENT} from '#/state/queries'
-import {IS_PROD} from '#/lib/constants'
-import {emitSessionDropped} from '../events'
 import {useLoggedOutViewControls} from '#/state/shell/logged-out'
 import {useCloseAllActiveElements} from '#/state/util'
-import {track} from '#/lib/analytics/analytics'
-import {hasProp} from '#/lib/type-guards'
+import {IS_DEV} from '#/env'
+import {emitSessionDropped} from '../events'
+import {readLabelers} from './agent-config'
 
 let __globalAgent: BskyAgent = PUBLIC_BSKY_AGENT
 
@@ -36,7 +44,6 @@ export type SessionState = {
 }
 export type StateContext = SessionState & {
   hasSession: boolean
-  isSandbox: boolean
 }
 export type ApiContext = {
   createAccount: (props: {
@@ -48,17 +55,22 @@ export type ApiContext = {
     verificationPhone?: string
     verificationCode?: string
   }) => Promise<void>
-  login: (props: {
-    service: string
-    identifier: string
-    password: string
-  }) => Promise<void>
+  login: (
+    props: {
+      service: string
+      identifier: string
+      password: string
+    },
+    logContext: LogEvents['account:loggedIn']['logContext'],
+  ) => Promise<void>
   /**
    * A full logout. Clears the `currentAccount` from session, AND removes
    * access tokens from all accounts, so that returning as any user will
    * require a full login.
    */
-  logout: () => Promise<void>
+  logout: (
+    logContext: LogEvents['account:loggedOut']['logContext'],
+  ) => Promise<void>
   /**
    * A partial logout. Clears the `currentAccount` from session, but DOES NOT
    * clear access tokens from accounts, allowing the user to return to their
@@ -70,7 +82,10 @@ export type ApiContext = {
   initSession: (account: SessionAccount) => Promise<void>
   resumeSession: (account?: SessionAccount) => Promise<void>
   removeAccount: (account: SessionAccount) => void
-  selectAccount: (account: SessionAccount) => Promise<void>
+  selectAccount: (
+    account: SessionAccount,
+    logContext: LogEvents['account:loggedIn']['logContext'],
+  ) => Promise<void>
   updateCurrentAccount: (
     account: Partial<
       Pick<SessionAccount, 'handle' | 'email' | 'emailConfirmed'>
@@ -84,7 +99,6 @@ const StateContext = React.createContext<StateContext>({
   accounts: [],
   currentAccount: undefined,
   hasSession: false,
-  isSandbox: false,
 })
 
 const ApiContext = React.createContext<ApiContext>({
@@ -136,7 +150,7 @@ function createPersistSessionHandler(
       accessJwt: session?.accessJwt,
     }
 
-    logger.info(`session: persistSession`, {
+    logger.debug(`session: persistSession`, {
       event,
       deactivated: refreshedAccount.deactivated,
     })
@@ -216,6 +230,7 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
     }: any) => {
       logger.info(`session: creating account`)
       track('Try Create Account')
+      logEvent('account:create:begin', {})
 
       const agent = new BskyAgent({service})
 
@@ -258,6 +273,8 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
         deactivated,
       }
 
+      await configureModeration(agent, account)
+
       agent.setPersistSessionHandler(
         createPersistSessionHandler(
           account,
@@ -274,12 +291,13 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
 
       logger.debug(`session: created account`, {}, logger.DebugContext.session)
       track('Create Account')
+      logEvent('account:create:success', {})
     },
     [upsertAccount, queryClient, clearCurrentAccount],
   )
 
   const login = React.useCallback<ApiContext['login']>(
-    async ({service, identifier, password}) => {
+    async ({service, identifier, password}, logContext) => {
       logger.debug(`session: login`, {}, logger.DebugContext.session)
 
       const agent = new BskyAgent({service})
@@ -301,6 +319,8 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
         deactivated: isSessionDeactivated(agent.session.accessJwt),
       }
 
+      await configureModeration(agent, account)
+
       agent.setPersistSessionHandler(
         createPersistSessionHandler(
           account,
@@ -312,30 +332,37 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
       )
 
       __globalAgent = agent
+      // @ts-ignore
+      if (IS_DEV && isWeb) window.agent = agent
       queryClient.clear()
       upsertAccount(account)
 
       logger.debug(`session: logged in`, {}, logger.DebugContext.session)
 
       track('Sign In', {resumedSession: false})
+      logEvent('account:loggedIn', {logContext, withPassword: true})
     },
     [upsertAccount, queryClient, clearCurrentAccount],
   )
 
-  const logout = React.useCallback<ApiContext['logout']>(async () => {
-    logger.info(`session: logout`)
-    clearCurrentAccount()
-    setStateAndPersist(s => {
-      return {
-        ...s,
-        accounts: s.accounts.map(a => ({
-          ...a,
-          refreshJwt: undefined,
-          accessJwt: undefined,
-        })),
-      }
-    })
-  }, [clearCurrentAccount, setStateAndPersist])
+  const logout = React.useCallback<ApiContext['logout']>(
+    async logContext => {
+      logger.debug(`session: logout`)
+      clearCurrentAccount()
+      setStateAndPersist(s => {
+        return {
+          ...s,
+          accounts: s.accounts.map(a => ({
+            ...a,
+            refreshJwt: undefined,
+            accessJwt: undefined,
+          })),
+        }
+      })
+      logEvent('account:loggedOut', {logContext})
+    },
+    [clearCurrentAccount, setStateAndPersist],
+  )
 
   const initSession = React.useCallback<ApiContext['initSession']>(
     async account => {
@@ -351,6 +378,9 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
           {networkErrorCallback: clearCurrentAccount},
         ),
       })
+      // @ts-ignore
+      if (IS_DEV && isWeb) window.agent = agent
+      await configureModeration(agent, account)
 
       let canReusePrevSession = false
       try {
@@ -377,7 +407,7 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
       }
 
       if (canReusePrevSession) {
-        logger.info(`session: attempting to reuse previous session`)
+        logger.debug(`session: attempting to reuse previous session`)
 
         agent.session = prevSession
         __globalAgent = agent
@@ -387,7 +417,7 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
         if (prevSession.deactivated) {
           // don't attempt to resume
           // use will be taken to the deactivated screen
-          logger.info(`session: reusing session for deactivated account`)
+          logger.debug(`session: reusing session for deactivated account`)
           return
         }
 
@@ -413,7 +443,7 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
             __globalAgent = PUBLIC_BSKY_AGENT
           })
       } else {
-        logger.info(`session: attempting to resume using previous session`)
+        logger.debug(`session: attempting to resume using previous session`)
 
         try {
           const freshAccount = await resumeSessionWithFreshAccount()
@@ -434,7 +464,7 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
       }
 
       async function resumeSessionWithFreshAccount(): Promise<SessionAccount> {
-        logger.info(`session: resumeSessionWithFreshAccount`)
+        logger.debug(`session: resumeSessionWithFreshAccount`)
 
         await networkRetry(1, () => agent.resumeSession(prevSession))
 
@@ -526,11 +556,12 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
   )
 
   const selectAccount = React.useCallback<ApiContext['selectAccount']>(
-    async account => {
+    async (account, logContext) => {
       setState(s => ({...s, isSwitchingAccounts: true}))
       try {
         await initSession(account)
         setState(s => ({...s, isSwitchingAccounts: false}))
+        logEvent('account:loggedIn', {logContext, withPassword: false})
       } catch (e) {
         // reset this in case of error
         setState(s => ({...s, isSwitchingAccounts: false}))
@@ -555,11 +586,11 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
     return persisted.onUpdate(() => {
       const session = persisted.get('session')
 
-      logger.info(`session: persisted onUpdate`, {})
+      logger.debug(`session: persisted onUpdate`, {})
 
       if (session.currentAccount && session.currentAccount.refreshJwt) {
         if (session.currentAccount?.did !== state.currentAccount?.did) {
-          logger.info(`session: persisted onUpdate, switching accounts`, {
+          logger.debug(`session: persisted onUpdate, switching accounts`, {
             from: {
               did: state.currentAccount?.did,
               handle: state.currentAccount?.handle,
@@ -572,7 +603,7 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
 
           initSession(session.currentAccount)
         } else {
-          logger.info(`session: persisted onUpdate, updating session`, {})
+          logger.debug(`session: persisted onUpdate, updating session`, {})
 
           /*
            * Use updated session in this tab's agent. Do not call
@@ -610,9 +641,6 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
     () => ({
       ...state,
       hasSession: !!state.currentAccount,
-      isSandbox: state.currentAccount
-        ? !IS_PROD(state.currentAccount?.service)
-        : false,
     }),
     [state],
   )
@@ -649,6 +677,28 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
   )
 }
 
+async function configureModeration(agent: BskyAgent, account: SessionAccount) {
+  if (IS_TEST_USER(account.handle)) {
+    const did = (
+      await agent
+        .resolveHandle({handle: 'mod-authority.test'})
+        .catch(_ => undefined)
+    )?.data.did
+    if (did) {
+      console.warn('USING TEST ENV MODERATION')
+      BskyAgent.configure({appLabelers: [did]})
+    }
+  } else {
+    BskyAgent.configure({appLabelers: [BSKY_LABELER_DID]})
+    const labelerDids = await readLabelers(account.did).catch(_ => {})
+    if (labelerDids) {
+      agent.configureLabelersHeader(
+        labelerDids.filter(did => did !== BSKY_LABELER_DID),
+      )
+    }
+  }
+}
+
 export function useSession() {
   return React.useContext(StateContext)
 }
diff --git a/src/state/shell/composer.tsx b/src/state/shell/composer.tsx
index 696a3c5ba..5b4e50543 100644
--- a/src/state/shell/composer.tsx
+++ b/src/state/shell/composer.tsx
@@ -2,7 +2,8 @@ import React from 'react'
 import {
   AppBskyEmbedRecord,
   AppBskyRichtextFacet,
-  PostModeration,
+  ModerationDecision,
+  AppBskyActorDefs,
 } from '@atproto/api'
 import {useNonReactiveCallback} from '#/lib/hooks/useNonReactiveCallback'
 
@@ -10,13 +11,9 @@ export interface ComposerOptsPostRef {
   uri: string
   cid: string
   text: string
-  author: {
-    handle: string
-    displayName?: string
-    avatar?: string
-  }
+  author: AppBskyActorDefs.ProfileViewBasic
   embed?: AppBskyEmbedRecord.ViewRecord['embed']
-  moderation?: PostModeration
+  moderation?: ModerationDecision
 }
 export interface ComposerOptsQuote {
   uri: string
@@ -38,6 +35,8 @@ export interface ComposerOpts {
   quote?: ComposerOptsQuote
   mention?: string // handle of user to mention
   openPicker?: (pos: DOMRect | undefined) => void
+  text?: string
+  imageUris?: {uri: string; width: number; height: number}[]
 }
 
 type StateContext = ComposerOpts | undefined
diff --git a/src/state/util.ts b/src/state/util.ts
index 57f4331b0..f65d14a84 100644
--- a/src/state/util.ts
+++ b/src/state/util.ts
@@ -3,6 +3,7 @@ import {useLightboxControls} from './lightbox'
 import {useModalControls} from './modals'
 import {useComposerControls} from './shell/composer'
 import {useSetDrawerOpen} from './shell/drawer-open'
+import {useDialogStateControlContext} from '#/state/dialogs'
 
 /**
  * returns true if something was closed
@@ -12,6 +13,7 @@ export function useCloseAnyActiveElement() {
   const {closeLightbox} = useLightboxControls()
   const {closeModal} = useModalControls()
   const {closeComposer} = useComposerControls()
+  const {closeAllDialogs} = useDialogStateControlContext()
   const setDrawerOpen = useSetDrawerOpen()
   return useCallback(() => {
     if (closeLightbox()) {
@@ -23,9 +25,12 @@ export function useCloseAnyActiveElement() {
     if (closeComposer()) {
       return true
     }
+    if (closeAllDialogs()) {
+      return true
+    }
     setDrawerOpen(false)
     return false
-  }, [closeLightbox, closeModal, closeComposer, setDrawerOpen])
+  }, [closeLightbox, closeModal, closeComposer, setDrawerOpen, closeAllDialogs])
 }
 
 /**
@@ -35,11 +40,19 @@ export function useCloseAllActiveElements() {
   const {closeLightbox} = useLightboxControls()
   const {closeAllModals} = useModalControls()
   const {closeComposer} = useComposerControls()
+  const {closeAllDialogs: closeAlfDialogs} = useDialogStateControlContext()
   const setDrawerOpen = useSetDrawerOpen()
   return useCallback(() => {
     closeLightbox()
     closeAllModals()
     closeComposer()
+    closeAlfDialogs()
     setDrawerOpen(false)
-  }, [closeLightbox, closeAllModals, closeComposer, setDrawerOpen])
+  }, [
+    closeLightbox,
+    closeAllModals,
+    closeComposer,
+    closeAlfDialogs,
+    setDrawerOpen,
+  ])
 }