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.tsx18
-rw-r--r--src/state/models/content/list-membership.ts130
-rw-r--r--src/state/models/content/list.ts508
-rw-r--r--src/state/models/content/profile.ts4
-rw-r--r--src/state/models/lists/lists-list.ts244
-rw-r--r--src/state/models/ui/profile.ts1
-rw-r--r--src/state/queries/actor-autocomplete.ts66
-rw-r--r--src/state/queries/list-members.ts31
-rw-r--r--src/state/queries/list-memberships.ts190
-rw-r--r--src/state/queries/list.ts285
-rw-r--r--src/state/queries/my-follows.ts43
-rw-r--r--src/state/queries/my-lists.ts89
-rw-r--r--src/state/queries/profile-lists.ts31
13 files changed, 747 insertions, 893 deletions
diff --git a/src/state/modals/index.tsx b/src/state/modals/index.tsx
index 287bbe593..6c63d9fc1 100644
--- a/src/state/modals/index.tsx
+++ b/src/state/modals/index.tsx
@@ -1,11 +1,10 @@
 import React from 'react'
-import {AppBskyActorDefs, ModerationUI} from '@atproto/api'
+import {AppBskyActorDefs, AppBskyGraphDefs, ModerationUI} from '@atproto/api'
 import {StyleProp, ViewStyle, DeviceEventEmitter} from 'react-native'
 import {Image as RNImage} from 'react-native-image-crop-picker'
 
 import {ProfileModel} from '#/state/models/content/profile'
 import {ImageModel} from '#/state/models/media/image'
-import {ListModel} from '#/state/models/content/list'
 import {GalleryModel} from '#/state/models/media/gallery'
 
 export interface ConfirmModal {
@@ -55,7 +54,7 @@ export type ReportModal = {
 export interface CreateOrEditListModal {
   name: 'create-or-edit-list'
   purpose?: string
-  list?: ListModel
+  list?: AppBskyGraphDefs.ListView
   onSave?: (uri: string) => void
 }
 
@@ -67,10 +66,13 @@ export interface UserAddRemoveListsModal {
   onRemove?: (listUri: string) => void
 }
 
-export interface ListAddUserModal {
-  name: 'list-add-user'
-  list: ListModel
-  onAdd?: (profile: AppBskyActorDefs.ProfileViewBasic) => void
+export interface ListAddRemoveUsersModal {
+  name: 'list-add-remove-users'
+  list: AppBskyGraphDefs.ListView
+  onChange?: (
+    type: 'add' | 'remove',
+    profile: AppBskyActorDefs.ProfileViewBasic,
+  ) => void
 }
 
 export interface EditImageModal {
@@ -184,7 +186,7 @@ export type Modal =
   // Lists
   | CreateOrEditListModal
   | UserAddRemoveListsModal
-  | ListAddUserModal
+  | ListAddRemoveUsersModal
 
   // Posts
   | AltTextImageModal
diff --git a/src/state/models/content/list-membership.ts b/src/state/models/content/list-membership.ts
deleted file mode 100644
index 135d34dd5..000000000
--- a/src/state/models/content/list-membership.ts
+++ /dev/null
@@ -1,130 +0,0 @@
-import {makeAutoObservable} from 'mobx'
-import {AtUri, AppBskyGraphListitem} from '@atproto/api'
-import {runInAction} from 'mobx'
-import {RootStoreModel} from '../root-store'
-
-const PAGE_SIZE = 100
-interface Membership {
-  uri: string
-  value: AppBskyGraphListitem.Record
-}
-
-interface ListitemRecord {
-  uri: string
-  value: AppBskyGraphListitem.Record
-}
-
-interface ListitemListResponse {
-  cursor?: string
-  records: ListitemRecord[]
-}
-
-export class ListMembershipModel {
-  // data
-  memberships: Membership[] = []
-
-  constructor(public rootStore: RootStoreModel, public subject: string) {
-    makeAutoObservable(
-      this,
-      {
-        rootStore: false,
-      },
-      {autoBind: true},
-    )
-  }
-
-  // public api
-  // =
-
-  async fetch() {
-    // NOTE
-    // this approach to determining list membership is too inefficient to work at any scale
-    // it needs to be replaced with server side list membership queries
-    // -prf
-    let cursor
-    let records: ListitemRecord[] = []
-    for (let i = 0; i < 100; i++) {
-      const res: ListitemListResponse =
-        await this.rootStore.agent.app.bsky.graph.listitem.list({
-          repo: this.rootStore.me.did,
-          cursor,
-          limit: PAGE_SIZE,
-        })
-      records = records.concat(
-        res.records.filter(record => record.value.subject === this.subject),
-      )
-      cursor = res.cursor
-      if (!cursor) {
-        break
-      }
-    }
-    runInAction(() => {
-      this.memberships = records
-    })
-  }
-
-  getMembership(listUri: string) {
-    return this.memberships.find(m => m.value.list === listUri)
-  }
-
-  isMember(listUri: string) {
-    return !!this.getMembership(listUri)
-  }
-
-  async add(listUri: string) {
-    if (this.isMember(listUri)) {
-      return
-    }
-    const res = await this.rootStore.agent.app.bsky.graph.listitem.create(
-      {
-        repo: this.rootStore.me.did,
-      },
-      {
-        subject: this.subject,
-        list: listUri,
-        createdAt: new Date().toISOString(),
-      },
-    )
-    const {rkey} = new AtUri(res.uri)
-    const record = await this.rootStore.agent.app.bsky.graph.listitem.get({
-      repo: this.rootStore.me.did,
-      rkey,
-    })
-    runInAction(() => {
-      this.memberships = this.memberships.concat([record])
-    })
-  }
-
-  async remove(listUri: string) {
-    const membership = this.getMembership(listUri)
-    if (!membership) {
-      return
-    }
-    const {rkey} = new AtUri(membership.uri)
-    await this.rootStore.agent.app.bsky.graph.listitem.delete({
-      repo: this.rootStore.me.did,
-      rkey,
-    })
-    runInAction(() => {
-      this.memberships = this.memberships.filter(m => m.value.list !== listUri)
-    })
-  }
-
-  async updateTo(
-    uris: string[],
-  ): Promise<{added: string[]; removed: string[]}> {
-    const added = []
-    const removed = []
-    for (const uri of uris) {
-      await this.add(uri)
-      added.push(uri)
-    }
-    for (const membership of this.memberships) {
-      if (!uris.includes(membership.value.list)) {
-        await this.remove(membership.value.list)
-        removed.push(membership.value.list)
-      }
-    }
-    return {added, removed}
-  }
-}
diff --git a/src/state/models/content/list.ts b/src/state/models/content/list.ts
deleted file mode 100644
index fc09eeb9f..000000000
--- a/src/state/models/content/list.ts
+++ /dev/null
@@ -1,508 +0,0 @@
-import {makeAutoObservable, runInAction} from 'mobx'
-import {
-  AtUri,
-  AppBskyActorDefs,
-  AppBskyGraphGetList as GetList,
-  AppBskyGraphDefs as GraphDefs,
-  AppBskyGraphList,
-  AppBskyGraphListitem,
-  RichText,
-} from '@atproto/api'
-import {Image as RNImage} from 'react-native-image-crop-picker'
-import chunk from 'lodash.chunk'
-import {RootStoreModel} from '../root-store'
-import * as apilib from 'lib/api/index'
-import {cleanError} from 'lib/strings/errors'
-import {bundleAsync} from 'lib/async/bundle'
-import {track} from 'lib/analytics/analytics'
-import {until} from 'lib/async/until'
-import {logger} from '#/logger'
-
-const PAGE_SIZE = 30
-
-interface ListitemRecord {
-  uri: string
-  value: AppBskyGraphListitem.Record
-}
-
-interface ListitemListResponse {
-  cursor?: string
-  records: ListitemRecord[]
-}
-
-export class ListModel {
-  // state
-  isLoading = false
-  isRefreshing = false
-  hasLoaded = false
-  error = ''
-  loadMoreError = ''
-  hasMore = true
-  loadMoreCursor?: string
-
-  // data
-  data: GraphDefs.ListView | null = null
-  items: GraphDefs.ListItemView[] = []
-  descriptionRT: RichText | null = null
-
-  static async createList(
-    rootStore: RootStoreModel,
-    {
-      purpose,
-      name,
-      description,
-      avatar,
-    }: {
-      purpose: string
-      name: string
-      description: string
-      avatar: RNImage | null | undefined
-    },
-  ) {
-    if (
-      purpose !== 'app.bsky.graph.defs#curatelist' &&
-      purpose !== 'app.bsky.graph.defs#modlist'
-    ) {
-      throw new Error('Invalid list purpose: must be curatelist or modlist')
-    }
-    const record: AppBskyGraphList.Record = {
-      purpose,
-      name,
-      description,
-      avatar: undefined,
-      createdAt: new Date().toISOString(),
-    }
-    if (avatar) {
-      const blobRes = await apilib.uploadBlob(
-        rootStore,
-        avatar.path,
-        avatar.mime,
-      )
-      record.avatar = blobRes.data.blob
-    }
-    const res = await rootStore.agent.app.bsky.graph.list.create(
-      {
-        repo: rootStore.me.did,
-      },
-      record,
-    )
-
-    // wait for the appview to update
-    await until(
-      5, // 5 tries
-      1e3, // 1s delay between tries
-      (v: GetList.Response, _e: any) => {
-        return typeof v?.data?.list.uri === 'string'
-      },
-      () =>
-        rootStore.agent.app.bsky.graph.getList({
-          list: res.uri,
-          limit: 1,
-        }),
-    )
-    return res
-  }
-
-  constructor(public rootStore: RootStoreModel, public uri: string) {
-    makeAutoObservable(
-      this,
-      {
-        rootStore: false,
-      },
-      {autoBind: true},
-    )
-  }
-
-  get hasContent() {
-    return this.items.length > 0
-  }
-
-  get hasError() {
-    return this.error !== ''
-  }
-
-  get isEmpty() {
-    return this.hasLoaded && !this.hasContent
-  }
-
-  get isCuratelist() {
-    return this.data?.purpose === 'app.bsky.graph.defs#curatelist'
-  }
-
-  get isModlist() {
-    return this.data?.purpose === 'app.bsky.graph.defs#modlist'
-  }
-
-  get isOwner() {
-    return this.data?.creator.did === this.rootStore.me.did
-  }
-
-  get isBlocking() {
-    return !!this.data?.viewer?.blocked
-  }
-
-  get isMuting() {
-    return !!this.data?.viewer?.muted
-  }
-
-  get isPinned() {
-    return this.rootStore.preferences.isPinnedFeed(this.uri)
-  }
-
-  get creatorDid() {
-    return this.data?.creator.did
-  }
-
-  getMembership(did: string) {
-    return this.items.find(item => item.subject.did === did)
-  }
-
-  isMember(did: string) {
-    return !!this.getMembership(did)
-  }
-
-  // public api
-  // =
-
-  async refresh() {
-    return this.loadMore(true)
-  }
-
-  loadMore = bundleAsync(async (replace: boolean = false) => {
-    if (!replace && !this.hasMore) {
-      return
-    }
-    this._xLoading(replace)
-    try {
-      await this._resolveUri()
-      const res = await this.rootStore.agent.app.bsky.graph.getList({
-        list: this.uri,
-        limit: PAGE_SIZE,
-        cursor: replace ? undefined : this.loadMoreCursor,
-      })
-      if (replace) {
-        this._replaceAll(res)
-      } else {
-        this._appendAll(res)
-      }
-      this._xIdle()
-    } catch (e: any) {
-      this._xIdle(replace ? e : undefined, !replace ? e : undefined)
-    }
-  })
-
-  async loadAll() {
-    for (let i = 0; i < 1000; i++) {
-      if (!this.hasMore) {
-        break
-      }
-      await this.loadMore()
-    }
-  }
-
-  async updateMetadata({
-    name,
-    description,
-    avatar,
-  }: {
-    name: string
-    description: string
-    avatar: RNImage | null | undefined
-  }) {
-    if (!this.data) {
-      return
-    }
-    if (!this.isOwner) {
-      throw new Error('Cannot edit this list')
-    }
-    await this._resolveUri()
-
-    // get the current record
-    const {rkey} = new AtUri(this.uri)
-    const {value: record} = await this.rootStore.agent.app.bsky.graph.list.get({
-      repo: this.rootStore.me.did,
-      rkey,
-    })
-
-    // update the fields
-    record.name = name
-    record.description = description
-    if (avatar) {
-      const blobRes = await apilib.uploadBlob(
-        this.rootStore,
-        avatar.path,
-        avatar.mime,
-      )
-      record.avatar = blobRes.data.blob
-    } else if (avatar === null) {
-      record.avatar = undefined
-    }
-    return await this.rootStore.agent.com.atproto.repo.putRecord({
-      repo: this.rootStore.me.did,
-      collection: 'app.bsky.graph.list',
-      rkey,
-      record,
-    })
-  }
-
-  async delete() {
-    if (!this.data) {
-      return
-    }
-    await this._resolveUri()
-
-    // fetch all the listitem records that belong to this list
-    let cursor
-    let records: ListitemRecord[] = []
-    for (let i = 0; i < 100; i++) {
-      const res: ListitemListResponse =
-        await this.rootStore.agent.app.bsky.graph.listitem.list({
-          repo: this.rootStore.me.did,
-          cursor,
-          limit: PAGE_SIZE,
-        })
-      records = records.concat(
-        res.records.filter(record => record.value.list === this.uri),
-      )
-      cursor = res.cursor
-      if (!cursor) {
-        break
-      }
-    }
-
-    // batch delete the list and listitem records
-    const createDel = (uri: string) => {
-      const urip = new AtUri(uri)
-      return {
-        $type: 'com.atproto.repo.applyWrites#delete',
-        collection: urip.collection,
-        rkey: urip.rkey,
-      }
-    }
-    const writes = records
-      .map(record => createDel(record.uri))
-      .concat([createDel(this.uri)])
-
-    // apply in chunks
-    for (const writesChunk of chunk(writes, 10)) {
-      await this.rootStore.agent.com.atproto.repo.applyWrites({
-        repo: this.rootStore.me.did,
-        writes: writesChunk,
-      })
-    }
-
-    /* dont await */ this.rootStore.preferences.removeSavedFeed(this.uri)
-    this.rootStore.emitListDeleted(this.uri)
-  }
-
-  async addMember(profile: AppBskyActorDefs.ProfileViewBasic) {
-    if (this.isMember(profile.did)) {
-      return
-    }
-    await this.rootStore.agent.app.bsky.graph.listitem.create(
-      {
-        repo: this.rootStore.me.did,
-      },
-      {
-        subject: profile.did,
-        list: this.uri,
-        createdAt: new Date().toISOString(),
-      },
-    )
-    runInAction(() => {
-      this.items = this.items.concat([
-        {_reactKey: profile.did, subject: profile},
-      ])
-    })
-  }
-
-  /**
-   * Just adds to local cache; used to reflect changes affected elsewhere
-   */
-  cacheAddMember(profile: AppBskyActorDefs.ProfileViewBasic) {
-    if (!this.isMember(profile.did)) {
-      this.items = this.items.concat([
-        {_reactKey: profile.did, subject: profile},
-      ])
-    }
-  }
-
-  /**
-   * Just removes from local cache; used to reflect changes affected elsewhere
-   */
-  cacheRemoveMember(profile: AppBskyActorDefs.ProfileViewBasic) {
-    if (this.isMember(profile.did)) {
-      this.items = this.items.filter(item => item.subject.did !== profile.did)
-    }
-  }
-
-  async pin() {
-    try {
-      await this.rootStore.preferences.addPinnedFeed(this.uri)
-    } catch (error) {
-      logger.error('Failed to pin feed', {error})
-    } finally {
-      track('CustomFeed:Pin', {
-        name: this.data?.name || '',
-        uri: this.uri,
-      })
-    }
-  }
-
-  async togglePin() {
-    if (!this.isPinned) {
-      track('CustomFeed:Pin', {
-        name: this.data?.name || '',
-        uri: this.uri,
-      })
-      return this.rootStore.preferences.addPinnedFeed(this.uri)
-    } else {
-      track('CustomFeed:Unpin', {
-        name: this.data?.name || '',
-        uri: this.uri,
-      })
-      // TODO TEMPORARY
-      // lists are temporarily piggybacking on the saved/pinned feeds preferences
-      // we'll eventually replace saved feeds with the bookmarks API
-      // until then, we need to unsave lists instead of just unpin them
-      // -prf
-      // return this.rootStore.preferences.removePinnedFeed(this.uri)
-      return this.rootStore.preferences.removeSavedFeed(this.uri)
-    }
-  }
-
-  async mute() {
-    if (!this.data) {
-      return
-    }
-    await this._resolveUri()
-    await this.rootStore.agent.muteModList(this.data.uri)
-    track('Lists:Mute')
-    runInAction(() => {
-      if (this.data) {
-        const d = this.data
-        this.data = {...d, viewer: {...(d.viewer || {}), muted: true}}
-      }
-    })
-  }
-
-  async unmute() {
-    if (!this.data) {
-      return
-    }
-    await this._resolveUri()
-    await this.rootStore.agent.unmuteModList(this.data.uri)
-    track('Lists:Unmute')
-    runInAction(() => {
-      if (this.data) {
-        const d = this.data
-        this.data = {...d, viewer: {...(d.viewer || {}), muted: false}}
-      }
-    })
-  }
-
-  async block() {
-    if (!this.data) {
-      return
-    }
-    await this._resolveUri()
-    const res = await this.rootStore.agent.blockModList(this.data.uri)
-    track('Lists:Block')
-    runInAction(() => {
-      if (this.data) {
-        const d = this.data
-        this.data = {...d, viewer: {...(d.viewer || {}), blocked: res.uri}}
-      }
-    })
-  }
-
-  async unblock() {
-    if (!this.data || !this.data.viewer?.blocked) {
-      return
-    }
-    await this._resolveUri()
-    await this.rootStore.agent.unblockModList(this.data.uri)
-    track('Lists:Unblock')
-    runInAction(() => {
-      if (this.data) {
-        const d = this.data
-        this.data = {...d, viewer: {...(d.viewer || {}), blocked: undefined}}
-      }
-    })
-  }
-
-  /**
-   * Attempt to load more again after a failure
-   */
-  async retryLoadMore() {
-    this.loadMoreError = ''
-    this.hasMore = true
-    return this.loadMore()
-  }
-
-  // state transitions
-  // =
-
-  _xLoading(isRefreshing = false) {
-    this.isLoading = true
-    this.isRefreshing = isRefreshing
-    this.error = ''
-  }
-
-  _xIdle(err?: any, loadMoreErr?: any) {
-    this.isLoading = false
-    this.isRefreshing = false
-    this.hasLoaded = true
-    this.error = cleanError(err)
-    this.loadMoreError = cleanError(loadMoreErr)
-    if (err) {
-      logger.error('Failed to fetch user items', {error: err})
-    }
-    if (loadMoreErr) {
-      logger.error('Failed to fetch user items', {
-        error: loadMoreErr,
-      })
-    }
-  }
-
-  // helper functions
-  // =
-
-  async _resolveUri() {
-    const urip = new AtUri(this.uri)
-    if (!urip.host.startsWith('did:')) {
-      try {
-        urip.host = await apilib.resolveName(this.rootStore, urip.host)
-      } catch (e: any) {
-        runInAction(() => {
-          this.error = e.toString()
-        })
-      }
-    }
-    runInAction(() => {
-      this.uri = urip.toString()
-    })
-  }
-
-  _replaceAll(res: GetList.Response) {
-    this.items = []
-    this._appendAll(res)
-  }
-
-  _appendAll(res: GetList.Response) {
-    this.loadMoreCursor = res.data.cursor
-    this.hasMore = !!this.loadMoreCursor
-    this.data = res.data.list
-    this.items = this.items.concat(
-      res.data.items.map(item => ({...item, _reactKey: item.subject.did})),
-    )
-    if (this.data.description) {
-      this.descriptionRT = new RichText({
-        text: this.data.description,
-        facets: (this.data.descriptionFacets || [])?.slice(),
-      })
-    } else {
-      this.descriptionRT = null
-    }
-  }
-}
diff --git a/src/state/models/content/profile.ts b/src/state/models/content/profile.ts
index 14362ceec..2abb9bfb5 100644
--- a/src/state/models/content/profile.ts
+++ b/src/state/models/content/profile.ts
@@ -158,7 +158,7 @@ export class ProfileModel {
       existing.description = updates.description
       if (newUserAvatar) {
         const res = await apilib.uploadBlob(
-          this.rootStore,
+          this.rootStore.agent,
           newUserAvatar.path,
           newUserAvatar.mime,
         )
@@ -168,7 +168,7 @@ export class ProfileModel {
       }
       if (newUserBanner) {
         const res = await apilib.uploadBlob(
-          this.rootStore,
+          this.rootStore.agent,
           newUserBanner.path,
           newUserBanner.mime,
         )
diff --git a/src/state/models/lists/lists-list.ts b/src/state/models/lists/lists-list.ts
deleted file mode 100644
index eb6291637..000000000
--- a/src/state/models/lists/lists-list.ts
+++ /dev/null
@@ -1,244 +0,0 @@
-import {makeAutoObservable} from 'mobx'
-import {AppBskyGraphDefs as GraphDefs} from '@atproto/api'
-import {RootStoreModel} from '../root-store'
-import {cleanError} from 'lib/strings/errors'
-import {bundleAsync} from 'lib/async/bundle'
-import {accumulate} from 'lib/async/accumulate'
-import {logger} from '#/logger'
-
-const PAGE_SIZE = 30
-
-export class ListsListModel {
-  // state
-  isLoading = false
-  isRefreshing = false
-  hasLoaded = false
-  error = ''
-  loadMoreError = ''
-  hasMore = true
-  loadMoreCursor?: string
-
-  // data
-  lists: GraphDefs.ListView[] = []
-
-  constructor(
-    public rootStore: RootStoreModel,
-    public source: 'mine' | 'my-curatelists' | 'my-modlists' | string,
-  ) {
-    makeAutoObservable(
-      this,
-      {
-        rootStore: false,
-      },
-      {autoBind: true},
-    )
-  }
-
-  get hasContent() {
-    return this.lists.length > 0
-  }
-
-  get hasError() {
-    return this.error !== ''
-  }
-
-  get isEmpty() {
-    return this.hasLoaded && !this.hasContent
-  }
-
-  get curatelists() {
-    return this.lists.filter(
-      list => list.purpose === 'app.bsky.graph.defs#curatelist',
-    )
-  }
-
-  get isCuratelistsEmpty() {
-    return this.hasLoaded && this.curatelists.length === 0
-  }
-
-  get modlists() {
-    return this.lists.filter(
-      list => list.purpose === 'app.bsky.graph.defs#modlist',
-    )
-  }
-
-  get isModlistsEmpty() {
-    return this.hasLoaded && this.modlists.length === 0
-  }
-
-  /**
-   * Removes posts from the feed upon deletion.
-   */
-  onListDeleted(uri: string) {
-    this.lists = this.lists.filter(l => l.uri !== uri)
-  }
-
-  // public api
-  // =
-
-  /**
-   * Register any event listeners. Returns a cleanup function.
-   */
-  registerListeners() {
-    const sub = this.rootStore.onListDeleted(this.onListDeleted.bind(this))
-    return () => sub.remove()
-  }
-
-  async refresh() {
-    return this.loadMore(true)
-  }
-
-  loadMore = bundleAsync(async (replace: boolean = false) => {
-    if (!replace && !this.hasMore) {
-      return
-    }
-    this._xLoading(replace)
-    try {
-      let cursor: string | undefined
-      let lists: GraphDefs.ListView[] = []
-      if (
-        this.source === 'mine' ||
-        this.source === 'my-curatelists' ||
-        this.source === 'my-modlists'
-      ) {
-        const promises = [
-          accumulate(cursor =>
-            this.rootStore.agent.app.bsky.graph
-              .getLists({
-                actor: this.rootStore.me.did,
-                cursor,
-                limit: 50,
-              })
-              .then(res => ({cursor: res.data.cursor, items: res.data.lists})),
-          ),
-        ]
-        if (this.source === 'my-modlists') {
-          promises.push(
-            accumulate(cursor =>
-              this.rootStore.agent.app.bsky.graph
-                .getListMutes({
-                  cursor,
-                  limit: 50,
-                })
-                .then(res => ({
-                  cursor: res.data.cursor,
-                  items: res.data.lists,
-                })),
-            ),
-          )
-          promises.push(
-            accumulate(cursor =>
-              this.rootStore.agent.app.bsky.graph
-                .getListBlocks({
-                  cursor,
-                  limit: 50,
-                })
-                .then(res => ({
-                  cursor: res.data.cursor,
-                  items: res.data.lists,
-                })),
-            ),
-          )
-        }
-        const resultset = await Promise.all(promises)
-        for (const res of resultset) {
-          for (let list of res) {
-            if (
-              this.source === 'my-curatelists' &&
-              list.purpose !== 'app.bsky.graph.defs#curatelist'
-            ) {
-              continue
-            }
-            if (
-              this.source === 'my-modlists' &&
-              list.purpose !== 'app.bsky.graph.defs#modlist'
-            ) {
-              continue
-            }
-            if (!lists.find(l => l.uri === list.uri)) {
-              lists.push(list)
-            }
-          }
-        }
-      } else {
-        const res = await this.rootStore.agent.app.bsky.graph.getLists({
-          actor: this.source,
-          limit: PAGE_SIZE,
-          cursor: replace ? undefined : this.loadMoreCursor,
-        })
-        lists = res.data.lists
-        cursor = res.data.cursor
-      }
-      if (replace) {
-        this._replaceAll({lists, cursor})
-      } else {
-        this._appendAll({lists, cursor})
-      }
-      this._xIdle()
-    } catch (e: any) {
-      this._xIdle(replace ? e : undefined, !replace ? e : undefined)
-    }
-  })
-
-  /**
-   * Attempt to load more again after a failure
-   */
-  async retryLoadMore() {
-    this.loadMoreError = ''
-    this.hasMore = true
-    return this.loadMore()
-  }
-
-  // state transitions
-  // =
-
-  _xLoading(isRefreshing = false) {
-    this.isLoading = true
-    this.isRefreshing = isRefreshing
-    this.error = ''
-  }
-
-  _xIdle(err?: any, loadMoreErr?: any) {
-    this.isLoading = false
-    this.isRefreshing = false
-    this.hasLoaded = true
-    this.error = cleanError(err)
-    this.loadMoreError = cleanError(loadMoreErr)
-    if (err) {
-      logger.error('Failed to fetch user lists', {error: err})
-    }
-    if (loadMoreErr) {
-      logger.error('Failed to fetch user lists', {
-        error: loadMoreErr,
-      })
-    }
-  }
-
-  // helper functions
-  // =
-
-  _replaceAll({
-    lists,
-    cursor,
-  }: {
-    lists: GraphDefs.ListView[]
-    cursor: string | undefined
-  }) {
-    this.lists = []
-    this._appendAll({lists, cursor})
-  }
-
-  _appendAll({
-    lists,
-    cursor,
-  }: {
-    lists: GraphDefs.ListView[]
-    cursor: string | undefined
-  }) {
-    this.loadMoreCursor = cursor
-    this.hasMore = !!this.loadMoreCursor
-    this.lists = this.lists.concat(
-      lists.map(list => ({...list, _reactKey: list.uri})),
-    )
-  }
-}
diff --git a/src/state/models/ui/profile.ts b/src/state/models/ui/profile.ts
index 0ef592928..d6ea0c084 100644
--- a/src/state/models/ui/profile.ts
+++ b/src/state/models/ui/profile.ts
@@ -2,7 +2,6 @@ import {makeAutoObservable, runInAction} from 'mobx'
 import {RootStoreModel} from '../root-store'
 import {ProfileModel} from '../content/profile'
 import {ActorFeedsModel} from '../lists/actor-feeds'
-import {ListsListModel} from '../lists/lists-list'
 import {logger} from '#/logger'
 
 export enum Sections {
diff --git a/src/state/queries/actor-autocomplete.ts b/src/state/queries/actor-autocomplete.ts
new file mode 100644
index 000000000..18abb6314
--- /dev/null
+++ b/src/state/queries/actor-autocomplete.ts
@@ -0,0 +1,66 @@
+import {AppBskyActorDefs} from '@atproto/api'
+import {useQuery} from '@tanstack/react-query'
+import {useSession} from '../session'
+import {useMyFollowsQuery} from './my-follows'
+
+export const RQKEY = (prefix: string) => ['actor-autocomplete', prefix]
+
+export function useActorAutocompleteQuery(prefix: string) {
+  const {agent} = useSession()
+  const {data: follows, isFetching} = useMyFollowsQuery()
+  return useQuery<AppBskyActorDefs.ProfileViewBasic[]>({
+    queryKey: RQKEY(prefix || ''),
+    async queryFn() {
+      const res = await agent.searchActorsTypeahead({
+        term: prefix,
+        limit: 8,
+      })
+      return computeSuggestions(prefix, follows, res.data.actors)
+    },
+    enabled: !isFetching && !!prefix,
+  })
+}
+
+function computeSuggestions(
+  prefix: string,
+  follows: AppBskyActorDefs.ProfileViewBasic[] = [],
+  searched: AppBskyActorDefs.ProfileViewBasic[] = [],
+) {
+  if (prefix) {
+    const items: AppBskyActorDefs.ProfileViewBasic[] = []
+    for (const item of follows) {
+      if (prefixMatch(prefix, item)) {
+        items.push(item)
+      }
+      if (items.length >= 8) {
+        break
+      }
+    }
+    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,
+        })
+      }
+    }
+    return items
+  } else {
+    return follows
+  }
+}
+
+function prefixMatch(
+  prefix: string,
+  info: AppBskyActorDefs.ProfileViewBasic,
+): boolean {
+  if (info.handle.includes(prefix)) {
+    return true
+  }
+  if (info.displayName?.toLocaleLowerCase().includes(prefix)) {
+    return true
+  }
+  return false
+}
diff --git a/src/state/queries/list-members.ts b/src/state/queries/list-members.ts
new file mode 100644
index 000000000..ec5daec90
--- /dev/null
+++ b/src/state/queries/list-members.ts
@@ -0,0 +1,31 @@
+import {AppBskyGraphGetList} from '@atproto/api'
+import {useInfiniteQuery, InfiniteData, QueryKey} from '@tanstack/react-query'
+import {useSession} from '../session'
+
+const PAGE_SIZE = 30
+type RQPageParam = string | undefined
+
+export const RQKEY = (uri: string) => ['list-members', uri]
+
+export function useListMembersQuery(uri: string) {
+  const {agent} = useSession()
+  return useInfiniteQuery<
+    AppBskyGraphGetList.OutputSchema,
+    Error,
+    InfiniteData<AppBskyGraphGetList.OutputSchema>,
+    QueryKey,
+    RQPageParam
+  >({
+    queryKey: RQKEY(uri),
+    async queryFn({pageParam}: {pageParam: RQPageParam}) {
+      const res = await agent.app.bsky.graph.getList({
+        list: uri,
+        limit: PAGE_SIZE,
+        cursor: pageParam,
+      })
+      return res.data
+    },
+    initialPageParam: undefined,
+    getNextPageParam: lastPage => lastPage.cursor,
+  })
+}
diff --git a/src/state/queries/list-memberships.ts b/src/state/queries/list-memberships.ts
new file mode 100644
index 000000000..f128c5867
--- /dev/null
+++ b/src/state/queries/list-memberships.ts
@@ -0,0 +1,190 @@
+/**
+ * NOTE
+ *
+ * This query is a temporary solution to our lack of server API for
+ * querying user membership in an API. It is extremely inefficient.
+ *
+ * THIS SHOULD ONLY BE USED IN MODALS FOR MODIFYING A USER'S LIST MEMBERSHIP!
+ * Use the list-members query for rendering a list's members.
+ *
+ * It works by fetching *all* of the user's list item records and querying
+ * or manipulating that cache. For users with large lists, it will fall
+ * down completely, so be very conservative about how you use it.
+ *
+ * -prf
+ */
+
+import {AtUri} from '@atproto/api'
+import {useMutation, useQuery, useQueryClient} from '@tanstack/react-query'
+import {useSession} from '../session'
+import {RQKEY as LIST_MEMBERS_RQKEY} from './list-members'
+
+// sanity limit is SANITY_PAGE_LIMIT*PAGE_SIZE total records
+const SANITY_PAGE_LIMIT = 1000
+const PAGE_SIZE = 100
+// ...which comes 100,000k list members
+
+export const RQKEY = () => ['list-memberships']
+
+export interface ListMembersip {
+  membershipUri: string
+  listUri: string
+  actorDid: string
+}
+
+/**
+ * This API is dangerous! Read the note above!
+ */
+export function useDangerousListMembershipsQuery() {
+  const {agent, currentAccount} = useSession()
+  return useQuery<ListMembersip[]>({
+    queryKey: RQKEY(),
+    async queryFn() {
+      if (!currentAccount) {
+        return []
+      }
+      let cursor
+      let arr: ListMembersip[] = []
+      for (let i = 0; i < SANITY_PAGE_LIMIT; i++) {
+        const res = await agent.app.bsky.graph.listitem.list({
+          repo: currentAccount.did,
+          limit: PAGE_SIZE,
+          cursor,
+        })
+        arr = arr.concat(
+          res.records.map(r => ({
+            membershipUri: r.uri,
+            listUri: r.value.list,
+            actorDid: r.value.subject,
+          })),
+        )
+        cursor = res.cursor
+        if (!cursor) {
+          break
+        }
+      }
+      return arr
+    },
+  })
+}
+
+/**
+ * Returns undefined for pending, false for not a member, and string for a member (the URI of the membership record)
+ */
+export function getMembership(
+  memberships: ListMembersip[] | undefined,
+  list: string,
+  actor: string,
+): string | false | undefined {
+  if (!memberships) {
+    return undefined
+  }
+  const membership = memberships.find(
+    m => m.listUri === list && m.actorDid === actor,
+  )
+  return membership ? membership.membershipUri : false
+}
+
+export function useListMembershipAddMutation() {
+  const {agent, currentAccount} = useSession()
+  const queryClient = useQueryClient()
+  return useMutation<
+    {uri: string; cid: string},
+    Error,
+    {listUri: string; actorDid: string}
+  >({
+    mutationFn: async ({listUri, actorDid}) => {
+      if (!currentAccount) {
+        throw new Error('Not logged in')
+      }
+      const res = await agent.app.bsky.graph.listitem.create(
+        {repo: currentAccount.did},
+        {
+          subject: actorDid,
+          list: listUri,
+          createdAt: new Date().toISOString(),
+        },
+      )
+      // TODO
+      // we need to wait for appview to update, but there's not an efficient
+      // query for that, so we use a timeout below
+      // -prf
+      return res
+    },
+    onSuccess(data, variables) {
+      // manually update the cache; a refetch is too expensive
+      let memberships = queryClient.getQueryData<ListMembersip[]>(RQKEY())
+      if (memberships) {
+        memberships = memberships
+          // avoid dups
+          .filter(
+            m =>
+              !(
+                m.actorDid === variables.actorDid &&
+                m.listUri === variables.listUri
+              ),
+          )
+          .concat([
+            {
+              ...variables,
+              membershipUri: data.uri,
+            },
+          ])
+        queryClient.setQueryData(RQKEY(), memberships)
+      }
+      // invalidate the members queries (used for rendering the listings)
+      // use a timeout to wait for the appview (see above)
+      setTimeout(() => {
+        queryClient.invalidateQueries({
+          queryKey: LIST_MEMBERS_RQKEY(variables.listUri),
+        })
+      }, 1e3)
+    },
+  })
+}
+
+export function useListMembershipRemoveMutation() {
+  const {agent, currentAccount} = useSession()
+  const queryClient = useQueryClient()
+  return useMutation<
+    void,
+    Error,
+    {listUri: string; actorDid: string; membershipUri: string}
+  >({
+    mutationFn: async ({membershipUri}) => {
+      if (!currentAccount) {
+        throw new Error('Not logged in')
+      }
+      const membershipUrip = new AtUri(membershipUri)
+      await agent.app.bsky.graph.listitem.delete({
+        repo: currentAccount.did,
+        rkey: membershipUrip.rkey,
+      })
+      // TODO
+      // we need to wait for appview to update, but there's not an efficient
+      // query for that, so we use a timeout below
+      // -prf
+    },
+    onSuccess(data, variables) {
+      // manually update the cache; a refetch is too expensive
+      let memberships = queryClient.getQueryData<ListMembersip[]>(RQKEY())
+      if (memberships) {
+        memberships = memberships.filter(
+          m =>
+            !(
+              m.actorDid === variables.actorDid &&
+              m.listUri === variables.listUri
+            ),
+        )
+        queryClient.setQueryData(RQKEY(), memberships)
+      }
+      // invalidate the members queries (used for rendering the listings)
+      // use a timeout to wait for the appview (see above)
+      setTimeout(() => {
+        queryClient.invalidateQueries({
+          queryKey: LIST_MEMBERS_RQKEY(variables.listUri),
+        })
+      }, 1e3)
+    },
+  })
+}
diff --git a/src/state/queries/list.ts b/src/state/queries/list.ts
new file mode 100644
index 000000000..4a46a4fbe
--- /dev/null
+++ b/src/state/queries/list.ts
@@ -0,0 +1,285 @@
+import {
+  AtUri,
+  AppBskyGraphGetList,
+  AppBskyGraphList,
+  AppBskyGraphDefs,
+  BskyAgent,
+} from '@atproto/api'
+import {Image as RNImage} from 'react-native-image-crop-picker'
+import {useQuery, useMutation, useQueryClient} from '@tanstack/react-query'
+import chunk from 'lodash.chunk'
+import {useSession} from '../session'
+import {invalidate as invalidateMyLists} from './my-lists'
+import {RQKEY as PROFILE_LISTS_RQKEY} from './profile-lists'
+import {uploadBlob} from '#/lib/api'
+import {until} from '#/lib/async/until'
+
+export const RQKEY = (uri: string) => ['list', uri]
+
+export function useListQuery(uri?: string) {
+  const {agent} = useSession()
+  return useQuery<AppBskyGraphDefs.ListView, Error>({
+    queryKey: RQKEY(uri || ''),
+    async queryFn() {
+      if (!uri) {
+        throw new Error('URI not provided')
+      }
+      const res = await agent.app.bsky.graph.getList({
+        list: uri,
+        limit: 1,
+      })
+      return res.data.list
+    },
+    enabled: !!uri,
+  })
+}
+
+export interface ListCreateMutateParams {
+  purpose: string
+  name: string
+  description: string
+  avatar: RNImage | null | undefined
+}
+export function useListCreateMutation() {
+  const {agent, currentAccount} = useSession()
+  const queryClient = useQueryClient()
+  return useMutation<{uri: string; cid: string}, Error, ListCreateMutateParams>(
+    {
+      async mutationFn({purpose, name, description, avatar}) {
+        if (!currentAccount) {
+          throw new Error('Not logged in')
+        }
+        if (
+          purpose !== 'app.bsky.graph.defs#curatelist' &&
+          purpose !== 'app.bsky.graph.defs#modlist'
+        ) {
+          throw new Error('Invalid list purpose: must be curatelist or modlist')
+        }
+        const record: AppBskyGraphList.Record = {
+          purpose,
+          name,
+          description,
+          avatar: undefined,
+          createdAt: new Date().toISOString(),
+        }
+        if (avatar) {
+          const blobRes = await uploadBlob(agent, avatar.path, avatar.mime)
+          record.avatar = blobRes.data.blob
+        }
+        const res = await agent.app.bsky.graph.list.create(
+          {
+            repo: currentAccount.did,
+          },
+          record,
+        )
+
+        // wait for the appview to update
+        await whenAppViewReady(
+          agent,
+          res.uri,
+          (v: AppBskyGraphGetList.Response) => {
+            return typeof v?.data?.list.uri === 'string'
+          },
+        )
+        return res
+      },
+      onSuccess() {
+        invalidateMyLists(queryClient)
+        queryClient.invalidateQueries({
+          queryKey: PROFILE_LISTS_RQKEY(currentAccount!.did),
+        })
+      },
+    },
+  )
+}
+
+export interface ListMetadataMutateParams {
+  uri: string
+  name: string
+  description: string
+  avatar: RNImage | null | undefined
+}
+export function useListMetadataMutation() {
+  const {agent, currentAccount} = useSession()
+  const queryClient = useQueryClient()
+  return useMutation<
+    {uri: string; cid: string},
+    Error,
+    ListMetadataMutateParams
+  >({
+    async mutationFn({uri, name, description, avatar}) {
+      const {hostname, rkey} = new AtUri(uri)
+      if (!currentAccount) {
+        throw new Error('Not logged in')
+      }
+      if (currentAccount.did !== hostname) {
+        throw new Error('You do not own this list')
+      }
+
+      // get the current record
+      const {value: record} = await agent.app.bsky.graph.list.get({
+        repo: currentAccount.did,
+        rkey,
+      })
+
+      // update the fields
+      record.name = name
+      record.description = description
+      if (avatar) {
+        const blobRes = await uploadBlob(agent, avatar.path, avatar.mime)
+        record.avatar = blobRes.data.blob
+      } else if (avatar === null) {
+        record.avatar = undefined
+      }
+      const res = (
+        await agent.com.atproto.repo.putRecord({
+          repo: currentAccount.did,
+          collection: 'app.bsky.graph.list',
+          rkey,
+          record,
+        })
+      ).data
+
+      // wait for the appview to update
+      await whenAppViewReady(
+        agent,
+        res.uri,
+        (v: AppBskyGraphGetList.Response) => {
+          const list = v.data.list
+          return (
+            list.name === record.name && list.description === record.description
+          )
+        },
+      )
+      return res
+    },
+    onSuccess(data, variables) {
+      invalidateMyLists(queryClient)
+      queryClient.invalidateQueries({
+        queryKey: PROFILE_LISTS_RQKEY(currentAccount!.did),
+      })
+      queryClient.invalidateQueries({
+        queryKey: RQKEY(variables.uri),
+      })
+    },
+  })
+}
+
+export function useListDeleteMutation() {
+  const {agent, currentAccount} = useSession()
+  const queryClient = useQueryClient()
+  return useMutation<void, Error, {uri: string}>({
+    mutationFn: async ({uri}) => {
+      if (!currentAccount) {
+        return
+      }
+      // fetch all the listitem records that belong to this list
+      let cursor
+      let listitemRecordUris: string[] = []
+      for (let i = 0; i < 100; i++) {
+        const res = await agent.app.bsky.graph.listitem.list({
+          repo: currentAccount.did,
+          cursor,
+          limit: 100,
+        })
+        listitemRecordUris = listitemRecordUris.concat(
+          res.records
+            .filter(record => record.value.list === uri)
+            .map(record => record.uri),
+        )
+        cursor = res.cursor
+        if (!cursor) {
+          break
+        }
+      }
+
+      // batch delete the list and listitem records
+      const createDel = (uri: string) => {
+        const urip = new AtUri(uri)
+        return {
+          $type: 'com.atproto.repo.applyWrites#delete',
+          collection: urip.collection,
+          rkey: urip.rkey,
+        }
+      }
+      const writes = listitemRecordUris
+        .map(uri => createDel(uri))
+        .concat([createDel(uri)])
+
+      // apply in chunks
+      for (const writesChunk of chunk(writes, 10)) {
+        await agent.com.atproto.repo.applyWrites({
+          repo: currentAccount.did,
+          writes: writesChunk,
+        })
+      }
+
+      // wait for the appview to update
+      await whenAppViewReady(agent, uri, (v: AppBskyGraphGetList.Response) => {
+        return !v?.success
+      })
+    },
+    onSuccess() {
+      invalidateMyLists(queryClient)
+      queryClient.invalidateQueries({
+        queryKey: PROFILE_LISTS_RQKEY(currentAccount!.did),
+      })
+      // TODO!! /* dont await */ this.rootStore.preferences.removeSavedFeed(this.uri)
+    },
+  })
+}
+
+export function useListMuteMutation() {
+  const {agent} = useSession()
+  const queryClient = useQueryClient()
+  return useMutation<void, Error, {uri: string; mute: boolean}>({
+    mutationFn: async ({uri, mute}) => {
+      if (mute) {
+        await agent.muteModList(uri)
+      } else {
+        await agent.unmuteModList(uri)
+      }
+    },
+    onSuccess(data, variables) {
+      queryClient.invalidateQueries({
+        queryKey: RQKEY(variables.uri),
+      })
+    },
+  })
+}
+
+export function useListBlockMutation() {
+  const {agent} = useSession()
+  const queryClient = useQueryClient()
+  return useMutation<void, Error, {uri: string; block: boolean}>({
+    mutationFn: async ({uri, block}) => {
+      if (block) {
+        await agent.blockModList(uri)
+      } else {
+        await agent.unblockModList(uri)
+      }
+    },
+    onSuccess(data, variables) {
+      queryClient.invalidateQueries({
+        queryKey: RQKEY(variables.uri),
+      })
+    },
+  })
+}
+
+async function whenAppViewReady(
+  agent: BskyAgent,
+  uri: string,
+  fn: (res: AppBskyGraphGetList.Response) => boolean,
+) {
+  await until(
+    5, // 5 tries
+    1e3, // 1s delay between tries
+    fn,
+    () =>
+      agent.app.bsky.graph.getList({
+        list: uri,
+        limit: 1,
+      }),
+  )
+}
diff --git a/src/state/queries/my-follows.ts b/src/state/queries/my-follows.ts
new file mode 100644
index 000000000..ad6cf837d
--- /dev/null
+++ b/src/state/queries/my-follows.ts
@@ -0,0 +1,43 @@
+import {AppBskyActorDefs} from '@atproto/api'
+import {useQuery} from '@tanstack/react-query'
+import {useSession} from '../session'
+
+// sanity limit is SANITY_PAGE_LIMIT*PAGE_SIZE total records
+const SANITY_PAGE_LIMIT = 1000
+const PAGE_SIZE = 100
+// ...which comes 10,000k follows
+
+export const RQKEY = () => ['my-follows']
+
+export function useMyFollowsQuery() {
+  const {agent, currentAccount} = useSession()
+  return useQuery<AppBskyActorDefs.ProfileViewBasic[]>({
+    queryKey: RQKEY(),
+    async queryFn() {
+      if (!currentAccount) {
+        return []
+      }
+      let cursor
+      let arr: AppBskyActorDefs.ProfileViewBasic[] = []
+      for (let i = 0; i < SANITY_PAGE_LIMIT; i++) {
+        const res = await agent.getFollows({
+          actor: currentAccount.did,
+          cursor,
+          limit: PAGE_SIZE,
+        })
+        // TODO
+        // res.data.follows = res.data.follows.filter(
+        //   profile =>
+        //     !moderateProfile(profile, this.rootStore.preferences.moderationOpts)
+        //       .account.filter,
+        // )
+        arr = arr.concat(res.data.follows)
+        if (!res.data.cursor) {
+          break
+        }
+        cursor = res.data.cursor
+      }
+      return arr
+    },
+  })
+}
diff --git a/src/state/queries/my-lists.ts b/src/state/queries/my-lists.ts
new file mode 100644
index 000000000..d412cff02
--- /dev/null
+++ b/src/state/queries/my-lists.ts
@@ -0,0 +1,89 @@
+import {AppBskyGraphDefs} from '@atproto/api'
+import {useQuery, QueryClient} from '@tanstack/react-query'
+import {accumulate} from 'lib/async/accumulate'
+import {useSession} from '../session'
+
+export type MyListsFilter = 'all' | 'curate' | 'mod'
+export const RQKEY = (filter: MyListsFilter) => ['my-lists', filter]
+
+export function useMyListsQuery(filter: MyListsFilter) {
+  const {agent, currentAccount} = useSession()
+  return useQuery<AppBskyGraphDefs.ListView[]>({
+    queryKey: RQKEY(filter),
+    async queryFn() {
+      let lists: AppBskyGraphDefs.ListView[] = []
+      const promises = [
+        accumulate(cursor =>
+          agent.app.bsky.graph
+            .getLists({
+              actor: currentAccount!.did,
+              cursor,
+              limit: 50,
+            })
+            .then(res => ({
+              cursor: res.data.cursor,
+              items: res.data.lists,
+            })),
+        ),
+      ]
+      if (filter === 'all' || filter === 'mod') {
+        promises.push(
+          accumulate(cursor =>
+            agent.app.bsky.graph
+              .getListMutes({
+                cursor,
+                limit: 50,
+              })
+              .then(res => ({
+                cursor: res.data.cursor,
+                items: res.data.lists,
+              })),
+          ),
+        )
+        promises.push(
+          accumulate(cursor =>
+            agent.app.bsky.graph
+              .getListBlocks({
+                cursor,
+                limit: 50,
+              })
+              .then(res => ({
+                cursor: res.data.cursor,
+                items: res.data.lists,
+              })),
+          ),
+        )
+      }
+      const resultset = await Promise.all(promises)
+      for (const res of resultset) {
+        for (let list of res) {
+          if (
+            filter === 'curate' &&
+            list.purpose !== 'app.bsky.graph.defs#curatelist'
+          ) {
+            continue
+          }
+          if (
+            filter === 'mod' &&
+            list.purpose !== 'app.bsky.graph.defs#modlist'
+          ) {
+            continue
+          }
+          if (!lists.find(l => l.uri === list.uri)) {
+            lists.push(list)
+          }
+        }
+      }
+      return lists
+    },
+    enabled: !!currentAccount,
+  })
+}
+
+export function invalidate(qc: QueryClient, filter?: MyListsFilter) {
+  if (filter) {
+    qc.invalidateQueries({queryKey: RQKEY(filter)})
+  } else {
+    qc.invalidateQueries({queryKey: ['my-lists']})
+  }
+}
diff --git a/src/state/queries/profile-lists.ts b/src/state/queries/profile-lists.ts
new file mode 100644
index 000000000..a277a6d61
--- /dev/null
+++ b/src/state/queries/profile-lists.ts
@@ -0,0 +1,31 @@
+import {AppBskyGraphGetLists} from '@atproto/api'
+import {useInfiniteQuery, InfiniteData, QueryKey} from '@tanstack/react-query'
+import {useSession} from '../session'
+
+const PAGE_SIZE = 30
+type RQPageParam = string | undefined
+
+export const RQKEY = (did: string) => ['profile-lists', did]
+
+export function useProfileListsQuery(did: string) {
+  const {agent} = useSession()
+  return useInfiniteQuery<
+    AppBskyGraphGetLists.OutputSchema,
+    Error,
+    InfiniteData<AppBskyGraphGetLists.OutputSchema>,
+    QueryKey,
+    RQPageParam
+  >({
+    queryKey: RQKEY(did),
+    async queryFn({pageParam}: {pageParam: RQPageParam}) {
+      const res = await agent.app.bsky.graph.getLists({
+        actor: did,
+        limit: PAGE_SIZE,
+        cursor: pageParam,
+      })
+      return res.data
+    },
+    initialPageParam: undefined,
+    getNextPageParam: lastPage => lastPage.cursor,
+  })
+}