about summary refs log tree commit diff
path: root/src/state/queries
diff options
context:
space:
mode:
Diffstat (limited to 'src/state/queries')
-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
18 files changed, 544 insertions, 602 deletions
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],
   }
 }