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/modals/index.tsx2
-rw-r--r--src/state/models/root-store.ts2
-rw-r--r--src/state/models/ui/create-account.ts1
-rw-r--r--src/state/models/ui/preferences.ts420
-rw-r--r--src/state/models/ui/saved-feeds.ts33
-rw-r--r--src/state/queries/feed.ts106
-rw-r--r--src/state/queries/preferences/const.ts27
-rw-r--r--src/state/queries/preferences/index.ts257
-rw-r--r--src/state/queries/preferences/moderation.ts163
-rw-r--r--src/state/queries/preferences/types.ts46
-rw-r--r--src/state/queries/preferences/util.ts16
11 files changed, 637 insertions, 436 deletions
diff --git a/src/state/modals/index.tsx b/src/state/modals/index.tsx
index f9bd1e3c9..287bbe593 100644
--- a/src/state/modals/index.tsx
+++ b/src/state/modals/index.tsx
@@ -243,7 +243,7 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
   const closeModal = React.useCallback(() => {
     let totalActiveModals = 0
     setActiveModals(activeModals => {
-      activeModals.pop()
+      activeModals = activeModals.slice(0, -1)
       totalActiveModals = activeModals.length
       return activeModals
     })
diff --git a/src/state/models/root-store.ts b/src/state/models/root-store.ts
index 4085a52c3..c07cf3078 100644
--- a/src/state/models/root-store.ts
+++ b/src/state/models/root-store.ts
@@ -101,7 +101,6 @@ export class RootStoreModel {
     this.agent = agent
     applyDebugHeader(this.agent)
     this.me.clear()
-    await this.preferences.sync()
     await this.me.load()
     if (!hadSession) {
       await resetNavigation()
@@ -137,7 +136,6 @@ export class RootStoreModel {
     }
     try {
       await this.me.updateIfNeeded()
-      await this.preferences.sync()
     } catch (e: any) {
       logger.error('Failed to fetch latest state', {error: e})
     }
diff --git a/src/state/models/ui/create-account.ts b/src/state/models/ui/create-account.ts
index 6d76784c1..60f4fc184 100644
--- a/src/state/models/ui/create-account.ts
+++ b/src/state/models/ui/create-account.ts
@@ -127,7 +127,6 @@ export class CreateAccountModel {
         password: this.password,
         inviteCode: this.inviteCode.trim(),
       })
-      /* dont await */ this.rootStore.preferences.setBirthDate(this.birthDate)
       track('Create Account')
     } catch (e: any) {
       onboardingDispatch({type: 'skip'}) // undo starting the onboard
diff --git a/src/state/models/ui/preferences.ts b/src/state/models/ui/preferences.ts
index 951486592..4f43487e7 100644
--- a/src/state/models/ui/preferences.ts
+++ b/src/state/models/ui/preferences.ts
@@ -1,19 +1,13 @@
-import {makeAutoObservable, runInAction} from 'mobx'
+import {makeAutoObservable} from 'mobx'
 import {
   LabelPreference as APILabelPreference,
   BskyFeedViewPreference,
   BskyThreadViewPreference,
 } from '@atproto/api'
 import AwaitLock from 'await-lock'
-import isEqual from 'lodash.isequal'
 import {isObj, hasProp} from 'lib/type-guards'
 import {RootStoreModel} from '../root-store'
 import {ModerationOpts} from '@atproto/api'
-import {DEFAULT_FEEDS} from 'lib/constants'
-import {getAge} from 'lib/strings/time'
-import {FeedTuner} from 'lib/api/feed-manip'
-import {logger} from '#/logger'
-import {getContentLanguages} from '#/state/preferences/languages'
 
 // TEMP we need to permanently convert 'show' to 'ignore', for now we manually convert -prf
 export type LabelPreference = APILabelPreference | 'show'
@@ -23,24 +17,6 @@ export type FeedViewPreference = BskyFeedViewPreference & {
 export type ThreadViewPreference = BskyThreadViewPreference & {
   lab_treeViewEnabled?: boolean | undefined
 }
-const LABEL_GROUPS = [
-  'nsfw',
-  'nudity',
-  'suggestive',
-  'gore',
-  'hate',
-  'spam',
-  'impersonation',
-]
-const VISIBILITY_VALUES = ['ignore', 'warn', 'hide']
-const THREAD_SORT_VALUES = ['oldest', 'newest', 'most-likes', 'random']
-
-interface LegacyPreferences {
-  hideReplies?: boolean
-  hideRepliesByLikeCount?: number
-  hideReposts?: boolean
-  hideQuotePosts?: boolean
-}
 
 export class LabelPreferencesModel {
   nsfw: LabelPreference = 'hide'
@@ -76,9 +52,6 @@ export class PreferencesModel {
     lab_treeViewEnabled: false, // experimental
   }
 
-  // used to help with transitions from device-stored to server-stored preferences
-  legacyPreferences: LegacyPreferences | undefined
-
   // used to linearize async modifications to state
   lock = new AwaitLock()
 
@@ -86,13 +59,6 @@ export class PreferencesModel {
     makeAutoObservable(this, {lock: false}, {autoBind: true})
   }
 
-  get userAge(): number | undefined {
-    if (!this.birthDate) {
-      return undefined
-    }
-    return getAge(this.birthDate)
-  }
-
   serialize() {
     return {
       contentLabels: this.contentLabels,
@@ -128,117 +94,15 @@ export class PreferencesModel {
       ) {
         this.pinnedFeeds = v.pinnedFeeds
       }
-      // grab legacy values
-      this.legacyPreferences = getLegacyPreferences(v)
-    }
-  }
-
-  /**
-   * This function fetches preferences and sets defaults for missing items.
-   */
-  async sync() {
-    await this.lock.acquireAsync()
-    try {
-      // fetch preferences
-      const prefs = await this.rootStore.agent.getPreferences()
-
-      runInAction(() => {
-        if (prefs.feedViewPrefs.home) {
-          this.homeFeed = prefs.feedViewPrefs.home
-        }
-        this.thread = prefs.threadViewPrefs
-        this.adultContentEnabled = prefs.adultContentEnabled
-        for (const label in prefs.contentLabels) {
-          if (
-            LABEL_GROUPS.includes(label) &&
-            VISIBILITY_VALUES.includes(prefs.contentLabels[label])
-          ) {
-            this.contentLabels[label as keyof LabelPreferencesModel] =
-              prefs.contentLabels[label]
-          }
-        }
-        if (prefs.feeds.saved && !isEqual(this.savedFeeds, prefs.feeds.saved)) {
-          this.savedFeeds = prefs.feeds.saved
-        }
-        if (
-          prefs.feeds.pinned &&
-          !isEqual(this.pinnedFeeds, prefs.feeds.pinned)
-        ) {
-          this.pinnedFeeds = prefs.feeds.pinned
-        }
-        this.birthDate = prefs.birthDate
-      })
-
-      // sync legacy values if needed
-      await this.syncLegacyPreferences()
-
-      // set defaults on missing items
-      if (typeof prefs.feeds.saved === 'undefined') {
-        try {
-          const {saved, pinned} = await DEFAULT_FEEDS(
-            this.rootStore.agent.service.toString(),
-            (handle: string) =>
-              this.rootStore.agent
-                .resolveHandle({handle})
-                .then(({data}) => data.did),
-          )
-          runInAction(() => {
-            this.savedFeeds = saved
-            this.pinnedFeeds = pinned
-          })
-          await this.rootStore.agent.setSavedFeeds(saved, pinned)
-        } catch (error) {
-          logger.error('Failed to set default feeds', {error})
-        }
-      }
-    } finally {
-      this.lock.release()
-    }
-  }
-
-  async syncLegacyPreferences() {
-    if (this.legacyPreferences) {
-      this.homeFeed = {...this.homeFeed, ...this.legacyPreferences}
-      this.legacyPreferences = undefined
-      await this.rootStore.agent.setFeedViewPrefs('home', this.homeFeed)
-    }
-  }
-
-  /**
-   * This function resets the preferences to an empty array of no preferences.
-   */
-  async reset() {
-    await this.lock.acquireAsync()
-    try {
-      runInAction(() => {
-        this.contentLabels = new LabelPreferencesModel()
-        this.savedFeeds = []
-        this.pinnedFeeds = []
-      })
-      await this.rootStore.agent.app.bsky.actor.putPreferences({
-        preferences: [],
-      })
-    } finally {
-      this.lock.release()
     }
   }
 
   // moderation
   // =
 
-  async setContentLabelPref(
-    key: keyof LabelPreferencesModel,
-    value: LabelPreference,
-  ) {
-    this.contentLabels[key] = value
-    await this.rootStore.agent.setContentLabelPref(key, value)
-  }
-
-  async setAdultContentEnabled(v: boolean) {
-    this.adultContentEnabled = v
-    await this.rootStore.agent.setAdultContentEnabled(v)
-  }
-
+  /**
+   * @deprecated use `getModerationOpts` from '#/state/queries/preferences/moderation' instead
+   */
   get moderationOpts(): ModerationOpts {
     return {
       userDid: this.rootStore.session.currentSession?.did || '',
@@ -284,274 +148,32 @@ export class PreferencesModel {
     return this.pinnedFeeds.includes(uri)
   }
 
-  async _optimisticUpdateSavedFeeds(
-    saved: string[],
-    pinned: string[],
-    cb: () => Promise<{saved: string[]; pinned: string[]}>,
-  ) {
-    const oldSaved = this.savedFeeds
-    const oldPinned = this.pinnedFeeds
-    this.savedFeeds = saved
-    this.pinnedFeeds = pinned
-    await this.lock.acquireAsync()
-    try {
-      const res = await cb()
-      runInAction(() => {
-        this.savedFeeds = res.saved
-        this.pinnedFeeds = res.pinned
-      })
-    } catch (e) {
-      runInAction(() => {
-        this.savedFeeds = oldSaved
-        this.pinnedFeeds = oldPinned
-      })
-      throw e
-    } finally {
-      this.lock.release()
-    }
-  }
-
-  async setSavedFeeds(saved: string[], pinned: string[]) {
-    return this._optimisticUpdateSavedFeeds(saved, pinned, () =>
-      this.rootStore.agent.setSavedFeeds(saved, pinned),
-    )
-  }
-
-  async addSavedFeed(v: string) {
-    return this._optimisticUpdateSavedFeeds(
-      [...this.savedFeeds.filter(uri => uri !== v), v],
-      this.pinnedFeeds,
-      () => this.rootStore.agent.addSavedFeed(v),
-    )
-  }
-
-  async removeSavedFeed(v: string) {
-    return this._optimisticUpdateSavedFeeds(
-      this.savedFeeds.filter(uri => uri !== v),
-      this.pinnedFeeds.filter(uri => uri !== v),
-      () => this.rootStore.agent.removeSavedFeed(v),
-    )
-  }
-
-  async addPinnedFeed(v: string) {
-    return this._optimisticUpdateSavedFeeds(
-      [...this.savedFeeds.filter(uri => uri !== v), v],
-      [...this.pinnedFeeds.filter(uri => uri !== v), v],
-      () => this.rootStore.agent.addPinnedFeed(v),
-    )
-  }
-
-  async removePinnedFeed(v: string) {
-    return this._optimisticUpdateSavedFeeds(
-      this.savedFeeds,
-      this.pinnedFeeds.filter(uri => uri !== v),
-      () => this.rootStore.agent.removePinnedFeed(v),
-    )
-  }
-
-  // other
-  // =
-
-  async setBirthDate(birthDate: Date) {
-    this.birthDate = birthDate
-    await this.lock.acquireAsync()
-    try {
-      await this.rootStore.agent.setPersonalDetails({birthDate})
-    } finally {
-      this.lock.release()
-    }
-  }
-
-  async toggleHomeFeedHideReplies() {
-    this.homeFeed.hideReplies = !this.homeFeed.hideReplies
-    await this.lock.acquireAsync()
-    try {
-      await this.rootStore.agent.setFeedViewPrefs('home', {
-        hideReplies: this.homeFeed.hideReplies,
-      })
-    } finally {
-      this.lock.release()
-    }
-  }
-
-  async toggleHomeFeedHideRepliesByUnfollowed() {
-    this.homeFeed.hideRepliesByUnfollowed =
-      !this.homeFeed.hideRepliesByUnfollowed
-    await this.lock.acquireAsync()
-    try {
-      await this.rootStore.agent.setFeedViewPrefs('home', {
-        hideRepliesByUnfollowed: this.homeFeed.hideRepliesByUnfollowed,
-      })
-    } finally {
-      this.lock.release()
-    }
-  }
-
-  async setHomeFeedHideRepliesByLikeCount(threshold: number) {
-    this.homeFeed.hideRepliesByLikeCount = threshold
-    await this.lock.acquireAsync()
-    try {
-      await this.rootStore.agent.setFeedViewPrefs('home', {
-        hideRepliesByLikeCount: this.homeFeed.hideRepliesByLikeCount,
-      })
-    } finally {
-      this.lock.release()
-    }
-  }
-
-  async toggleHomeFeedHideReposts() {
-    this.homeFeed.hideReposts = !this.homeFeed.hideReposts
-    await this.lock.acquireAsync()
-    try {
-      await this.rootStore.agent.setFeedViewPrefs('home', {
-        hideReposts: this.homeFeed.hideReposts,
-      })
-    } finally {
-      this.lock.release()
-    }
-  }
-
-  async toggleHomeFeedHideQuotePosts() {
-    this.homeFeed.hideQuotePosts = !this.homeFeed.hideQuotePosts
-    await this.lock.acquireAsync()
-    try {
-      await this.rootStore.agent.setFeedViewPrefs('home', {
-        hideQuotePosts: this.homeFeed.hideQuotePosts,
-      })
-    } finally {
-      this.lock.release()
-    }
-  }
-
-  async toggleHomeFeedMergeFeedEnabled() {
-    this.homeFeed.lab_mergeFeedEnabled = !this.homeFeed.lab_mergeFeedEnabled
-    await this.lock.acquireAsync()
-    try {
-      await this.rootStore.agent.setFeedViewPrefs('home', {
-        lab_mergeFeedEnabled: this.homeFeed.lab_mergeFeedEnabled,
-      })
-    } finally {
-      this.lock.release()
-    }
-  }
-
-  async setThreadSort(v: string) {
-    if (THREAD_SORT_VALUES.includes(v)) {
-      this.thread.sort = v
-      await this.lock.acquireAsync()
-      try {
-        await this.rootStore.agent.setThreadViewPrefs({sort: v})
-      } finally {
-        this.lock.release()
-      }
-    }
-  }
-
-  async togglePrioritizedFollowedUsers() {
-    this.thread.prioritizeFollowedUsers = !this.thread.prioritizeFollowedUsers
-    await this.lock.acquireAsync()
-    try {
-      await this.rootStore.agent.setThreadViewPrefs({
-        prioritizeFollowedUsers: this.thread.prioritizeFollowedUsers,
-      })
-    } finally {
-      this.lock.release()
-    }
-  }
-
-  async toggleThreadTreeViewEnabled() {
-    this.thread.lab_treeViewEnabled = !this.thread.lab_treeViewEnabled
-    await this.lock.acquireAsync()
-    try {
-      await this.rootStore.agent.setThreadViewPrefs({
-        lab_treeViewEnabled: this.thread.lab_treeViewEnabled,
-      })
-    } finally {
-      this.lock.release()
-    }
-  }
-
-  getFeedTuners(
-    feedType: 'home' | 'following' | 'author' | 'custom' | 'list' | 'likes',
-  ) {
-    if (feedType === 'custom') {
-      return [
-        FeedTuner.dedupReposts,
-        FeedTuner.preferredLangOnly(getContentLanguages()),
-      ]
-    }
-    if (feedType === 'list') {
-      return [FeedTuner.dedupReposts]
-    }
-    if (feedType === 'home' || feedType === 'following') {
-      const feedTuners = []
-
-      if (this.homeFeed.hideReposts) {
-        feedTuners.push(FeedTuner.removeReposts)
-      } else {
-        feedTuners.push(FeedTuner.dedupReposts)
-      }
+  /**
+   * @deprecated use `useAddSavedFeedMutation` from `#/state/queries/preferences` instead
+   */
+  async addSavedFeed(_v: string) {}
 
-      if (this.homeFeed.hideReplies) {
-        feedTuners.push(FeedTuner.removeReplies)
-      } else {
-        feedTuners.push(
-          FeedTuner.thresholdRepliesOnly({
-            userDid: this.rootStore.session.data?.did || '',
-            minLikes: this.homeFeed.hideRepliesByLikeCount,
-            followedOnly: !!this.homeFeed.hideRepliesByUnfollowed,
-          }),
-        )
-      }
+  /**
+   * @deprecated use `useRemoveSavedFeedMutation` from `#/state/queries/preferences` instead
+   */
+  async removeSavedFeed(_v: string) {}
 
-      if (this.homeFeed.hideQuotePosts) {
-        feedTuners.push(FeedTuner.removeQuotePosts)
-      }
+  /**
+   * @deprecated use `usePinFeedMutation` from `#/state/queries/preferences` instead
+   */
+  async addPinnedFeed(_v: string) {}
 
-      return feedTuners
-    }
-    return []
-  }
+  /**
+   * @deprecated use `useUnpinFeedMutation` from `#/state/queries/preferences` instead
+   */
+  async removePinnedFeed(_v: string) {}
 }
 
 // TEMP we need to permanently convert 'show' to 'ignore', for now we manually convert -prf
+// TODO do we need this?
 function tempfixLabelPref(pref: LabelPreference): APILabelPreference {
   if (pref === 'show') {
     return 'ignore'
   }
   return pref
 }
-
-function getLegacyPreferences(
-  v: Record<string, unknown>,
-): LegacyPreferences | undefined {
-  const legacyPreferences: LegacyPreferences = {}
-  if (
-    hasProp(v, 'homeFeedRepliesEnabled') &&
-    typeof v.homeFeedRepliesEnabled === 'boolean'
-  ) {
-    legacyPreferences.hideReplies = !v.homeFeedRepliesEnabled
-  }
-  if (
-    hasProp(v, 'homeFeedRepliesThreshold') &&
-    typeof v.homeFeedRepliesThreshold === 'number'
-  ) {
-    legacyPreferences.hideRepliesByLikeCount = v.homeFeedRepliesThreshold
-  }
-  if (
-    hasProp(v, 'homeFeedRepostsEnabled') &&
-    typeof v.homeFeedRepostsEnabled === 'boolean'
-  ) {
-    legacyPreferences.hideReposts = !v.homeFeedRepostsEnabled
-  }
-  if (
-    hasProp(v, 'homeFeedQuotePostsEnabled') &&
-    typeof v.homeFeedQuotePostsEnabled === 'boolean'
-  ) {
-    legacyPreferences.hideQuotePosts = !v.homeFeedQuotePostsEnabled
-  }
-  if (Object.keys(legacyPreferences).length) {
-    return legacyPreferences
-  }
-  return undefined
-}
diff --git a/src/state/models/ui/saved-feeds.ts b/src/state/models/ui/saved-feeds.ts
index 624da4f5f..cf4cf6d71 100644
--- a/src/state/models/ui/saved-feeds.ts
+++ b/src/state/models/ui/saved-feeds.ts
@@ -3,7 +3,6 @@ import {RootStoreModel} from '../root-store'
 import {bundleAsync} from 'lib/async/bundle'
 import {cleanError} from 'lib/strings/errors'
 import {FeedSourceModel} from '../content/feed-source'
-import {track} from 'lib/analytics/analytics'
 import {logger} from '#/logger'
 
 export class SavedFeedsModel {
@@ -69,7 +68,6 @@ export class SavedFeedsModel {
   refresh = bundleAsync(async () => {
     this._xLoading(true)
     try {
-      await this.rootStore.preferences.sync()
       const uris = dedup(
         this.rootStore.preferences.pinnedFeeds.concat(
           this.rootStore.preferences.savedFeeds,
@@ -87,37 +85,6 @@ export class SavedFeedsModel {
     }
   })
 
-  async reorderPinnedFeeds(feeds: FeedSourceModel[]) {
-    this._updatePinSortOrder(feeds.map(f => f.uri))
-    await this.rootStore.preferences.setSavedFeeds(
-      this.rootStore.preferences.savedFeeds,
-      feeds.filter(feed => feed.isPinned).map(feed => feed.uri),
-    )
-  }
-
-  async movePinnedFeed(item: FeedSourceModel, direction: 'up' | 'down') {
-    const pinned = this.rootStore.preferences.pinnedFeeds.slice()
-    const index = pinned.indexOf(item.uri)
-    if (index === -1) {
-      return
-    }
-    if (direction === 'up' && index !== 0) {
-      ;[pinned[index], pinned[index - 1]] = [pinned[index - 1], pinned[index]]
-    } else if (direction === 'down' && index < pinned.length - 1) {
-      ;[pinned[index], pinned[index + 1]] = [pinned[index + 1], pinned[index]]
-    }
-    this._updatePinSortOrder(pinned.concat(this.unpinned.map(f => f.uri)))
-    await this.rootStore.preferences.setSavedFeeds(
-      this.rootStore.preferences.savedFeeds,
-      pinned,
-    )
-    track('CustomFeed:Reorder', {
-      name: item.displayName,
-      uri: item.uri,
-      index: pinned.indexOf(item.uri),
-    })
-  }
-
   // state transitions
   // =
 
diff --git a/src/state/queries/feed.ts b/src/state/queries/feed.ts
new file mode 100644
index 000000000..0ba323314
--- /dev/null
+++ b/src/state/queries/feed.ts
@@ -0,0 +1,106 @@
+import {useQuery} from '@tanstack/react-query'
+import {AtUri, RichText, AppBskyFeedDefs, AppBskyGraphDefs} from '@atproto/api'
+
+import {sanitizeDisplayName} from '#/lib/strings/display-names'
+import {sanitizeHandle} from '#/lib/strings/handles'
+import {useSession} from '#/state/session'
+
+type FeedSourceInfo =
+  | {
+      type: 'feed'
+      uri: string
+      cid: string
+      avatar: string | undefined
+      displayName: string
+      description: RichText
+      creatorDid: string
+      creatorHandle: string
+      likeCount: number | undefined
+      likeUri: string | undefined
+    }
+  | {
+      type: 'list'
+      uri: string
+      cid: string
+      avatar: string | undefined
+      displayName: string
+      description: RichText
+      creatorDid: string
+      creatorHandle: string
+    }
+
+export const useFeedSourceInfoQueryKey = ({uri}: {uri: string}) => [
+  'getFeedSourceInfo',
+  uri,
+]
+
+const feedSourceNSIDs = {
+  feed: 'app.bsky.feed.generator',
+  list: 'app.bsky.graph.list',
+}
+
+function hydrateFeedGenerator(
+  view: AppBskyFeedDefs.GeneratorView,
+): FeedSourceInfo {
+  return {
+    type: 'feed',
+    uri: view.uri,
+    cid: view.cid,
+    avatar: view.avatar,
+    displayName: view.displayName
+      ? sanitizeDisplayName(view.displayName)
+      : `Feed by ${sanitizeHandle(view.creator.handle, '@')}`,
+    description: new RichText({
+      text: view.description || '',
+      facets: (view.descriptionFacets || [])?.slice(),
+    }),
+    creatorDid: view.creator.did,
+    creatorHandle: view.creator.handle,
+    likeCount: view.likeCount,
+    likeUri: view.viewer?.like,
+  }
+}
+
+function hydrateList(view: AppBskyGraphDefs.ListView): FeedSourceInfo {
+  return {
+    type: 'list',
+    uri: view.uri,
+    cid: view.cid,
+    avatar: view.avatar,
+    description: new RichText({
+      text: view.description || '',
+      facets: (view.descriptionFacets || [])?.slice(),
+    }),
+    creatorDid: view.creator.did,
+    creatorHandle: view.creator.handle,
+    displayName: view.name
+      ? sanitizeDisplayName(view.name)
+      : `User List by ${sanitizeHandle(view.creator.handle, '@')}`,
+  }
+}
+
+export function useFeedSourceInfoQuery({uri}: {uri: string}) {
+  const {agent} = useSession()
+  const {pathname} = new AtUri(uri)
+  const type = pathname.includes(feedSourceNSIDs.feed) ? 'feed' : 'list'
+
+  return useQuery({
+    queryKey: useFeedSourceInfoQueryKey({uri}),
+    queryFn: async () => {
+      let view: FeedSourceInfo
+
+      if (type === 'feed') {
+        const res = await agent.app.bsky.feed.getFeedGenerator({feed: uri})
+        view = hydrateFeedGenerator(res.data.view)
+      } else {
+        const res = await agent.app.bsky.graph.getList({
+          list: uri,
+          limit: 1,
+        })
+        view = hydrateList(res.data.list)
+      }
+
+      return view
+    },
+  })
+}
diff --git a/src/state/queries/preferences/const.ts b/src/state/queries/preferences/const.ts
new file mode 100644
index 000000000..5db137e58
--- /dev/null
+++ b/src/state/queries/preferences/const.ts
@@ -0,0 +1,27 @@
+import {
+  UsePreferencesQueryResponse,
+  ThreadViewPreferences,
+} from '#/state/queries/preferences/types'
+
+export const DEFAULT_HOME_FEED_PREFS: UsePreferencesQueryResponse['feedViewPrefs'] =
+  {
+    hideReplies: false,
+    hideRepliesByUnfollowed: false,
+    hideRepliesByLikeCount: 0,
+    hideReposts: false,
+    hideQuotePosts: false,
+    lab_mergeFeedEnabled: false, // experimental
+  }
+
+export const DEFAULT_THREAD_VIEW_PREFS: ThreadViewPreferences = {
+  sort: 'newest',
+  prioritizeFollowedUsers: true,
+  lab_treeViewEnabled: false,
+}
+
+const DEFAULT_PROD_FEED_PREFIX = (rkey: string) =>
+  `at://did:plc:z72i7hdynmk6r22z27h6tvur/app.bsky.feed.generator/${rkey}`
+export const DEFAULT_PROD_FEEDS = {
+  pinned: [DEFAULT_PROD_FEED_PREFIX('whats-hot')],
+  saved: [DEFAULT_PROD_FEED_PREFIX('whats-hot')],
+}
diff --git a/src/state/queries/preferences/index.ts b/src/state/queries/preferences/index.ts
new file mode 100644
index 000000000..d64bbd954
--- /dev/null
+++ b/src/state/queries/preferences/index.ts
@@ -0,0 +1,257 @@
+import {useQuery, useMutation, useQueryClient} from '@tanstack/react-query'
+import {LabelPreference, BskyFeedViewPreference} from '@atproto/api'
+
+import {track} from '#/lib/analytics/analytics'
+import {getAge} from '#/lib/strings/time'
+import {useSession} from '#/state/session'
+import {DEFAULT_LABEL_PREFERENCES} from '#/state/queries/preferences/moderation'
+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,
+} from '#/state/queries/preferences/const'
+
+export * from '#/state/queries/preferences/types'
+export * from '#/state/queries/preferences/moderation'
+export * from '#/state/queries/preferences/const'
+
+export const usePreferencesQueryKey = ['getPreferences']
+
+export function usePreferencesQuery() {
+  const {agent} = useSession()
+  return useQuery({
+    queryKey: usePreferencesQueryKey,
+    queryFn: async () => {
+      const res = await agent.getPreferences()
+      const preferences: UsePreferencesQueryResponse = {
+        ...res,
+        feeds: {
+          saved: res.feeds?.saved || [],
+          pinned: res.feeds?.pinned || [],
+          unpinned:
+            res.feeds.saved?.filter(f => {
+              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 || {}),
+        },
+        threadViewPrefs: {
+          ...DEFAULT_THREAD_VIEW_PREFS,
+          ...(res.threadViewPrefs ?? {}),
+        },
+        userAge: res.birthDate ? getAge(res.birthDate) : undefined,
+      }
+      return preferences
+    },
+  })
+}
+
+export function useClearPreferencesMutation() {
+  const {agent} = useSession()
+  const queryClient = useQueryClient()
+
+  return useMutation({
+    mutationFn: async () => {
+      await agent.app.bsky.actor.putPreferences({preferences: []})
+      // triggers a refetch
+      await queryClient.invalidateQueries({
+        queryKey: usePreferencesQueryKey,
+      })
+    },
+  })
+}
+
+export function usePreferencesSetContentLabelMutation() {
+  const {agent} = useSession()
+  const queryClient = useQueryClient()
+
+  return useMutation<
+    void,
+    unknown,
+    {labelGroup: ConfigurableLabelGroup; visibility: LabelPreference}
+  >({
+    mutationFn: async ({labelGroup, visibility}) => {
+      await agent.setContentLabelPref(labelGroup, visibility)
+      // triggers a refetch
+      await queryClient.invalidateQueries({
+        queryKey: usePreferencesQueryKey,
+      })
+    },
+  })
+}
+
+export function usePreferencesSetAdultContentMutation() {
+  const {agent} = useSession()
+  const queryClient = useQueryClient()
+
+  return useMutation<void, unknown, {enabled: boolean}>({
+    mutationFn: async ({enabled}) => {
+      await agent.setAdultContentEnabled(enabled)
+      // triggers a refetch
+      await queryClient.invalidateQueries({
+        queryKey: usePreferencesQueryKey,
+      })
+    },
+  })
+}
+
+export function usePreferencesSetBirthDateMutation() {
+  const {agent} = useSession()
+  const queryClient = useQueryClient()
+
+  return useMutation<void, unknown, {birthDate: Date}>({
+    mutationFn: async ({birthDate}: {birthDate: Date}) => {
+      await agent.setPersonalDetails({birthDate})
+      // triggers a refetch
+      await queryClient.invalidateQueries({
+        queryKey: usePreferencesQueryKey,
+      })
+    },
+  })
+}
+
+export function useSetFeedViewPreferencesMutation() {
+  const {agent} = useSession()
+  const queryClient = useQueryClient()
+
+  return useMutation<void, unknown, Partial<BskyFeedViewPreference>>({
+    mutationFn: async prefs => {
+      await agent.setFeedViewPrefs('home', prefs)
+      // triggers a refetch
+      await queryClient.invalidateQueries({
+        queryKey: usePreferencesQueryKey,
+      })
+    },
+  })
+}
+
+export function useSetThreadViewPreferencesMutation() {
+  const {agent} = useSession()
+  const queryClient = useQueryClient()
+
+  return useMutation<void, unknown, Partial<ThreadViewPreferences>>({
+    mutationFn: async prefs => {
+      await agent.setThreadViewPrefs(prefs)
+      // triggers a refetch
+      await queryClient.invalidateQueries({
+        queryKey: usePreferencesQueryKey,
+      })
+    },
+  })
+}
+
+export function useSetSaveFeedsMutation() {
+  const {agent} = useSession()
+  const queryClient = useQueryClient()
+
+  return useMutation<
+    void,
+    unknown,
+    Pick<UsePreferencesQueryResponse['feeds'], 'saved' | 'pinned'>
+  >({
+    mutationFn: async ({saved, pinned}) => {
+      await agent.setSavedFeeds(saved, pinned)
+      // triggers a refetch
+      await queryClient.invalidateQueries({
+        queryKey: usePreferencesQueryKey,
+      })
+    },
+  })
+}
+
+export function useSaveFeedMutation() {
+  const {agent} = useSession()
+  const queryClient = useQueryClient()
+
+  return useMutation<void, unknown, {uri: string}>({
+    mutationFn: async ({uri}) => {
+      await agent.addSavedFeed(uri)
+      track('CustomFeed:Save')
+      // triggers a refetch
+      await queryClient.invalidateQueries({
+        queryKey: usePreferencesQueryKey,
+      })
+    },
+  })
+}
+
+export function useRemoveFeedMutation() {
+  const {agent} = useSession()
+  const queryClient = useQueryClient()
+
+  return useMutation<void, unknown, {uri: string}>({
+    mutationFn: async ({uri}) => {
+      await agent.removeSavedFeed(uri)
+      track('CustomFeed:Unsave')
+      // triggers a refetch
+      await queryClient.invalidateQueries({
+        queryKey: usePreferencesQueryKey,
+      })
+    },
+  })
+}
+
+export function usePinFeedMutation() {
+  const {agent} = useSession()
+  const queryClient = useQueryClient()
+
+  return useMutation<void, unknown, {uri: string}>({
+    mutationFn: async ({uri}) => {
+      await agent.addPinnedFeed(uri)
+      track('CustomFeed:Pin', {uri})
+      // triggers a refetch
+      await queryClient.invalidateQueries({
+        queryKey: usePreferencesQueryKey,
+      })
+    },
+  })
+}
+
+export function useUnpinFeedMutation() {
+  const {agent} = useSession()
+  const queryClient = useQueryClient()
+
+  return useMutation<void, unknown, {uri: string}>({
+    mutationFn: async ({uri}) => {
+      await agent.removePinnedFeed(uri)
+      track('CustomFeed:Unpin', {uri})
+      // triggers a refetch
+      await queryClient.invalidateQueries({
+        queryKey: usePreferencesQueryKey,
+      })
+    },
+  })
+}
diff --git a/src/state/queries/preferences/moderation.ts b/src/state/queries/preferences/moderation.ts
new file mode 100644
index 000000000..a26380a36
--- /dev/null
+++ b/src/state/queries/preferences/moderation.ts
@@ -0,0 +1,163 @@
+import {
+  LabelPreference,
+  ComAtprotoLabelDefs,
+  ModerationOpts,
+} 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',
+}
+
+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: {},
+      },
+    ],
+  }
+}
diff --git a/src/state/queries/preferences/types.ts b/src/state/queries/preferences/types.ts
new file mode 100644
index 000000000..9f4c30e53
--- /dev/null
+++ b/src/state/queries/preferences/types.ts
@@ -0,0 +1,46 @@
+import {
+  BskyPreferences,
+  LabelPreference,
+  BskyThreadViewPreference,
+} from '@atproto/api'
+
+export type ConfigurableLabelGroup =
+  | 'nsfw'
+  | 'nudity'
+  | 'suggestive'
+  | 'gore'
+  | 'hate'
+  | 'spam'
+  | 'impersonation'
+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: BskyPreferences['feedViewPrefs']['home']
+  /**
+   * User thread-view prefs, including newer fields that may not be typed yet.
+   */
+  threadViewPrefs: ThreadViewPreferences
+  userAge: number | undefined
+  feeds: Required<BskyPreferences['feeds']> & {
+    unpinned: string[]
+  }
+}
+
+export type ThreadViewPreferences = Omit<BskyThreadViewPreference, 'sort'> & {
+  sort: 'oldest' | 'newest' | 'most-likes' | 'random' | string
+  lab_treeViewEnabled: boolean
+}
diff --git a/src/state/queries/preferences/util.ts b/src/state/queries/preferences/util.ts
new file mode 100644
index 000000000..7b8160c28
--- /dev/null
+++ b/src/state/queries/preferences/util.ts
@@ -0,0 +1,16 @@
+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
+}