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/messages/__tests__/convo.test.ts65
-rw-r--r--src/state/messages/convo.ts900
-rw-r--r--src/state/messages/index.tsx48
-rw-r--r--src/state/persisted/index.ts8
-rw-r--r--src/state/persisted/schema.ts23
-rw-r--r--src/state/preferences/index.tsx8
-rw-r--r--src/state/preferences/kawaii.tsx50
-rw-r--r--src/state/preferences/label-defs.tsx8
-rw-r--r--src/state/preferences/moderation-opts.tsx61
-rw-r--r--src/state/queries/actor-autocomplete.ts9
-rw-r--r--src/state/queries/index.ts8
-rw-r--r--src/state/queries/messages/conversation.ts25
-rw-r--r--src/state/queries/messages/get-convo-for-members.ts39
-rw-r--r--src/state/queries/messages/leave-conversation.ts68
-rw-r--r--src/state/queries/messages/list-converations.ts29
-rw-r--r--src/state/queries/messages/mute-conversation.ts84
-rw-r--r--src/state/queries/messages/temp-headers.ts11
-rw-r--r--src/state/queries/notifications/feed.ts2
-rw-r--r--src/state/queries/notifications/unread.tsx2
-rw-r--r--src/state/queries/post-feed.ts16
-rw-r--r--src/state/queries/preferences/index.ts48
-rw-r--r--src/state/queries/suggested-follows.ts28
-rw-r--r--src/state/session/index.tsx801
-rw-r--r--src/state/session/types.ts56
-rw-r--r--src/state/session/util/index.ts177
-rw-r--r--src/state/session/util/readLastActiveAccount.ts6
26 files changed, 2012 insertions, 568 deletions
diff --git a/src/state/messages/__tests__/convo.test.ts b/src/state/messages/__tests__/convo.test.ts
new file mode 100644
index 000000000..44fe16fef
--- /dev/null
+++ b/src/state/messages/__tests__/convo.test.ts
@@ -0,0 +1,65 @@
+import {describe, it} from '@jest/globals'
+
+describe(`#/state/messages/convo`, () => {
+  describe(`init`, () => {
+    it.todo(`fails if sender and recipients aren't found`)
+    it.todo(`cannot re-initialize from a non-unintialized state`)
+    it.todo(`can re-initialize from a failed state`)
+  })
+
+  describe(`resume`, () => {
+    it.todo(`restores previous state if resume fails`)
+  })
+
+  describe(`suspend`, () => {
+    it.todo(`cannot be interacted with when suspended`)
+    it.todo(`polling is stopped when suspended`)
+  })
+
+  describe(`read states`, () => {
+    it.todo(`should mark messages as read as they come in`)
+  })
+
+  describe(`history fetching`, () => {
+    it.todo(`fetches initial chat history`)
+    it.todo(`fetches additional chat history`)
+    it.todo(`handles history fetch failure`)
+    it.todo(`does not insert deleted messages`)
+  })
+
+  describe(`sending messages`, () => {
+    it.todo(`optimistically adds sending messages`)
+    it.todo(`sends messages in order`)
+    it.todo(`failed message send fails all sending messages`)
+    it.todo(`can retry all failed messages via retry ConvoItem`)
+    it.todo(
+      `successfully sent messages are re-ordered, if needed, by events received from server`,
+    )
+  })
+
+  describe(`deleting messages`, () => {
+    it.todo(`messages are optimistically deleted from the chat`)
+    it.todo(`messages are confirmed deleted via events from the server`)
+  })
+
+  describe(`log handling`, () => {
+    it.todo(`updates rev to latest message received`)
+    it.todo(`only handles log events for this convoId`)
+    it.todo(`does not insert deleted messages`)
+  })
+
+  describe(`item ordering`, () => {
+    it.todo(`pending items are first, and in order`)
+    it.todo(`new message items are next, and in order`)
+    it.todo(`past message items are next, and in order`)
+  })
+
+  describe(`inactivity`, () => {
+    it.todo(
+      `below a certain threshold of inactivity, restore entirely from log`,
+    )
+    it.todo(
+      `above a certain threshold of inactivity, rehydrate entirely fresh state`,
+    )
+  })
+})
diff --git a/src/state/messages/convo.ts b/src/state/messages/convo.ts
new file mode 100644
index 000000000..81ab94f43
--- /dev/null
+++ b/src/state/messages/convo.ts
@@ -0,0 +1,900 @@
+import {AppBskyActorDefs} from '@atproto/api'
+import {
+  BskyAgent,
+  ChatBskyConvoDefs,
+  ChatBskyConvoSendMessage,
+} from '@atproto-labs/api'
+import {nanoid} from 'nanoid/non-secure'
+
+import {logger} from '#/logger'
+import {isNative} from '#/platform/detection'
+
+export type ConvoParams = {
+  convoId: string
+  agent: BskyAgent
+  __tempFromUserDid: string
+}
+
+export enum ConvoStatus {
+  Uninitialized = 'uninitialized',
+  Initializing = 'initializing',
+  Resuming = 'resuming',
+  Ready = 'ready',
+  Error = 'error',
+  Backgrounded = 'backgrounded',
+  Suspended = 'suspended',
+}
+
+export enum ConvoItemError {
+  HistoryFailed = 'historyFailed',
+  ResumeFailed = 'resumeFailed',
+  PollFailed = 'pollFailed',
+}
+
+export enum ConvoError {
+  InitFailed = 'initFailed',
+}
+
+export type ConvoItem =
+  | {
+      type: 'message' | 'pending-message'
+      key: string
+      message: ChatBskyConvoDefs.MessageView
+      nextMessage:
+        | ChatBskyConvoDefs.MessageView
+        | ChatBskyConvoDefs.DeletedMessageView
+        | null
+    }
+  | {
+      type: 'deleted-message'
+      key: string
+      message: ChatBskyConvoDefs.DeletedMessageView
+      nextMessage:
+        | ChatBskyConvoDefs.MessageView
+        | ChatBskyConvoDefs.DeletedMessageView
+        | null
+    }
+  | {
+      type: 'pending-retry'
+      key: string
+      retry: () => void
+    }
+  | {
+      type: 'error-recoverable'
+      key: string
+      code: ConvoItemError
+      retry: () => void
+    }
+
+export type ConvoState =
+  | {
+      status: ConvoStatus.Uninitialized
+      items: []
+      convo: undefined
+      error: undefined
+      sender: undefined
+      recipients: undefined
+      isFetchingHistory: false
+      deleteMessage: undefined
+      sendMessage: undefined
+      fetchMessageHistory: undefined
+    }
+  | {
+      status: ConvoStatus.Initializing
+      items: []
+      convo: undefined
+      error: undefined
+      sender: undefined
+      recipients: undefined
+      isFetchingHistory: boolean
+      deleteMessage: undefined
+      sendMessage: undefined
+      fetchMessageHistory: undefined
+    }
+  | {
+      status: ConvoStatus.Ready
+      items: ConvoItem[]
+      convo: ChatBskyConvoDefs.ConvoView
+      error: undefined
+      sender: AppBskyActorDefs.ProfileViewBasic
+      recipients: AppBskyActorDefs.ProfileViewBasic[]
+      isFetchingHistory: boolean
+      deleteMessage: (messageId: string) => Promise<void>
+      sendMessage: (
+        message: ChatBskyConvoSendMessage.InputSchema['message'],
+      ) => void
+      fetchMessageHistory: () => void
+    }
+  | {
+      status: ConvoStatus.Suspended
+      items: ConvoItem[]
+      convo: ChatBskyConvoDefs.ConvoView
+      error: undefined
+      sender: AppBskyActorDefs.ProfileViewBasic
+      recipients: AppBskyActorDefs.ProfileViewBasic[]
+      isFetchingHistory: boolean
+      deleteMessage: (messageId: string) => Promise<void>
+      sendMessage: (
+        message: ChatBskyConvoSendMessage.InputSchema['message'],
+      ) => Promise<void>
+      fetchMessageHistory: () => Promise<void>
+    }
+  | {
+      status: ConvoStatus.Backgrounded
+      items: ConvoItem[]
+      convo: ChatBskyConvoDefs.ConvoView
+      error: undefined
+      sender: AppBskyActorDefs.ProfileViewBasic
+      recipients: AppBskyActorDefs.ProfileViewBasic[]
+      isFetchingHistory: boolean
+      deleteMessage: (messageId: string) => Promise<void>
+      sendMessage: (
+        message: ChatBskyConvoSendMessage.InputSchema['message'],
+      ) => Promise<void>
+      fetchMessageHistory: () => Promise<void>
+    }
+  | {
+      status: ConvoStatus.Resuming
+      items: ConvoItem[]
+      convo: ChatBskyConvoDefs.ConvoView
+      error: undefined
+      sender: AppBskyActorDefs.ProfileViewBasic
+      recipients: AppBskyActorDefs.ProfileViewBasic[]
+      isFetchingHistory: boolean
+      deleteMessage: (messageId: string) => Promise<void>
+      sendMessage: (
+        message: ChatBskyConvoSendMessage.InputSchema['message'],
+      ) => Promise<void>
+      fetchMessageHistory: () => Promise<void>
+    }
+  | {
+      status: ConvoStatus.Error
+      items: []
+      convo: undefined
+      error: any
+      sender: undefined
+      recipients: undefined
+      isFetchingHistory: false
+      deleteMessage: undefined
+      sendMessage: undefined
+      fetchMessageHistory: undefined
+    }
+
+const ACTIVE_POLL_INTERVAL = 2e3
+const BACKGROUND_POLL_INTERVAL = 10e3
+
+export function isConvoItemMessage(
+  item: ConvoItem,
+): item is ConvoItem & {type: 'message'} {
+  if (!item) return false
+  return (
+    item.type === 'message' ||
+    item.type === 'deleted-message' ||
+    item.type === 'pending-message'
+  )
+}
+
+export class Convo {
+  private agent: BskyAgent
+  private __tempFromUserDid: string
+
+  private pollInterval = ACTIVE_POLL_INTERVAL
+  private status: ConvoStatus = ConvoStatus.Uninitialized
+  private error:
+    | {
+        code: ConvoError
+        exception?: Error
+        retry: () => void
+      }
+    | undefined
+  private historyCursor: string | undefined | null = undefined
+  private isFetchingHistory = false
+  private eventsCursor: string | undefined = undefined
+  private pollingFailure = false
+
+  private pastMessages: Map<
+    string,
+    ChatBskyConvoDefs.MessageView | ChatBskyConvoDefs.DeletedMessageView
+  > = new Map()
+  private newMessages: Map<
+    string,
+    ChatBskyConvoDefs.MessageView | ChatBskyConvoDefs.DeletedMessageView
+  > = new Map()
+  private pendingMessages: Map<
+    string,
+    {id: string; message: ChatBskyConvoSendMessage.InputSchema['message']}
+  > = new Map()
+  private deletedMessages: Set<string> = new Set()
+  private footerItems: Map<string, ConvoItem> = new Map()
+  private headerItems: Map<string, ConvoItem> = new Map()
+
+  private pendingEventIngestion: Promise<void> | undefined
+  private isProcessingPendingMessages = false
+
+  convoId: string
+  convo: ChatBskyConvoDefs.ConvoView | undefined
+  sender: AppBskyActorDefs.ProfileViewBasic | undefined
+  recipients: AppBskyActorDefs.ProfileViewBasic[] | undefined = undefined
+  snapshot: ConvoState | undefined
+
+  constructor(params: ConvoParams) {
+    this.convoId = params.convoId
+    this.agent = params.agent
+    this.__tempFromUserDid = params.__tempFromUserDid
+
+    this.subscribe = this.subscribe.bind(this)
+    this.getSnapshot = this.getSnapshot.bind(this)
+    this.sendMessage = this.sendMessage.bind(this)
+    this.deleteMessage = this.deleteMessage.bind(this)
+    this.fetchMessageHistory = this.fetchMessageHistory.bind(this)
+  }
+
+  private commit() {
+    this.snapshot = undefined
+    this.subscribers.forEach(subscriber => subscriber())
+  }
+
+  private subscribers: (() => void)[] = []
+
+  subscribe(subscriber: () => void) {
+    if (this.subscribers.length === 0) this.init()
+
+    this.subscribers.push(subscriber)
+
+    return () => {
+      this.subscribers = this.subscribers.filter(s => s !== subscriber)
+      if (this.subscribers.length === 0) this.suspend()
+    }
+  }
+
+  getSnapshot(): ConvoState {
+    if (!this.snapshot) this.snapshot = this.generateSnapshot()
+    // logger.debug('Convo: snapshotted', {}, logger.DebugContext.convo)
+    return this.snapshot
+  }
+
+  private generateSnapshot(): ConvoState {
+    switch (this.status) {
+      case ConvoStatus.Initializing: {
+        return {
+          status: ConvoStatus.Initializing,
+          items: [],
+          convo: undefined,
+          error: undefined,
+          sender: undefined,
+          recipients: undefined,
+          isFetchingHistory: this.isFetchingHistory,
+          deleteMessage: undefined,
+          sendMessage: undefined,
+          fetchMessageHistory: undefined,
+        }
+      }
+      case ConvoStatus.Suspended:
+      case ConvoStatus.Backgrounded:
+      case ConvoStatus.Resuming:
+      case ConvoStatus.Ready: {
+        return {
+          status: this.status,
+          items: this.getItems(),
+          convo: this.convo!,
+          error: undefined,
+          sender: this.sender!,
+          recipients: this.recipients!,
+          isFetchingHistory: this.isFetchingHistory,
+          deleteMessage: this.deleteMessage,
+          sendMessage: this.sendMessage,
+          fetchMessageHistory: this.fetchMessageHistory,
+        }
+      }
+      case ConvoStatus.Error: {
+        return {
+          status: ConvoStatus.Error,
+          items: [],
+          convo: undefined,
+          error: this.error,
+          sender: undefined,
+          recipients: undefined,
+          isFetchingHistory: false,
+          deleteMessage: undefined,
+          sendMessage: undefined,
+          fetchMessageHistory: undefined,
+        }
+      }
+      default: {
+        return {
+          status: ConvoStatus.Uninitialized,
+          items: [],
+          convo: undefined,
+          error: undefined,
+          sender: undefined,
+          recipients: undefined,
+          isFetchingHistory: false,
+          deleteMessage: undefined,
+          sendMessage: undefined,
+          fetchMessageHistory: undefined,
+        }
+      }
+    }
+  }
+
+  async init() {
+    logger.debug('Convo: init', {}, logger.DebugContext.convo)
+
+    if (
+      this.status === ConvoStatus.Uninitialized ||
+      this.status === ConvoStatus.Error
+    ) {
+      try {
+        this.status = ConvoStatus.Initializing
+        this.commit()
+
+        await this.refreshConvo()
+        this.status = ConvoStatus.Ready
+        this.commit()
+
+        await this.fetchMessageHistory()
+
+        this.pollEvents()
+      } catch (e: any) {
+        logger.error('Convo: failed to init')
+        this.error = {
+          exception: e,
+          code: ConvoError.InitFailed,
+          retry: () => {
+            this.error = undefined
+            this.init()
+          },
+        }
+        this.status = ConvoStatus.Error
+        this.commit()
+      }
+    } else {
+      logger.warn(`Convo: cannot init from ${this.status}`)
+    }
+  }
+
+  async resume() {
+    logger.debug('Convo: resume', {}, logger.DebugContext.convo)
+
+    if (
+      this.status === ConvoStatus.Suspended ||
+      this.status === ConvoStatus.Backgrounded
+    ) {
+      const fromStatus = this.status
+
+      try {
+        this.status = ConvoStatus.Resuming
+        this.commit()
+
+        await this.refreshConvo()
+        this.status = ConvoStatus.Ready
+        this.commit()
+
+        // throw new Error('UNCOMMENT TO TEST RESUME FAILURE')
+
+        this.pollInterval = ACTIVE_POLL_INTERVAL
+        this.pollEvents()
+      } catch (e) {
+        logger.error('Convo: failed to resume')
+
+        this.footerItems.set(ConvoItemError.ResumeFailed, {
+          type: 'error-recoverable',
+          key: ConvoItemError.ResumeFailed,
+          code: ConvoItemError.ResumeFailed,
+          retry: () => {
+            this.footerItems.delete(ConvoItemError.ResumeFailed)
+            this.resume()
+          },
+        })
+
+        this.status = fromStatus
+        this.commit()
+      }
+    } else {
+      logger.warn(`Convo: cannot resume from ${this.status}`)
+    }
+  }
+
+  async background() {
+    logger.debug('Convo: backgrounded', {}, logger.DebugContext.convo)
+    this.status = ConvoStatus.Backgrounded
+    this.pollInterval = BACKGROUND_POLL_INTERVAL
+    this.commit()
+  }
+
+  async suspend() {
+    logger.debug('Convo: suspended', {}, logger.DebugContext.convo)
+    this.status = ConvoStatus.Suspended
+    this.commit()
+  }
+
+  async refreshConvo() {
+    const response = await this.agent.api.chat.bsky.convo.getConvo(
+      {
+        convoId: this.convoId,
+      },
+      {
+        headers: {
+          Authorization: this.__tempFromUserDid,
+        },
+      },
+    )
+    this.convo = response.data.convo
+    this.sender = this.convo.members.find(m => m.did === this.__tempFromUserDid)
+    this.recipients = this.convo.members.filter(
+      m => m.did !== this.__tempFromUserDid,
+    )
+
+    /*
+     * Prevent invalid states
+     */
+    if (!this.sender) {
+      throw new Error('Convo: could not find sender in convo')
+    }
+    if (!this.recipients) {
+      throw new Error('Convo: could not find recipients in convo')
+    }
+  }
+
+  async fetchMessageHistory() {
+    logger.debug('Convo: fetch message history', {}, logger.DebugContext.convo)
+
+    /*
+     * If historyCursor is null, we've fetched all history.
+     */
+    if (this.historyCursor === null) return
+
+    /*
+     * Don't fetch again if a fetch is already in progress
+     */
+    if (this.isFetchingHistory) return
+
+    /*
+     * If we've rendered a retry state for history fetching, exit. Upon retry,
+     * this will be removed and we'll try again.
+     */
+    if (this.headerItems.has(ConvoItemError.HistoryFailed)) return
+
+    try {
+      this.isFetchingHistory = true
+      this.commit()
+
+      /*
+       * Delay if paginating while scrolled to prevent momentum scrolling from
+       * jerking the list around, plus makes it feel a little more human.
+       */
+      if (this.pastMessages.size > 0) {
+        await new Promise(y => setTimeout(y, 500))
+        // throw new Error('UNCOMMENT TO TEST RETRY')
+      }
+
+      const response = await this.agent.api.chat.bsky.convo.getMessages(
+        {
+          cursor: this.historyCursor,
+          convoId: this.convoId,
+          limit: isNative ? 25 : 50,
+        },
+        {
+          headers: {
+            Authorization: this.__tempFromUserDid,
+          },
+        },
+      )
+      const {cursor, messages} = response.data
+
+      this.historyCursor = cursor ?? null
+
+      for (const message of messages) {
+        if (
+          ChatBskyConvoDefs.isMessageView(message) ||
+          ChatBskyConvoDefs.isDeletedMessageView(message)
+        ) {
+          this.pastMessages.set(message.id, message)
+
+          // set to latest rev
+          if (
+            message.rev > (this.eventsCursor = this.eventsCursor || message.rev)
+          ) {
+            this.eventsCursor = message.rev
+          }
+        }
+      }
+    } catch (e: any) {
+      logger.error('Convo: failed to fetch message history')
+
+      this.headerItems.set(ConvoItemError.HistoryFailed, {
+        type: 'error-recoverable',
+        key: ConvoItemError.HistoryFailed,
+        code: ConvoItemError.HistoryFailed,
+        retry: () => {
+          this.headerItems.delete(ConvoItemError.HistoryFailed)
+          this.fetchMessageHistory()
+        },
+      })
+    } finally {
+      this.isFetchingHistory = false
+      this.commit()
+    }
+  }
+
+  private async pollEvents() {
+    if (
+      this.status === ConvoStatus.Ready ||
+      this.status === ConvoStatus.Backgrounded
+    ) {
+      if (this.pendingEventIngestion) return
+
+      /*
+       * Represents a failed state, which is retryable.
+       */
+      if (this.pollingFailure) return
+
+      setTimeout(async () => {
+        this.pendingEventIngestion = this.ingestLatestEvents()
+        await this.pendingEventIngestion
+        this.pendingEventIngestion = undefined
+        this.pollEvents()
+      }, this.pollInterval)
+    }
+  }
+
+  async ingestLatestEvents() {
+    try {
+      // throw new Error('UNCOMMENT TO TEST POLL FAILURE')
+      const response = await this.agent.api.chat.bsky.convo.getLog(
+        {
+          cursor: this.eventsCursor,
+        },
+        {
+          headers: {
+            Authorization: this.__tempFromUserDid,
+          },
+        },
+      )
+      const {logs} = response.data
+
+      let needsCommit = false
+
+      for (const log of logs) {
+        /*
+         * If there's a rev, we should handle it. If there's not a rev, we don't
+         * know what it is.
+         */
+        if (typeof log.rev === 'string') {
+          /*
+           * We only care about new events
+           */
+          if (log.rev > (this.eventsCursor = this.eventsCursor || log.rev)) {
+            /*
+             * Update rev regardless of if it's a log type we care about or not
+             */
+            this.eventsCursor = log.rev
+
+            /*
+             * This is VERY important. We don't want to insert any messages from
+             * your other chats.
+             */
+            if (log.convoId !== this.convoId) continue
+
+            if (
+              ChatBskyConvoDefs.isLogCreateMessage(log) &&
+              ChatBskyConvoDefs.isMessageView(log.message)
+            ) {
+              if (this.newMessages.has(log.message.id)) {
+                // Trust the log as the source of truth on ordering
+                this.newMessages.delete(log.message.id)
+              }
+              this.newMessages.set(log.message.id, log.message)
+              needsCommit = true
+            } else if (
+              ChatBskyConvoDefs.isLogDeleteMessage(log) &&
+              ChatBskyConvoDefs.isDeletedMessageView(log.message)
+            ) {
+              /*
+               * Update if we have this in state. If we don't, don't worry about it.
+               */
+              if (this.pastMessages.has(log.message.id)) {
+                /*
+                 * For now, we remove deleted messages from the thread, if we receive one.
+                 *
+                 * To support them, it'd look something like this:
+                 *   this.pastMessages.set(log.message.id, log.message)
+                 */
+                this.pastMessages.delete(log.message.id)
+                this.newMessages.delete(log.message.id)
+                this.deletedMessages.delete(log.message.id)
+                needsCommit = true
+              }
+            }
+          }
+        }
+      }
+
+      if (needsCommit) {
+        this.commit()
+      }
+    } catch (e: any) {
+      logger.error('Convo: failed to poll events')
+      this.pollingFailure = true
+      this.footerItems.set(ConvoItemError.PollFailed, {
+        type: 'error-recoverable',
+        key: ConvoItemError.PollFailed,
+        code: ConvoItemError.PollFailed,
+        retry: () => {
+          this.footerItems.delete(ConvoItemError.PollFailed)
+          this.pollingFailure = false
+          this.commit()
+          this.pollEvents()
+        },
+      })
+      this.commit()
+    }
+  }
+
+  async sendMessage(message: ChatBskyConvoSendMessage.InputSchema['message']) {
+    // Ignore empty messages for now since they have no other purpose atm
+    if (!message.text.trim()) return
+
+    logger.debug('Convo: send message', {}, logger.DebugContext.convo)
+
+    const tempId = nanoid()
+
+    this.pendingMessages.set(tempId, {
+      id: tempId,
+      message,
+    })
+    this.commit()
+
+    if (!this.isProcessingPendingMessages) {
+      this.processPendingMessages()
+    }
+  }
+
+  async processPendingMessages() {
+    logger.debug(
+      `Convo: processing messages (${this.pendingMessages.size} remaining)`,
+      {},
+      logger.DebugContext.convo,
+    )
+
+    const pendingMessage = Array.from(this.pendingMessages.values()).shift()
+
+    /*
+     * If there are no pending messages, we're done.
+     */
+    if (!pendingMessage) {
+      this.isProcessingPendingMessages = false
+      return
+    }
+
+    try {
+      this.isProcessingPendingMessages = true
+
+      // throw new Error('UNCOMMENT TO TEST RETRY')
+      const {id, message} = pendingMessage
+
+      const response = await this.agent.api.chat.bsky.convo.sendMessage(
+        {
+          convoId: this.convoId,
+          message,
+        },
+        {
+          encoding: 'application/json',
+          headers: {
+            Authorization: this.__tempFromUserDid,
+          },
+        },
+      )
+      const res = response.data
+
+      /*
+       * Insert into `newMessages` as soon as we have a real ID. That way, when
+       * we get an event log back, we can replace in situ.
+       */
+      this.newMessages.set(res.id, {
+        ...res,
+        $type: 'chat.bsky.convo.defs#messageView',
+        sender: this.sender,
+      })
+      this.pendingMessages.delete(id)
+
+      await this.processPendingMessages()
+
+      this.commit()
+    } catch (e) {
+      this.footerItems.set('pending-retry', {
+        type: 'pending-retry',
+        key: 'pending-retry',
+        retry: this.batchRetryPendingMessages.bind(this),
+      })
+      this.commit()
+    }
+  }
+
+  async batchRetryPendingMessages() {
+    logger.debug(
+      `Convo: retrying ${this.pendingMessages.size} pending messages`,
+      {},
+      logger.DebugContext.convo,
+    )
+
+    this.footerItems.delete('pending-retry')
+    this.commit()
+
+    try {
+      const messageArray = Array.from(this.pendingMessages.values())
+      const {data} = await this.agent.api.chat.bsky.convo.sendMessageBatch(
+        {
+          items: messageArray.map(({message}) => ({
+            convoId: this.convoId,
+            message,
+          })),
+        },
+        {
+          encoding: 'application/json',
+          headers: {
+            Authorization: this.__tempFromUserDid,
+          },
+        },
+      )
+      const {items} = data
+
+      /*
+       * Insert into `newMessages` as soon as we have a real ID. That way, when
+       * we get an event log back, we can replace in situ.
+       */
+      for (const item of items) {
+        this.newMessages.set(item.id, {
+          ...item,
+          $type: 'chat.bsky.convo.defs#messageView',
+          sender: this.convo?.members.find(
+            m => m.did === this.__tempFromUserDid,
+          ),
+        })
+      }
+
+      for (const pendingMessage of messageArray) {
+        this.pendingMessages.delete(pendingMessage.id)
+      }
+
+      this.commit()
+    } catch (e) {
+      this.footerItems.set('pending-retry', {
+        type: 'pending-retry',
+        key: 'pending-retry',
+        retry: this.batchRetryPendingMessages.bind(this),
+      })
+      this.commit()
+    }
+  }
+
+  async deleteMessage(messageId: string) {
+    logger.debug('Convo: delete message', {}, logger.DebugContext.convo)
+
+    this.deletedMessages.add(messageId)
+    this.commit()
+
+    try {
+      await this.agent.api.chat.bsky.convo.deleteMessageForSelf(
+        {
+          convoId: this.convoId,
+          messageId,
+        },
+        {
+          encoding: 'application/json',
+          headers: {
+            Authorization: this.__tempFromUserDid,
+          },
+        },
+      )
+    } catch (e) {
+      this.deletedMessages.delete(messageId)
+      this.commit()
+      throw e
+    }
+  }
+
+  /*
+   * Items in reverse order, since FlatList inverts
+   */
+  getItems(): ConvoItem[] {
+    const items: ConvoItem[] = []
+
+    this.headerItems.forEach(item => {
+      items.push(item)
+    })
+
+    this.pastMessages.forEach(m => {
+      if (ChatBskyConvoDefs.isMessageView(m)) {
+        items.unshift({
+          type: 'message',
+          key: m.id,
+          message: m,
+          nextMessage: null,
+        })
+      } else if (ChatBskyConvoDefs.isDeletedMessageView(m)) {
+        items.unshift({
+          type: 'deleted-message',
+          key: m.id,
+          message: m,
+          nextMessage: null,
+        })
+      }
+    })
+
+    this.newMessages.forEach(m => {
+      if (ChatBskyConvoDefs.isMessageView(m)) {
+        items.push({
+          type: 'message',
+          key: m.id,
+          message: m,
+          nextMessage: null,
+        })
+      } else if (ChatBskyConvoDefs.isDeletedMessageView(m)) {
+        items.push({
+          type: 'deleted-message',
+          key: m.id,
+          message: m,
+          nextMessage: null,
+        })
+      }
+    })
+
+    this.pendingMessages.forEach(m => {
+      items.push({
+        type: 'pending-message',
+        key: m.id,
+        message: {
+          ...m.message,
+          id: nanoid(),
+          rev: '__fake__',
+          sentAt: new Date().toISOString(),
+          sender: this.sender,
+        },
+        nextMessage: null,
+      })
+    })
+
+    this.footerItems.forEach(item => {
+      items.push(item)
+    })
+
+    return items
+      .filter(item => {
+        if (isConvoItemMessage(item)) {
+          return !this.deletedMessages.has(item.message.id)
+        }
+        return true
+      })
+      .map((item, i, arr) => {
+        let nextMessage = null
+        const isMessage = isConvoItemMessage(item)
+
+        if (isMessage) {
+          if (
+            isMessage &&
+            (ChatBskyConvoDefs.isMessageView(item.message) ||
+              ChatBskyConvoDefs.isDeletedMessageView(item.message))
+          ) {
+            const next = arr[i + 1]
+
+            if (
+              isConvoItemMessage(next) &&
+              next &&
+              (ChatBskyConvoDefs.isMessageView(next.message) ||
+                ChatBskyConvoDefs.isDeletedMessageView(next.message))
+            ) {
+              nextMessage = next.message
+            }
+          }
+
+          return {
+            ...item,
+            nextMessage,
+          }
+        }
+
+        return item
+      })
+  }
+}
diff --git a/src/state/messages/index.tsx b/src/state/messages/index.tsx
new file mode 100644
index 000000000..22c4242e2
--- /dev/null
+++ b/src/state/messages/index.tsx
@@ -0,0 +1,48 @@
+import React, {useContext, useState, useSyncExternalStore} from 'react'
+import {BskyAgent} from '@atproto-labs/api'
+import {useFocusEffect} from '@react-navigation/native'
+
+import {Convo, ConvoParams, ConvoState} from '#/state/messages/convo'
+import {useAgent} from '#/state/session'
+import {useDmServiceUrlStorage} from '#/screens/Messages/Temp/useDmServiceUrlStorage'
+
+const ChatContext = React.createContext<ConvoState | null>(null)
+
+export function useChat() {
+  const ctx = useContext(ChatContext)
+  if (!ctx) {
+    throw new Error('useChat must be used within a ChatProvider')
+  }
+  return ctx
+}
+
+export function ChatProvider({
+  children,
+  convoId,
+}: Pick<ConvoParams, 'convoId'> & {children: React.ReactNode}) {
+  const {serviceUrl} = useDmServiceUrlStorage()
+  const {getAgent} = useAgent()
+  const [convo] = useState(
+    () =>
+      new Convo({
+        convoId,
+        agent: new BskyAgent({
+          service: serviceUrl,
+        }),
+        __tempFromUserDid: getAgent().session?.did!,
+      }),
+  )
+  const service = useSyncExternalStore(convo.subscribe, convo.getSnapshot)
+
+  useFocusEffect(
+    React.useCallback(() => {
+      convo.resume()
+
+      return () => {
+        convo.background()
+      }
+    }, [convo]),
+  )
+
+  return <ChatContext.Provider value={service}>{children}</ChatContext.Provider>
+}
diff --git a/src/state/persisted/index.ts b/src/state/persisted/index.ts
index f57172d2f..5fe0f9bd0 100644
--- a/src/state/persisted/index.ts
+++ b/src/state/persisted/index.ts
@@ -1,11 +1,11 @@
 import EventEmitter from 'eventemitter3'
+
+import BroadcastChannel from '#/lib/broadcast'
 import {logger} from '#/logger'
-import {defaults, Schema} from '#/state/persisted/schema'
 import {migrate} from '#/state/persisted/legacy'
+import {defaults, Schema} from '#/state/persisted/schema'
 import * as store from '#/state/persisted/store'
-import BroadcastChannel from '#/lib/broadcast'
-
-export type {Schema, PersistedAccount} from '#/state/persisted/schema'
+export type {PersistedAccount, Schema} from '#/state/persisted/schema'
 export {defaults} from '#/state/persisted/schema'
 
 const broadcast = new BroadcastChannel('BSKY_BROADCAST_CHANNEL')
diff --git a/src/state/persisted/schema.ts b/src/state/persisted/schema.ts
index f090365a3..77a79b78e 100644
--- a/src/state/persisted/schema.ts
+++ b/src/state/persisted/schema.ts
@@ -4,7 +4,10 @@ import {deviceLocales, prefersReducedMotion} from '#/platform/detection'
 
 const externalEmbedOptions = ['show', 'hide'] as const
 
-// only data needed for rendering account page
+/**
+ * A account persisted to storage. Stored in the `accounts[]` array. Contains
+ * base account info and access tokens.
+ */
 const accountSchema = z.object({
   service: z.string(),
   did: z.string(),
@@ -19,12 +22,26 @@ const accountSchema = z.object({
 })
 export type PersistedAccount = z.infer<typeof accountSchema>
 
+/**
+ * The current account. Stored in the `currentAccount` field.
+ *
+ * In previous versions, this included tokens and other info. Now, it's used
+ * only to reference the `did` field, and all other fields are marked as
+ * optional. They should be considered deprecated and not used, but are kept
+ * here for backwards compat.
+ */
+const currentAccountSchema = accountSchema.extend({
+  service: z.string().optional(),
+  handle: z.string().optional(),
+})
+export type PersistedCurrentAccount = z.infer<typeof currentAccountSchema>
+
 export const schema = z.object({
   colorMode: z.enum(['system', 'light', 'dark']),
   darkTheme: z.enum(['dim', 'dark']).optional(),
   session: z.object({
     accounts: z.array(accountSchema),
-    currentAccount: accountSchema.optional(),
+    currentAccount: currentAccountSchema.optional(),
   }),
   reminders: z.object({
     lastEmailConfirm: z.string().optional(),
@@ -63,6 +80,7 @@ export const schema = z.object({
   pdsAddressHistory: z.array(z.string()).optional(),
   disableHaptics: z.boolean().optional(),
   disableAutoplay: z.boolean().optional(),
+  kawaii: z.boolean().optional(),
 })
 export type Schema = z.infer<typeof schema>
 
@@ -100,4 +118,5 @@ export const defaults: Schema = {
   pdsAddressHistory: [],
   disableHaptics: false,
   disableAutoplay: prefersReducedMotion,
+  kawaii: false,
 }
diff --git a/src/state/preferences/index.tsx b/src/state/preferences/index.tsx
index 5c8fab2ad..5bca35452 100644
--- a/src/state/preferences/index.tsx
+++ b/src/state/preferences/index.tsx
@@ -1,11 +1,13 @@
 import React from 'react'
 
+import {DmServiceUrlProvider} from '#/screens/Messages/Temp/useDmServiceUrlStorage'
 import {Provider as AltTextRequiredProvider} from './alt-text-required'
 import {Provider as AutoplayProvider} from './autoplay'
 import {Provider as DisableHapticsProvider} from './disable-haptics'
 import {Provider as ExternalEmbedsProvider} from './external-embeds-prefs'
 import {Provider as HiddenPostsProvider} from './hidden-posts'
 import {Provider as InAppBrowserProvider} from './in-app-browser'
+import {Provider as KawaiiProvider} from './kawaii'
 import {Provider as LanguagesProvider} from './languages'
 
 export {
@@ -30,7 +32,11 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
           <HiddenPostsProvider>
             <InAppBrowserProvider>
               <DisableHapticsProvider>
-                <AutoplayProvider>{children}</AutoplayProvider>
+                <AutoplayProvider>
+                  <DmServiceUrlProvider>
+                    <KawaiiProvider>{children}</KawaiiProvider>
+                  </DmServiceUrlProvider>
+                </AutoplayProvider>
               </DisableHapticsProvider>
             </InAppBrowserProvider>
           </HiddenPostsProvider>
diff --git a/src/state/preferences/kawaii.tsx b/src/state/preferences/kawaii.tsx
new file mode 100644
index 000000000..4aa95ef8b
--- /dev/null
+++ b/src/state/preferences/kawaii.tsx
@@ -0,0 +1,50 @@
+import React from 'react'
+
+import {isWeb} from '#/platform/detection'
+import * as persisted from '#/state/persisted'
+
+type StateContext = persisted.Schema['kawaii']
+
+const stateContext = React.createContext<StateContext>(
+  persisted.defaults.kawaii,
+)
+
+export function Provider({children}: React.PropsWithChildren<{}>) {
+  const [state, setState] = React.useState(persisted.get('kawaii'))
+
+  const setStateWrapped = React.useCallback(
+    (kawaii: persisted.Schema['kawaii']) => {
+      setState(kawaii)
+      persisted.write('kawaii', kawaii)
+    },
+    [setState],
+  )
+
+  React.useEffect(() => {
+    return persisted.onUpdate(() => {
+      setState(persisted.get('kawaii'))
+    })
+  }, [setStateWrapped])
+
+  React.useEffect(() => {
+    // dumb and stupid but it's web only so just refresh the page if you want to change it
+
+    if (isWeb) {
+      const kawaii = new URLSearchParams(window.location.search).get('kawaii')
+      switch (kawaii) {
+        case 'true':
+          setStateWrapped(true)
+          break
+        case 'false':
+          setStateWrapped(false)
+          break
+      }
+    }
+  }, [setStateWrapped])
+
+  return <stateContext.Provider value={state}>{children}</stateContext.Provider>
+}
+
+export function useKawaiiMode() {
+  return React.useContext(stateContext)
+}
diff --git a/src/state/preferences/label-defs.tsx b/src/state/preferences/label-defs.tsx
index d60f8ccb8..e24a1144a 100644
--- a/src/state/preferences/label-defs.tsx
+++ b/src/state/preferences/label-defs.tsx
@@ -1,5 +1,6 @@
 import React from 'react'
-import {InterpretedLabelValueDefinition, AppBskyLabelerDefs} from '@atproto/api'
+import {AppBskyLabelerDefs, InterpretedLabelValueDefinition} from '@atproto/api'
+
 import {useLabelDefinitionsQuery} from '../queries/preferences'
 
 interface StateContext {
@@ -13,10 +14,7 @@ const stateContext = React.createContext<StateContext>({
 })
 
 export function Provider({children}: React.PropsWithChildren<{}>) {
-  const {labelDefs, labelers} = useLabelDefinitionsQuery()
-
-  const state = {labelDefs, labelers}
-
+  const state = useLabelDefinitionsQuery()
   return <stateContext.Provider value={state}>{children}</stateContext.Provider>
 }
 
diff --git a/src/state/preferences/moderation-opts.tsx b/src/state/preferences/moderation-opts.tsx
new file mode 100644
index 000000000..b0278d5e8
--- /dev/null
+++ b/src/state/preferences/moderation-opts.tsx
@@ -0,0 +1,61 @@
+import React, {createContext, useContext, useMemo} from 'react'
+import {BSKY_LABELER_DID, ModerationOpts} from '@atproto/api'
+
+import {useHiddenPosts, useLabelDefinitions} from '#/state/preferences'
+import {DEFAULT_LOGGED_OUT_LABEL_PREFERENCES} from '#/state/queries/preferences/moderation'
+import {useSession} from '#/state/session'
+import {usePreferencesQuery} from '../queries/preferences'
+
+export const moderationOptsContext = createContext<ModerationOpts | undefined>(
+  undefined,
+)
+
+// used in the moderation state devtool
+export const moderationOptsOverrideContext = createContext<
+  ModerationOpts | undefined
+>(undefined)
+
+export function useModerationOpts() {
+  return useContext(moderationOptsContext)
+}
+
+export function Provider({children}: React.PropsWithChildren<{}>) {
+  const override = useContext(moderationOptsOverrideContext)
+  const {currentAccount} = useSession()
+  const prefs = usePreferencesQuery()
+  const {labelDefs} = useLabelDefinitions()
+  const hiddenPosts = useHiddenPosts() // TODO move this into pds-stored prefs
+
+  const userDid = currentAccount?.did
+  const moderationPrefs = prefs.data?.moderationPrefs
+  const value = useMemo<ModerationOpts | undefined>(() => {
+    if (override) {
+      return override
+    }
+    if (!moderationPrefs) {
+      return undefined
+    }
+    return {
+      userDid,
+      prefs: {
+        ...moderationPrefs,
+        labelers: moderationPrefs.labelers.length
+          ? moderationPrefs.labelers
+          : [
+              {
+                did: BSKY_LABELER_DID,
+                labels: DEFAULT_LOGGED_OUT_LABEL_PREFERENCES,
+              },
+            ],
+        hiddenPosts: hiddenPosts || [],
+      },
+      labelDefs,
+    }
+  }, [override, userDid, labelDefs, moderationPrefs, hiddenPosts])
+
+  return (
+    <moderationOptsContext.Provider value={value}>
+      {children}
+    </moderationOptsContext.Provider>
+  )
+}
diff --git a/src/state/queries/actor-autocomplete.ts b/src/state/queries/actor-autocomplete.ts
index 98b5aa17e..8708a244b 100644
--- a/src/state/queries/actor-autocomplete.ts
+++ b/src/state/queries/actor-autocomplete.ts
@@ -6,7 +6,8 @@ import {isJustAMute} from '#/lib/moderation'
 import {logger} from '#/logger'
 import {STALE} from '#/state/queries'
 import {useAgent} from '#/state/session'
-import {DEFAULT_LOGGED_OUT_PREFERENCES, useModerationOpts} from './preferences'
+import {useModerationOpts} from '../preferences/moderation-opts'
+import {DEFAULT_LOGGED_OUT_PREFERENCES} from './preferences'
 
 const DEFAULT_MOD_OPTS = {
   userDid: undefined,
@@ -23,7 +24,11 @@ export function useActorAutocompleteQuery(
   const moderationOpts = useModerationOpts()
   const {getAgent} = useAgent()
 
-  prefix = prefix.toLowerCase()
+  prefix = prefix.toLowerCase().trim()
+  if (prefix.endsWith('.')) {
+    // Going from "foo" to "foo." should not clear matches.
+    prefix = prefix.slice(0, -1)
+  }
 
   return useQuery<AppBskyActorDefs.ProfileViewBasic[]>({
     staleTime: STALE.MINUTES.ONE,
diff --git a/src/state/queries/index.ts b/src/state/queries/index.ts
index e30528ca1..0635bf316 100644
--- a/src/state/queries/index.ts
+++ b/src/state/queries/index.ts
@@ -1,11 +1,3 @@
-import {BskyAgent} from '@atproto/api'
-
-import {PUBLIC_BSKY_SERVICE} from '#/lib/constants'
-
-export const PUBLIC_BSKY_AGENT = new BskyAgent({
-  service: PUBLIC_BSKY_SERVICE,
-})
-
 export const STALE = {
   SECONDS: {
     FIFTEEN: 1e3 * 15,
diff --git a/src/state/queries/messages/conversation.ts b/src/state/queries/messages/conversation.ts
new file mode 100644
index 000000000..9456861d2
--- /dev/null
+++ b/src/state/queries/messages/conversation.ts
@@ -0,0 +1,25 @@
+import {BskyAgent} from '@atproto-labs/api'
+import {useQuery} from '@tanstack/react-query'
+
+import {useDmServiceUrlStorage} from '#/screens/Messages/Temp/useDmServiceUrlStorage'
+import {useHeaders} from './temp-headers'
+
+const RQKEY_ROOT = 'convo'
+export const RQKEY = (convoId: string) => [RQKEY_ROOT, convoId]
+
+export function useConvoQuery(convoId: string) {
+  const headers = useHeaders()
+  const {serviceUrl} = useDmServiceUrlStorage()
+
+  return useQuery({
+    queryKey: RQKEY(convoId),
+    queryFn: async () => {
+      const agent = new BskyAgent({service: serviceUrl})
+      const {data} = await agent.api.chat.bsky.convo.getConvo(
+        {convoId},
+        {headers},
+      )
+      return data.convo
+    },
+  })
+}
diff --git a/src/state/queries/messages/get-convo-for-members.ts b/src/state/queries/messages/get-convo-for-members.ts
new file mode 100644
index 000000000..0a657c07e
--- /dev/null
+++ b/src/state/queries/messages/get-convo-for-members.ts
@@ -0,0 +1,39 @@
+import {BskyAgent, ChatBskyConvoGetConvoForMembers} from '@atproto-labs/api'
+import {useMutation, useQueryClient} from '@tanstack/react-query'
+
+import {logger} from '#/logger'
+import {useDmServiceUrlStorage} from '#/screens/Messages/Temp/useDmServiceUrlStorage'
+import {RQKEY as CONVO_KEY} from './conversation'
+import {useHeaders} from './temp-headers'
+
+export function useGetConvoForMembers({
+  onSuccess,
+  onError,
+}: {
+  onSuccess?: (data: ChatBskyConvoGetConvoForMembers.OutputSchema) => void
+  onError?: (error: Error) => void
+}) {
+  const queryClient = useQueryClient()
+  const headers = useHeaders()
+  const {serviceUrl} = useDmServiceUrlStorage()
+
+  return useMutation({
+    mutationFn: async (members: string[]) => {
+      const agent = new BskyAgent({service: serviceUrl})
+      const {data} = await agent.api.chat.bsky.convo.getConvoForMembers(
+        {members: members},
+        {headers},
+      )
+
+      return data
+    },
+    onSuccess: data => {
+      queryClient.setQueryData(CONVO_KEY(data.convo.id), data.convo)
+      onSuccess?.(data)
+    },
+    onError: error => {
+      logger.error(error)
+      onError?.(error)
+    },
+  })
+}
diff --git a/src/state/queries/messages/leave-conversation.ts b/src/state/queries/messages/leave-conversation.ts
new file mode 100644
index 000000000..0dd67fa0b
--- /dev/null
+++ b/src/state/queries/messages/leave-conversation.ts
@@ -0,0 +1,68 @@
+import {
+  BskyAgent,
+  ChatBskyConvoLeaveConvo,
+  ChatBskyConvoListConvos,
+} from '@atproto-labs/api'
+import {useMutation, useQueryClient} from '@tanstack/react-query'
+
+import {logger} from '#/logger'
+import {useDmServiceUrlStorage} from '#/screens/Messages/Temp/useDmServiceUrlStorage'
+import {RQKEY as CONVO_LIST_KEY} from './list-converations'
+import {useHeaders} from './temp-headers'
+
+export function useLeaveConvo(
+  convoId: string,
+  {
+    onSuccess,
+    onError,
+  }: {
+    onSuccess?: (data: ChatBskyConvoLeaveConvo.OutputSchema) => void
+    onError?: (error: Error) => void
+  },
+) {
+  const queryClient = useQueryClient()
+  const headers = useHeaders()
+  const {serviceUrl} = useDmServiceUrlStorage()
+
+  return useMutation({
+    mutationFn: async () => {
+      const agent = new BskyAgent({service: serviceUrl})
+      const {data} = await agent.api.chat.bsky.convo.leaveConvo(
+        {convoId},
+        {headers, encoding: 'application/json'},
+      )
+
+      return data
+    },
+    onMutate: () => {
+      queryClient.setQueryData(
+        CONVO_LIST_KEY,
+        (old?: {
+          pageParams: Array<string | undefined>
+          pages: Array<ChatBskyConvoListConvos.OutputSchema>
+        }) => {
+          console.log('old', old)
+          if (!old) return old
+          return {
+            ...old,
+            pages: old.pages.map(page => {
+              return {
+                ...page,
+                convos: page.convos.filter(convo => convo.id !== convoId),
+              }
+            }),
+          }
+        },
+      )
+    },
+    onSuccess: data => {
+      queryClient.invalidateQueries({queryKey: CONVO_LIST_KEY})
+      onSuccess?.(data)
+    },
+    onError: error => {
+      logger.error(error)
+      queryClient.invalidateQueries({queryKey: CONVO_LIST_KEY})
+      onError?.(error)
+    },
+  })
+}
diff --git a/src/state/queries/messages/list-converations.ts b/src/state/queries/messages/list-converations.ts
new file mode 100644
index 000000000..1e4ecb6d7
--- /dev/null
+++ b/src/state/queries/messages/list-converations.ts
@@ -0,0 +1,29 @@
+import {BskyAgent} from '@atproto-labs/api'
+import {useInfiniteQuery} from '@tanstack/react-query'
+
+import {useDmServiceUrlStorage} from '#/screens/Messages/Temp/useDmServiceUrlStorage'
+import {useHeaders} from './temp-headers'
+
+export const RQKEY = ['convo-list']
+type RQPageParam = string | undefined
+
+export function useListConvos({refetchInterval}: {refetchInterval: number}) {
+  const headers = useHeaders()
+  const {serviceUrl} = useDmServiceUrlStorage()
+
+  return useInfiniteQuery({
+    queryKey: RQKEY,
+    queryFn: async ({pageParam}) => {
+      const agent = new BskyAgent({service: serviceUrl})
+      const {data} = await agent.api.chat.bsky.convo.listConvos(
+        {cursor: pageParam},
+        {headers},
+      )
+
+      return data
+    },
+    initialPageParam: undefined as RQPageParam,
+    getNextPageParam: lastPage => lastPage.cursor,
+    refetchInterval,
+  })
+}
diff --git a/src/state/queries/messages/mute-conversation.ts b/src/state/queries/messages/mute-conversation.ts
new file mode 100644
index 000000000..4840c65ad
--- /dev/null
+++ b/src/state/queries/messages/mute-conversation.ts
@@ -0,0 +1,84 @@
+import {
+  BskyAgent,
+  ChatBskyConvoMuteConvo,
+  ChatBskyConvoUnmuteConvo,
+} from '@atproto-labs/api'
+import {useMutation, useQueryClient} from '@tanstack/react-query'
+
+import {logger} from '#/logger'
+import {useDmServiceUrlStorage} from '#/screens/Messages/Temp/useDmServiceUrlStorage'
+import {RQKEY as CONVO_KEY} from './conversation'
+import {RQKEY as CONVO_LIST_KEY} from './list-converations'
+import {useHeaders} from './temp-headers'
+
+export function useMuteConvo(
+  convoId: string,
+  {
+    onSuccess,
+    onError,
+  }: {
+    onSuccess?: (data: ChatBskyConvoMuteConvo.OutputSchema) => void
+    onError?: (error: Error) => void
+  },
+) {
+  const queryClient = useQueryClient()
+  const headers = useHeaders()
+  const {serviceUrl} = useDmServiceUrlStorage()
+
+  return useMutation({
+    mutationFn: async () => {
+      const agent = new BskyAgent({service: serviceUrl})
+      const {data} = await agent.api.chat.bsky.convo.muteConvo(
+        {convoId},
+        {headers, encoding: 'application/json'},
+      )
+
+      return data
+    },
+    onSuccess: data => {
+      queryClient.invalidateQueries({queryKey: CONVO_LIST_KEY})
+      queryClient.invalidateQueries({queryKey: CONVO_KEY(convoId)})
+      onSuccess?.(data)
+    },
+    onError: error => {
+      logger.error(error)
+      onError?.(error)
+    },
+  })
+}
+
+export function useUnmuteConvo(
+  convoId: string,
+  {
+    onSuccess,
+    onError,
+  }: {
+    onSuccess?: (data: ChatBskyConvoUnmuteConvo.OutputSchema) => void
+    onError?: (error: Error) => void
+  },
+) {
+  const queryClient = useQueryClient()
+  const headers = useHeaders()
+  const {serviceUrl} = useDmServiceUrlStorage()
+
+  return useMutation({
+    mutationFn: async () => {
+      const agent = new BskyAgent({service: serviceUrl})
+      const {data} = await agent.api.chat.bsky.convo.unmuteConvo(
+        {convoId},
+        {headers, encoding: 'application/json'},
+      )
+
+      return data
+    },
+    onSuccess: data => {
+      queryClient.invalidateQueries({queryKey: CONVO_LIST_KEY})
+      queryClient.invalidateQueries({queryKey: CONVO_KEY(convoId)})
+      onSuccess?.(data)
+    },
+    onError: error => {
+      logger.error(error)
+      onError?.(error)
+    },
+  })
+}
diff --git a/src/state/queries/messages/temp-headers.ts b/src/state/queries/messages/temp-headers.ts
new file mode 100644
index 000000000..9e46e8a61
--- /dev/null
+++ b/src/state/queries/messages/temp-headers.ts
@@ -0,0 +1,11 @@
+import {useSession} from '#/state/session'
+
+// toy auth
+export const useHeaders = () => {
+  const {currentAccount} = useSession()
+  return {
+    get Authorization() {
+      return currentAccount!.did
+    },
+  }
+}
diff --git a/src/state/queries/notifications/feed.ts b/src/state/queries/notifications/feed.ts
index 1f2199901..80e5a4c47 100644
--- a/src/state/queries/notifications/feed.ts
+++ b/src/state/queries/notifications/feed.ts
@@ -28,8 +28,8 @@ import {
 
 import {useMutedThreads} from '#/state/muted-threads'
 import {useAgent} from '#/state/session'
+import {useModerationOpts} from '../../preferences/moderation-opts'
 import {STALE} from '..'
-import {useModerationOpts} from '../preferences'
 import {embedViewRecordToPostView, getEmbeddedPost} from '../util'
 import {FeedPage} from './types'
 import {useUnreadNotificationsApi} from './unread'
diff --git a/src/state/queries/notifications/unread.tsx b/src/state/queries/notifications/unread.tsx
index 1c569e2a0..80333b524 100644
--- a/src/state/queries/notifications/unread.tsx
+++ b/src/state/queries/notifications/unread.tsx
@@ -13,7 +13,7 @@ import {logger} from '#/logger'
 import {isNative} from '#/platform/detection'
 import {useMutedThreads} from '#/state/muted-threads'
 import {useAgent, useSession} from '#/state/session'
-import {useModerationOpts} from '../preferences'
+import {useModerationOpts} from '../../preferences/moderation-opts'
 import {truncateAndInvalidate} from '../util'
 import {RQKEY as RQKEY_NOTIFS} from './feed'
 import {CachedFeedPage, FeedPage} from './types'
diff --git a/src/state/queries/post-feed.ts b/src/state/queries/post-feed.ts
index 747dba02e..827f8a2a8 100644
--- a/src/state/queries/post-feed.ts
+++ b/src/state/queries/post-feed.ts
@@ -15,6 +15,7 @@ import {
 } from '@tanstack/react-query'
 
 import {HomeFeedAPI} from '#/lib/api/feed/home'
+import {aggregateUserInterests} from '#/lib/api/feed/utils'
 import {moderatePost_wrapped as moderatePost} from '#/lib/moderatePost_wrapped'
 import {logger} from '#/logger'
 import {STALE} from '#/state/queries'
@@ -31,7 +32,8 @@ 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 {useFeedTuners} from '../preferences/feed-tuners'
-import {useModerationOpts} from './preferences'
+import {useModerationOpts} from '../preferences/moderation-opts'
+import {usePreferencesQuery} from './preferences'
 import {embedViewRecordToPostView, getEmbeddedPost} from './util'
 
 type ActorDid = string
@@ -102,8 +104,11 @@ export function usePostFeedQuery(
 ) {
   const feedTuners = useFeedTuners(feedDesc)
   const moderationOpts = useModerationOpts()
+  const {data: preferences} = usePreferencesQuery()
+  const enabled =
+    opts?.enabled !== false && Boolean(moderationOpts) && Boolean(preferences)
+  const userInterests = aggregateUserInterests(preferences)
   const {getAgent} = useAgent()
-  const enabled = opts?.enabled !== false && Boolean(moderationOpts)
   const lastRun = useRef<{
     data: InfiniteData<FeedPageUnselected>
     args: typeof selectArgs
@@ -141,6 +146,7 @@ export function usePostFeedQuery(
               feedDesc,
               feedParams: params || {},
               feedTuners,
+              userInterests, // Not in the query key because they don't change.
               getAgent,
             }),
             cursor: undefined,
@@ -371,11 +377,13 @@ function createApi({
   feedDesc,
   feedParams,
   feedTuners,
+  userInterests,
   getAgent,
 }: {
   feedDesc: FeedDescriptor
   feedParams: FeedParams
   feedTuners: FeedTunerFn[]
+  userInterests?: string
   getAgent: () => BskyAgent
 }) {
   if (feedDesc === 'home') {
@@ -384,9 +392,10 @@ function createApi({
         getAgent,
         feedParams,
         feedTuners,
+        userInterests,
       })
     } else {
-      return new HomeFeedAPI({getAgent})
+      return new HomeFeedAPI({getAgent, userInterests})
     }
   } else if (feedDesc === 'following') {
     return new FollowingFeedAPI({getAgent})
@@ -401,6 +410,7 @@ function createApi({
     return new CustomFeedAPI({
       getAgent,
       feedParams: {feed},
+      userInterests,
     })
   } else if (feedDesc.startsWith('list')) {
     const [_, list] = feedDesc.split('|')
diff --git a/src/state/queries/preferences/index.ts b/src/state/queries/preferences/index.ts
index 06e47391f..f51eaac2a 100644
--- a/src/state/queries/preferences/index.ts
+++ b/src/state/queries/preferences/index.ts
@@ -1,28 +1,24 @@
-import {createContext, useContext, useMemo} from 'react'
 import {
   AppBskyActorDefs,
-  BSKY_LABELER_DID,
   BskyFeedViewPreference,
   LabelPreference,
-  ModerationOpts,
 } from '@atproto/api'
 import {useMutation, useQuery, useQueryClient} from '@tanstack/react-query'
 
 import {track} from '#/lib/analytics/analytics'
+import {replaceEqualDeep} from '#/lib/functions'
 import {getAge} from '#/lib/strings/time'
-import {useHiddenPosts, useLabelDefinitions} from '#/state/preferences'
 import {STALE} from '#/state/queries'
 import {
   DEFAULT_HOME_FEED_PREFS,
   DEFAULT_LOGGED_OUT_PREFERENCES,
   DEFAULT_THREAD_VIEW_PREFS,
 } from '#/state/queries/preferences/const'
-import {DEFAULT_LOGGED_OUT_LABEL_PREFERENCES} from '#/state/queries/preferences/moderation'
 import {
   ThreadViewPreferences,
   UsePreferencesQueryResponse,
 } from '#/state/queries/preferences/types'
-import {useAgent, useSession} from '#/state/session'
+import {useAgent} from '#/state/session'
 import {saveLabelers} from '#/state/session/agent-config'
 
 export * from '#/state/queries/preferences/const'
@@ -36,7 +32,7 @@ export function usePreferencesQuery() {
   const {getAgent} = useAgent()
   return useQuery({
     staleTime: STALE.SECONDS.FIFTEEN,
-    structuralSharing: true,
+    structuralSharing: replaceEqualDeep,
     refetchOnWindowFocus: true,
     queryKey: preferencesQueryKey,
     queryFn: async () => {
@@ -79,44 +75,6 @@ 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 {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
-    }
-    return {
-      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,
-    }
-  }, [override, currentAccount, labelDefs, prefs.data, hiddenPosts])
-  return opts
-}
-
 export function useClearPreferencesMutation() {
   const queryClient = useQueryClient()
   const {getAgent} = useAgent()
diff --git a/src/state/queries/suggested-follows.ts b/src/state/queries/suggested-follows.ts
index 936912ab3..7740b1977 100644
--- a/src/state/queries/suggested-follows.ts
+++ b/src/state/queries/suggested-follows.ts
@@ -12,9 +12,15 @@ import {
   useQuery,
 } from '@tanstack/react-query'
 
+import {
+  aggregateUserInterests,
+  createBskyTopicsHeader,
+} from '#/lib/api/feed/utils'
+import {getContentLanguages} from '#/state/preferences/languages'
 import {STALE} from '#/state/queries'
-import {useModerationOpts} from '#/state/queries/preferences'
+import {usePreferencesQuery} from '#/state/queries/preferences'
 import {useAgent, useSession} from '#/state/session'
+import {useModerationOpts} from '../preferences/moderation-opts'
 
 const suggestedFollowsQueryKeyRoot = 'suggested-follows'
 const suggestedFollowsQueryKey = [suggestedFollowsQueryKeyRoot]
@@ -29,6 +35,7 @@ export function useSuggestedFollowsQuery() {
   const {currentAccount} = useSession()
   const {getAgent} = useAgent()
   const moderationOpts = useModerationOpts()
+  const {data: preferences} = usePreferencesQuery()
 
   return useInfiniteQuery<
     AppBskyActorGetSuggestions.OutputSchema,
@@ -37,14 +44,23 @@ export function useSuggestedFollowsQuery() {
     QueryKey,
     string | undefined
   >({
-    enabled: !!moderationOpts,
+    enabled: !!moderationOpts && !!preferences,
     staleTime: STALE.HOURS.ONE,
     queryKey: suggestedFollowsQueryKey,
     queryFn: async ({pageParam}) => {
-      const res = await getAgent().app.bsky.actor.getSuggestions({
-        limit: 25,
-        cursor: pageParam,
-      })
+      const contentLangs = getContentLanguages().join(',')
+      const res = await getAgent().app.bsky.actor.getSuggestions(
+        {
+          limit: 25,
+          cursor: pageParam,
+        },
+        {
+          headers: {
+            ...createBskyTopicsHeader(aggregateUserInterests(preferences)),
+            'Accept-Language': contentLangs,
+          },
+        },
+      )
 
       res.data.actors = res.data.actors
         .filter(
diff --git a/src/state/session/index.tsx b/src/state/session/index.tsx
index e45aa031f..276e3b97b 100644
--- a/src/state/session/index.tsx
+++ b/src/state/session/index.tsx
@@ -1,225 +1,199 @@
 import React from 'react'
-import {
-  AtpPersistSessionHandler,
-  BSKY_LABELER_DID,
-  BskyAgent,
-} from '@atproto/api'
-import {jwtDecode} from 'jwt-decode'
+import {AtpSessionData, AtpSessionEvent, BskyAgent} from '@atproto/api'
 
 import {track} from '#/lib/analytics/analytics'
 import {networkRetry} from '#/lib/async/retry'
-import {IS_TEST_USER} from '#/lib/constants'
-import {logEvent, LogEvents, tryFetchGates} from '#/lib/statsig/statsig'
-import {hasProp} from '#/lib/type-guards'
+import {PUBLIC_BSKY_SERVICE} from '#/lib/constants'
+import {logEvent, tryFetchGates} from '#/lib/statsig/statsig'
 import {logger} from '#/logger'
 import {isWeb} from '#/platform/detection'
 import * as persisted from '#/state/persisted'
-import {PUBLIC_BSKY_AGENT} from '#/state/queries'
 import {useCloseAllActiveElements} from '#/state/util'
 import {useGlobalDialogsControlContext} from '#/components/dialogs/Context'
 import {IS_DEV} from '#/env'
 import {emitSessionDropped} from '../events'
-import {readLabelers} from './agent-config'
-
-let __globalAgent: BskyAgent = PUBLIC_BSKY_AGENT
-
-function __getAgent() {
-  return __globalAgent
-}
-
-export function useAgent() {
-  return React.useMemo(() => ({getAgent: __getAgent}), [])
-}
+import {
+  agentToSessionAccount,
+  configureModerationForAccount,
+  configureModerationForGuest,
+  createAgentAndCreateAccount,
+  createAgentAndLogin,
+  isSessionDeactivated,
+  isSessionExpired,
+} from './util'
+
+export type {SessionAccount} from '#/state/session/types'
+import {
+  SessionAccount,
+  SessionApiContext,
+  SessionStateContext,
+} from '#/state/session/types'
 
-export type SessionAccount = persisted.PersistedAccount
+export {isSessionDeactivated}
 
-export type SessionState = {
-  isInitialLoad: boolean
-  isSwitchingAccounts: boolean
-  accounts: SessionAccount[]
-  currentAccount: SessionAccount | undefined
-}
-export type StateContext = SessionState & {
-  hasSession: boolean
-}
-export type ApiContext = {
-  createAccount: (props: {
-    service: string
-    email: string
-    password: string
-    handle: string
-    inviteCode?: string
-    verificationPhone?: string
-    verificationCode?: string
-  }) => Promise<void>
-  login: (
-    props: {
-      service: string
-      identifier: string
-      password: string
-      authFactorToken?: string | undefined
-    },
-    logContext: LogEvents['account:loggedIn']['logContext'],
-  ) => Promise<void>
-  /**
-   * A full logout. Clears the `currentAccount` from session, AND removes
-   * access tokens from all accounts, so that returning as any user will
-   * require a full login.
-   */
-  logout: (
-    logContext: LogEvents['account:loggedOut']['logContext'],
-  ) => Promise<void>
-  /**
-   * A partial logout. Clears the `currentAccount` from session, but DOES NOT
-   * clear access tokens from accounts, allowing the user to return to their
-   * other accounts without logging in.
-   *
-   * Used when adding a new account, deleting an account.
-   */
-  clearCurrentAccount: () => void
-  initSession: (account: SessionAccount) => Promise<void>
-  resumeSession: (account?: SessionAccount) => Promise<void>
-  removeAccount: (account: SessionAccount) => void
-  selectAccount: (
-    account: SessionAccount,
-    logContext: LogEvents['account:loggedIn']['logContext'],
-  ) => Promise<void>
-  updateCurrentAccount: (
-    account: Partial<
-      Pick<
-        SessionAccount,
-        'handle' | 'email' | 'emailConfirmed' | 'emailAuthFactor'
-      >
-    >,
-  ) => void
-}
+const PUBLIC_BSKY_AGENT = new BskyAgent({service: PUBLIC_BSKY_SERVICE})
+configureModerationForGuest()
 
-const StateContext = React.createContext<StateContext>({
-  isInitialLoad: true,
-  isSwitchingAccounts: false,
+const StateContext = React.createContext<SessionStateContext>({
   accounts: [],
   currentAccount: undefined,
   hasSession: false,
 })
 
-const ApiContext = React.createContext<ApiContext>({
+const ApiContext = React.createContext<SessionApiContext>({
   createAccount: async () => {},
   login: async () => {},
   logout: async () => {},
   initSession: async () => {},
-  resumeSession: async () => {},
   removeAccount: () => {},
-  selectAccount: async () => {},
   updateCurrentAccount: () => {},
   clearCurrentAccount: () => {},
 })
 
-function createPersistSessionHandler(
-  agent: BskyAgent,
-  account: SessionAccount,
-  persistSessionCallback: (props: {
-    expired: boolean
-    refreshedAccount: SessionAccount
-  }) => void,
-  {
-    networkErrorCallback,
-  }: {
-    networkErrorCallback?: () => void
-  } = {},
-): AtpPersistSessionHandler {
-  return function persistSession(event, session) {
-    const expired = event === 'expired' || event === 'create-failed'
-
-    if (event === 'network-error') {
-      logger.warn(`session: persistSessionHandler received network-error event`)
-      networkErrorCallback?.()
-      return
-    }
-
-    const refreshedAccount: SessionAccount = {
-      service: account.service,
-      did: session?.did || account.did,
-      handle: session?.handle || account.handle,
-      email: session?.email || account.email,
-      emailConfirmed: session?.emailConfirmed || account.emailConfirmed,
-      deactivated: isSessionDeactivated(session?.accessJwt),
-      pdsUrl: agent.pdsUrl?.toString(),
-
-      /*
-       * Tokens are undefined if the session expires, or if creation fails for
-       * any reason e.g. tokens are invalid, network error, etc.
-       */
-      refreshJwt: session?.refreshJwt,
-      accessJwt: session?.accessJwt,
-    }
+let __globalAgent: BskyAgent = PUBLIC_BSKY_AGENT
 
-    logger.debug(`session: persistSession`, {
-      event,
-      deactivated: refreshedAccount.deactivated,
-    })
+function __getAgent() {
+  return __globalAgent
+}
 
-    if (expired) {
-      logger.warn(`session: expired`)
-      emitSessionDropped()
-    }
+type AgentState = {
+  readonly agent: BskyAgent
+  readonly did: string | undefined
+}
 
-    /*
-     * If the session expired, or it was successfully created/updated, we want
-     * to update/persist the data.
-     *
-     * If the session creation failed, it could be a network error, or it could
-     * be more serious like an invalid token(s). We can't differentiate, so in
-     * order to allow the user to get a fresh token (if they need it), we need
-     * to persist this data and wipe their tokens, effectively logging them
-     * out.
-     */
-    persistSessionCallback({
-      expired,
-      refreshedAccount,
-    })
-  }
+type State = {
+  accounts: SessionStateContext['accounts']
+  currentAgentState: AgentState
+  needsPersist: boolean
 }
 
 export function Provider({children}: React.PropsWithChildren<{}>) {
-  const isDirty = React.useRef(false)
-  const [state, setState] = React.useState<SessionState>({
-    isInitialLoad: true,
-    isSwitchingAccounts: false,
+  const [state, setState] = React.useState<State>(() => ({
     accounts: persisted.get('session').accounts,
-    currentAccount: undefined, // assume logged out to start
-  })
-
-  const setStateAndPersist = React.useCallback(
-    (fn: (prev: SessionState) => SessionState) => {
-      isDirty.current = true
-      setState(fn)
+    currentAgentState: {
+      agent: PUBLIC_BSKY_AGENT,
+      did: undefined, // assume logged out to start
     },
-    [setState],
-  )
+    needsPersist: false,
+  }))
 
-  const upsertAccount = React.useCallback(
-    (account: SessionAccount, expired = false) => {
-      setStateAndPersist(s => {
+  const clearCurrentAccount = React.useCallback(() => {
+    logger.warn(`session: clear current account`)
+    __globalAgent = PUBLIC_BSKY_AGENT
+    configureModerationForGuest()
+    setState(s => ({
+      accounts: s.accounts,
+      currentAgentState: {
+        agent: PUBLIC_BSKY_AGENT,
+        did: undefined,
+      },
+      needsPersist: true,
+    }))
+  }, [setState])
+
+  const onAgentSessionChange = React.useCallback(
+    (
+      agent: BskyAgent,
+      account: SessionAccount,
+      event: AtpSessionEvent,
+      session: AtpSessionData | undefined,
+    ) => {
+      const expired = event === 'expired' || event === 'create-failed'
+
+      if (event === 'network-error') {
+        logger.warn(
+          `session: persistSessionHandler received network-error event`,
+        )
+        logger.warn(`session: clear current account`)
+        __globalAgent = PUBLIC_BSKY_AGENT
+        configureModerationForGuest()
+        setState(s => ({
+          accounts: s.accounts,
+          currentAgentState: {
+            agent: PUBLIC_BSKY_AGENT,
+            did: undefined,
+          },
+          needsPersist: true,
+        }))
+        return
+      }
+
+      // TODO: use agentToSessionAccount for this too.
+      const refreshedAccount: SessionAccount = {
+        service: account.service,
+        did: session?.did ?? account.did,
+        handle: session?.handle ?? account.handle,
+        email: session?.email ?? account.email,
+        emailConfirmed: session?.emailConfirmed ?? account.emailConfirmed,
+        emailAuthFactor: session?.emailAuthFactor ?? account.emailAuthFactor,
+        deactivated: isSessionDeactivated(session?.accessJwt),
+        pdsUrl: agent.pdsUrl?.toString(),
+
+        /*
+         * Tokens are undefined if the session expires, or if creation fails for
+         * any reason e.g. tokens are invalid, network error, etc.
+         */
+        refreshJwt: session?.refreshJwt,
+        accessJwt: session?.accessJwt,
+      }
+
+      logger.debug(`session: persistSession`, {
+        event,
+        deactivated: refreshedAccount.deactivated,
+      })
+
+      if (expired) {
+        logger.warn(`session: expired`)
+        emitSessionDropped()
+        __globalAgent = PUBLIC_BSKY_AGENT
+        configureModerationForGuest()
+        setState(s => ({
+          accounts: s.accounts,
+          currentAgentState: {
+            agent: PUBLIC_BSKY_AGENT,
+            did: undefined,
+          },
+          needsPersist: true,
+        }))
+      }
+
+      /*
+       * If the session expired, or it was successfully created/updated, we want
+       * to update/persist the data.
+       *
+       * If the session creation failed, it could be a network error, or it could
+       * be more serious like an invalid token(s). We can't differentiate, so in
+       * order to allow the user to get a fresh token (if they need it), we need
+       * to persist this data and wipe their tokens, effectively logging them
+       * out.
+       */
+      setState(s => {
+        const existingAccount = s.accounts.find(
+          a => a.did === refreshedAccount.did,
+        )
+        if (
+          !expired &&
+          existingAccount &&
+          refreshedAccount &&
+          JSON.stringify(existingAccount) === JSON.stringify(refreshedAccount)
+        ) {
+          // Fast path without a state update.
+          return s
+        }
         return {
-          ...s,
-          currentAccount: expired ? undefined : account,
-          accounts: [account, ...s.accounts.filter(a => a.did !== account.did)],
+          accounts: [
+            refreshedAccount,
+            ...s.accounts.filter(a => a.did !== refreshedAccount.did),
+          ],
+          currentAgentState: s.currentAgentState,
+          needsPersist: true,
         }
       })
     },
-    [setStateAndPersist],
+    [],
   )
 
-  const clearCurrentAccount = React.useCallback(() => {
-    logger.warn(`session: clear current account`)
-    __globalAgent = PUBLIC_BSKY_AGENT
-    setStateAndPersist(s => ({
-      ...s,
-      currentAccount: undefined,
-    }))
-  }, [setStateAndPersist])
-
-  const createAccount = React.useCallback<ApiContext['createAccount']>(
+  const createAccount = React.useCallback<SessionApiContext['createAccount']>(
     async ({
       service,
       email,
@@ -228,157 +202,109 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
       inviteCode,
       verificationPhone,
       verificationCode,
-    }: any) => {
+    }) => {
       logger.info(`session: creating account`)
       track('Try Create Account')
       logEvent('account:create:begin', {})
-
-      const agent = new BskyAgent({service})
-
-      await agent.createAccount({
-        handle,
-        password,
-        email,
-        inviteCode,
-        verificationPhone,
-        verificationCode,
-      })
-
-      if (!agent.session) {
-        throw new Error(`session: createAccount failed to establish a session`)
-      }
-      const fetchingGates = tryFetchGates(
-        agent.session.did,
-        'prefer-fresh-gates',
+      const {agent, account, fetchingGates} = await createAgentAndCreateAccount(
+        {
+          service,
+          email,
+          password,
+          handle,
+          inviteCode,
+          verificationPhone,
+          verificationCode,
+        },
       )
 
-      const deactivated = isSessionDeactivated(agent.session.accessJwt)
-      if (!deactivated) {
-        /*dont await*/ agent.upsertProfile(_existing => {
-          return {
-            displayName: '',
-
-            // HACKFIX
-            // creating a bunch of identical profile objects is breaking the relay
-            // tossing this unspecced field onto it to reduce the size of the problem
-            // -prf
-            createdAt: new Date().toISOString(),
-          }
-        })
-      }
-
-      const account: SessionAccount = {
-        service: agent.service.toString(),
-        did: agent.session.did,
-        handle: agent.session.handle,
-        email: agent.session.email!, // TODO this is always defined?
-        emailConfirmed: false,
-        refreshJwt: agent.session.refreshJwt,
-        accessJwt: agent.session.accessJwt,
-        deactivated,
-        pdsUrl: agent.pdsUrl?.toString(),
-      }
-
-      await configureModeration(agent, account)
-
-      agent.setPersistSessionHandler(
-        createPersistSessionHandler(
-          agent,
-          account,
-          ({expired, refreshedAccount}) => {
-            upsertAccount(refreshedAccount, expired)
-          },
-          {networkErrorCallback: clearCurrentAccount},
-        ),
-      )
+      agent.setPersistSessionHandler((event, session) => {
+        onAgentSessionChange(agent, account, event, session)
+      })
 
       __globalAgent = agent
       await fetchingGates
-      upsertAccount(account)
+      setState(s => {
+        return {
+          accounts: [account, ...s.accounts.filter(a => a.did !== account.did)],
+          currentAgentState: {
+            did: account.did,
+            agent: agent,
+          },
+          needsPersist: true,
+        }
+      })
 
       logger.debug(`session: created account`, {}, logger.DebugContext.session)
       track('Create Account')
       logEvent('account:create:success', {})
     },
-    [upsertAccount, clearCurrentAccount],
+    [onAgentSessionChange],
   )
 
-  const login = React.useCallback<ApiContext['login']>(
+  const login = React.useCallback<SessionApiContext['login']>(
     async ({service, identifier, password, authFactorToken}, logContext) => {
       logger.debug(`session: login`, {}, logger.DebugContext.session)
+      const {agent, account, fetchingGates} = await createAgentAndLogin({
+        service,
+        identifier,
+        password,
+        authFactorToken,
+      })
 
-      const agent = new BskyAgent({service})
-
-      await agent.login({identifier, password, authFactorToken})
-
-      if (!agent.session) {
-        throw new Error(`session: login failed to establish a session`)
-      }
-      const fetchingGates = tryFetchGates(
-        agent.session.did,
-        'prefer-fresh-gates',
-      )
-
-      const account: SessionAccount = {
-        service: agent.service.toString(),
-        did: agent.session.did,
-        handle: agent.session.handle,
-        email: agent.session.email,
-        emailConfirmed: agent.session.emailConfirmed || false,
-        emailAuthFactor: agent.session.emailAuthFactor,
-        refreshJwt: agent.session.refreshJwt,
-        accessJwt: agent.session.accessJwt,
-        deactivated: isSessionDeactivated(agent.session.accessJwt),
-        pdsUrl: agent.pdsUrl?.toString(),
-      }
-
-      await configureModeration(agent, account)
-
-      agent.setPersistSessionHandler(
-        createPersistSessionHandler(
-          agent,
-          account,
-          ({expired, refreshedAccount}) => {
-            upsertAccount(refreshedAccount, expired)
-          },
-          {networkErrorCallback: clearCurrentAccount},
-        ),
-      )
+      agent.setPersistSessionHandler((event, session) => {
+        onAgentSessionChange(agent, account, event, session)
+      })
 
       __globalAgent = agent
       // @ts-ignore
       if (IS_DEV && isWeb) window.agent = agent
       await fetchingGates
-      upsertAccount(account)
+      setState(s => {
+        return {
+          accounts: [account, ...s.accounts.filter(a => a.did !== account.did)],
+          currentAgentState: {
+            did: account.did,
+            agent: agent,
+          },
+          needsPersist: true,
+        }
+      })
 
       logger.debug(`session: logged in`, {}, logger.DebugContext.session)
 
       track('Sign In', {resumedSession: false})
       logEvent('account:loggedIn', {logContext, withPassword: true})
     },
-    [upsertAccount, clearCurrentAccount],
+    [onAgentSessionChange],
   )
 
-  const logout = React.useCallback<ApiContext['logout']>(
+  const logout = React.useCallback<SessionApiContext['logout']>(
     async logContext => {
       logger.debug(`session: logout`)
-      clearCurrentAccount()
-      setStateAndPersist(s => {
+      logger.warn(`session: clear current account`)
+      __globalAgent = PUBLIC_BSKY_AGENT
+      configureModerationForGuest()
+      setState(s => {
         return {
-          ...s,
           accounts: s.accounts.map(a => ({
             ...a,
             refreshJwt: undefined,
             accessJwt: undefined,
           })),
+          currentAgentState: {
+            did: undefined,
+            agent: PUBLIC_BSKY_AGENT,
+          },
+          needsPersist: true,
         }
       })
       logEvent('account:loggedOut', {logContext})
     },
-    [clearCurrentAccount, setStateAndPersist],
+    [setState],
   )
 
-  const initSession = React.useCallback<ApiContext['initSession']>(
+  const initSession = React.useCallback<SessionApiContext['initSession']>(
     async account => {
       logger.debug(`session: initSession`, {}, logger.DebugContext.session)
       const fetchingGates = tryFetchGates(account.did, 'prefer-low-latency')
@@ -390,55 +316,65 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
         agent.pdsUrl = agent.api.xrpc.uri = new URL(account.pdsUrl)
       }
 
-      agent.setPersistSessionHandler(
-        createPersistSessionHandler(
-          agent,
-          account,
-          ({expired, refreshedAccount}) => {
-            upsertAccount(refreshedAccount, expired)
-          },
-          {networkErrorCallback: clearCurrentAccount},
-        ),
-      )
+      agent.setPersistSessionHandler((event, session) => {
+        onAgentSessionChange(agent, account, event, session)
+      })
 
       // @ts-ignore
       if (IS_DEV && isWeb) window.agent = agent
-      await configureModeration(agent, account)
-
-      let canReusePrevSession = false
-      try {
-        if (account.accessJwt) {
-          const decoded = jwtDecode(account.accessJwt)
-          if (decoded.exp) {
-            const didExpire = Date.now() >= decoded.exp * 1000
-            if (!didExpire) {
-              canReusePrevSession = true
-            }
-          }
-        }
-      } catch (e) {
-        logger.error(`session: could not decode jwt`)
-      }
+      await configureModerationForAccount(agent, account)
+
+      const accountOrSessionDeactivated =
+        isSessionDeactivated(account.accessJwt) || account.deactivated
 
       const prevSession = {
-        accessJwt: account.accessJwt || '',
-        refreshJwt: account.refreshJwt || '',
+        accessJwt: account.accessJwt ?? '',
+        refreshJwt: account.refreshJwt ?? '',
         did: account.did,
         handle: account.handle,
-        deactivated:
-          isSessionDeactivated(account.accessJwt) || account.deactivated,
       }
 
-      if (canReusePrevSession) {
+      if (isSessionExpired(account)) {
+        logger.debug(`session: attempting to resume using previous session`)
+
+        const freshAccount = await resumeSessionWithFreshAccount()
+        __globalAgent = agent
+        await fetchingGates
+        setState(s => {
+          return {
+            accounts: [
+              freshAccount,
+              ...s.accounts.filter(a => a.did !== freshAccount.did),
+            ],
+            currentAgentState: {
+              did: freshAccount.did,
+              agent: agent,
+            },
+            needsPersist: true,
+          }
+        })
+      } else {
         logger.debug(`session: attempting to reuse previous session`)
 
         agent.session = prevSession
 
         __globalAgent = agent
         await fetchingGates
-        upsertAccount(account)
+        setState(s => {
+          return {
+            accounts: [
+              account,
+              ...s.accounts.filter(a => a.did !== account.did),
+            ],
+            currentAgentState: {
+              did: account.did,
+              agent: agent,
+            },
+            needsPersist: true,
+          }
+        })
 
-        if (prevSession.deactivated) {
+        if (accountOrSessionDeactivated) {
           // don't attempt to resume
           // use will be taken to the deactivated screen
           logger.debug(`session: reusing session for deactivated account`)
@@ -447,191 +383,112 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
 
         // Intentionally not awaited to unblock the UI:
         resumeSessionWithFreshAccount()
-          .then(freshAccount => {
-            if (JSON.stringify(account) !== JSON.stringify(freshAccount)) {
-              logger.info(
-                `session: reuse of previous session returned a fresh account, upserting`,
-              )
-              upsertAccount(freshAccount)
-            }
-          })
-          .catch(e => {
-            /*
-             * Note: `agent.persistSession` is also called when this fails, and
-             * we handle that failure via `createPersistSessionHandler`
-             */
-            logger.info(`session: resumeSessionWithFreshAccount failed`, {
-              message: e,
-            })
-
-            __globalAgent = PUBLIC_BSKY_AGENT
-          })
-      } else {
-        logger.debug(`session: attempting to resume using previous session`)
-
-        try {
-          const freshAccount = await resumeSessionWithFreshAccount()
-          __globalAgent = agent
-          await fetchingGates
-          upsertAccount(freshAccount)
-        } catch (e) {
-          /*
-           * Note: `agent.persistSession` is also called when this fails, and
-           * we handle that failure via `createPersistSessionHandler`
-           */
-          logger.info(`session: resumeSessionWithFreshAccount failed`, {
-            message: e,
-          })
-
-          __globalAgent = PUBLIC_BSKY_AGENT
-        }
       }
 
       async function resumeSessionWithFreshAccount(): Promise<SessionAccount> {
         logger.debug(`session: resumeSessionWithFreshAccount`)
 
         await networkRetry(1, () => agent.resumeSession(prevSession))
-
+        const sessionAccount = agentToSessionAccount(agent)
         /*
          * If `agent.resumeSession` fails above, it'll throw. This is just to
          * make TypeScript happy.
          */
-        if (!agent.session) {
+        if (!sessionAccount) {
           throw new Error(`session: initSession failed to establish a session`)
         }
-
-        // ensure changes in handle/email etc are captured on reload
-        return {
-          service: agent.service.toString(),
-          did: agent.session.did,
-          handle: agent.session.handle,
-          email: agent.session.email,
-          emailConfirmed: agent.session.emailConfirmed || false,
-          emailAuthFactor: agent.session.emailAuthFactor || false,
-          refreshJwt: agent.session.refreshJwt,
-          accessJwt: agent.session.accessJwt,
-          deactivated: isSessionDeactivated(agent.session.accessJwt),
-          pdsUrl: agent.pdsUrl?.toString(),
-        }
+        return sessionAccount
       }
     },
-    [upsertAccount, clearCurrentAccount],
+    [onAgentSessionChange],
   )
 
-  const resumeSession = React.useCallback<ApiContext['resumeSession']>(
-    async account => {
-      try {
-        if (account) {
-          await initSession(account)
-        }
-      } catch (e) {
-        logger.error(`session: resumeSession failed`, {message: e})
-      } finally {
-        setState(s => ({
-          ...s,
-          isInitialLoad: false,
-        }))
-      }
-    },
-    [initSession],
-  )
-
-  const removeAccount = React.useCallback<ApiContext['removeAccount']>(
+  const removeAccount = React.useCallback<SessionApiContext['removeAccount']>(
     account => {
-      setStateAndPersist(s => {
+      setState(s => {
         return {
-          ...s,
           accounts: s.accounts.filter(a => a.did !== account.did),
+          currentAgentState: s.currentAgentState,
+          needsPersist: true,
         }
       })
     },
-    [setStateAndPersist],
+    [setState],
   )
 
   const updateCurrentAccount = React.useCallback<
-    ApiContext['updateCurrentAccount']
+    SessionApiContext['updateCurrentAccount']
   >(
     account => {
-      setStateAndPersist(s => {
-        const currentAccount = s.currentAccount
-
+      setState(s => {
+        const currentAccount = s.accounts.find(
+          a => a.did === s.currentAgentState.did,
+        )
         // ignore, should never happen
         if (!currentAccount) return s
 
         const updatedAccount = {
           ...currentAccount,
-          handle: account.handle || currentAccount.handle,
-          email: account.email || currentAccount.email,
+          handle: account.handle ?? currentAccount.handle,
+          email: account.email ?? currentAccount.email,
           emailConfirmed:
-            account.emailConfirmed !== undefined
-              ? account.emailConfirmed
-              : currentAccount.emailConfirmed,
+            account.emailConfirmed ?? currentAccount.emailConfirmed,
           emailAuthFactor:
-            account.emailAuthFactor !== undefined
-              ? account.emailAuthFactor
-              : currentAccount.emailAuthFactor,
+            account.emailAuthFactor ?? currentAccount.emailAuthFactor,
         }
 
         return {
-          ...s,
-          currentAccount: updatedAccount,
           accounts: [
             updatedAccount,
             ...s.accounts.filter(a => a.did !== currentAccount.did),
           ],
+          currentAgentState: s.currentAgentState,
+          needsPersist: true,
         }
       })
     },
-    [setStateAndPersist],
-  )
-
-  const selectAccount = React.useCallback<ApiContext['selectAccount']>(
-    async (account, logContext) => {
-      setState(s => ({...s, isSwitchingAccounts: true}))
-      try {
-        await initSession(account)
-        setState(s => ({...s, isSwitchingAccounts: false}))
-        logEvent('account:loggedIn', {logContext, withPassword: false})
-      } catch (e) {
-        // reset this in case of error
-        setState(s => ({...s, isSwitchingAccounts: false}))
-        // but other listeners need a throw
-        throw e
-      }
-    },
-    [setState, initSession],
+    [setState],
   )
 
   React.useEffect(() => {
-    if (isDirty.current) {
-      isDirty.current = false
+    if (state.needsPersist) {
+      state.needsPersist = false
       persisted.write('session', {
         accounts: state.accounts,
-        currentAccount: state.currentAccount,
+        currentAccount: state.accounts.find(
+          a => a.did === state.currentAgentState.did,
+        ),
       })
     }
   }, [state])
 
   React.useEffect(() => {
     return persisted.onUpdate(() => {
-      const session = persisted.get('session')
+      const persistedSession = persisted.get('session')
 
       logger.debug(`session: persisted onUpdate`, {})
+      setState(s => ({
+        accounts: persistedSession.accounts,
+        currentAgentState: s.currentAgentState,
+        needsPersist: false, // Synced from another tab. Don't persist to avoid cycles.
+      }))
+
+      const selectedAccount = persistedSession.accounts.find(
+        a => a.did === persistedSession.currentAccount?.did,
+      )
 
-      if (session.currentAccount && session.currentAccount.refreshJwt) {
-        if (session.currentAccount?.did !== state.currentAccount?.did) {
+      if (selectedAccount && selectedAccount.refreshJwt) {
+        if (selectedAccount.did !== state.currentAgentState.did) {
           logger.debug(`session: persisted onUpdate, switching accounts`, {
             from: {
-              did: state.currentAccount?.did,
-              handle: state.currentAccount?.handle,
+              did: state.currentAgentState.did,
             },
             to: {
-              did: session.currentAccount.did,
-              handle: session.currentAccount.handle,
+              did: selectedAccount.did,
             },
           })
 
-          initSession(session.currentAccount)
+          initSession(selectedAccount)
         } else {
           logger.debug(`session: persisted onUpdate, updating session`, {})
 
@@ -641,9 +498,10 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
            * already persisted, and we'll get a loop between tabs.
            */
           // @ts-ignore we checked for `refreshJwt` above
-          __globalAgent.session = session.currentAccount
+          __globalAgent.session = selectedAccount
+          // TODO: This needs a setState.
         }
-      } else if (!session.currentAccount && state.currentAccount) {
+      } else if (!selectedAccount && state.currentAgentState.did) {
         logger.debug(
           `session: persisted onUpdate, logging out`,
           {},
@@ -656,21 +514,28 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
          * handled by `persistSession` (which nukes this accounts tokens only),
          * or by a `logout` call  which nukes all accounts tokens)
          */
-        clearCurrentAccount()
+        logger.warn(`session: clear current account`)
+        __globalAgent = PUBLIC_BSKY_AGENT
+        configureModerationForGuest()
+        setState(s => ({
+          accounts: s.accounts,
+          currentAgentState: {
+            did: undefined,
+            agent: PUBLIC_BSKY_AGENT,
+          },
+          needsPersist: false, // Synced from another tab. Don't persist to avoid cycles.
+        }))
       }
-
-      setState(s => ({
-        ...s,
-        accounts: session.accounts,
-        currentAccount: session.currentAccount,
-      }))
     })
-  }, [state, setState, clearCurrentAccount, initSession])
+  }, [state, setState, initSession])
 
   const stateContext = React.useMemo(
     () => ({
-      ...state,
-      hasSession: !!state.currentAccount,
+      accounts: state.accounts,
+      currentAccount: state.accounts.find(
+        a => a.did === state.currentAgentState.did,
+      ),
+      hasSession: !!state.currentAgentState.did,
     }),
     [state],
   )
@@ -681,9 +546,7 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
       login,
       logout,
       initSession,
-      resumeSession,
       removeAccount,
-      selectAccount,
       updateCurrentAccount,
       clearCurrentAccount,
     }),
@@ -692,9 +555,7 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
       login,
       logout,
       initSession,
-      resumeSession,
       removeAccount,
-      selectAccount,
       updateCurrentAccount,
       clearCurrentAccount,
     ],
@@ -707,28 +568,6 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
   )
 }
 
-async function configureModeration(agent: BskyAgent, account: SessionAccount) {
-  if (IS_TEST_USER(account.handle)) {
-    const did = (
-      await agent
-        .resolveHandle({handle: 'mod-authority.test'})
-        .catch(_ => undefined)
-    )?.data.did
-    if (did) {
-      console.warn('USING TEST ENV MODERATION')
-      BskyAgent.configure({appLabelers: [did]})
-    }
-  } else {
-    BskyAgent.configure({appLabelers: [BSKY_LABELER_DID]})
-    const labelerDids = await readLabelers(account.did).catch(_ => {})
-    if (labelerDids) {
-      agent.configureLabelersHeader(
-        labelerDids.filter(did => did !== BSKY_LABELER_DID),
-      )
-    }
-  }
-}
-
 export function useSession() {
   return React.useContext(StateContext)
 }
@@ -755,12 +594,6 @@ export function useRequireAuth() {
   )
 }
 
-export function isSessionDeactivated(accessJwt: string | undefined) {
-  if (accessJwt) {
-    const sessData = jwtDecode(accessJwt)
-    return (
-      hasProp(sessData, 'scope') && sessData.scope === 'com.atproto.deactivated'
-    )
-  }
-  return false
+export function useAgent() {
+  return React.useMemo(() => ({getAgent: __getAgent}), [])
 }
diff --git a/src/state/session/types.ts b/src/state/session/types.ts
new file mode 100644
index 000000000..b3252f777
--- /dev/null
+++ b/src/state/session/types.ts
@@ -0,0 +1,56 @@
+import {LogEvents} from '#/lib/statsig/statsig'
+import {PersistedAccount} from '#/state/persisted'
+
+export type SessionAccount = PersistedAccount
+
+export type SessionStateContext = {
+  accounts: SessionAccount[]
+  currentAccount: SessionAccount | undefined
+  hasSession: boolean
+}
+export type SessionApiContext = {
+  createAccount: (props: {
+    service: string
+    email: string
+    password: string
+    handle: string
+    inviteCode?: string
+    verificationPhone?: string
+    verificationCode?: string
+  }) => Promise<void>
+  login: (
+    props: {
+      service: string
+      identifier: string
+      password: string
+      authFactorToken?: string | undefined
+    },
+    logContext: LogEvents['account:loggedIn']['logContext'],
+  ) => Promise<void>
+  /**
+   * A full logout. Clears the `currentAccount` from session, AND removes
+   * access tokens from all accounts, so that returning as any user will
+   * require a full login.
+   */
+  logout: (
+    logContext: LogEvents['account:loggedOut']['logContext'],
+  ) => Promise<void>
+  /**
+   * A partial logout. Clears the `currentAccount` from session, but DOES NOT
+   * clear access tokens from accounts, allowing the user to return to their
+   * other accounts without logging in.
+   *
+   * Used when adding a new account, deleting an account.
+   */
+  clearCurrentAccount: () => void
+  initSession: (account: SessionAccount) => Promise<void>
+  removeAccount: (account: SessionAccount) => void
+  updateCurrentAccount: (
+    account: Partial<
+      Pick<
+        SessionAccount,
+        'handle' | 'email' | 'emailConfirmed' | 'emailAuthFactor'
+      >
+    >,
+  ) => void
+}
diff --git a/src/state/session/util/index.ts b/src/state/session/util/index.ts
new file mode 100644
index 000000000..e3e246f7b
--- /dev/null
+++ b/src/state/session/util/index.ts
@@ -0,0 +1,177 @@
+import {BSKY_LABELER_DID, BskyAgent} from '@atproto/api'
+import {jwtDecode} from 'jwt-decode'
+
+import {IS_TEST_USER} from '#/lib/constants'
+import {tryFetchGates} from '#/lib/statsig/statsig'
+import {hasProp} from '#/lib/type-guards'
+import {logger} from '#/logger'
+import * as persisted from '#/state/persisted'
+import {readLabelers} from '../agent-config'
+import {SessionAccount, SessionApiContext} from '../types'
+
+export function isSessionDeactivated(accessJwt: string | undefined) {
+  if (accessJwt) {
+    const sessData = jwtDecode(accessJwt)
+    return (
+      hasProp(sessData, 'scope') && sessData.scope === 'com.atproto.deactivated'
+    )
+  }
+  return false
+}
+
+export function readLastActiveAccount() {
+  const {currentAccount, accounts} = persisted.get('session')
+  return accounts.find(a => a.did === currentAccount?.did)
+}
+
+export function agentToSessionAccount(
+  agent: BskyAgent,
+): SessionAccount | undefined {
+  if (!agent.session) return undefined
+
+  return {
+    service: agent.service.toString(),
+    did: agent.session.did,
+    handle: agent.session.handle,
+    email: agent.session.email,
+    emailConfirmed: agent.session.emailConfirmed || false,
+    emailAuthFactor: agent.session.emailAuthFactor || false,
+    refreshJwt: agent.session.refreshJwt,
+    accessJwt: agent.session.accessJwt,
+    deactivated: isSessionDeactivated(agent.session.accessJwt),
+    pdsUrl: agent.pdsUrl?.toString(),
+  }
+}
+
+export function configureModerationForGuest() {
+  switchToBskyAppLabeler()
+}
+
+export async function configureModerationForAccount(
+  agent: BskyAgent,
+  account: SessionAccount,
+) {
+  switchToBskyAppLabeler()
+  if (IS_TEST_USER(account.handle)) {
+    await trySwitchToTestAppLabeler(agent)
+  }
+
+  const labelerDids = await readLabelers(account.did).catch(_ => {})
+  if (labelerDids) {
+    agent.configureLabelersHeader(
+      labelerDids.filter(did => did !== BSKY_LABELER_DID),
+    )
+  } else {
+    // If there are no headers in the storage, we'll not send them on the initial requests.
+    // If we wanted to fix this, we could block on the preferences query here.
+  }
+}
+
+function switchToBskyAppLabeler() {
+  BskyAgent.configure({appLabelers: [BSKY_LABELER_DID]})
+}
+
+async function trySwitchToTestAppLabeler(agent: BskyAgent) {
+  const did = (
+    await agent
+      .resolveHandle({handle: 'mod-authority.test'})
+      .catch(_ => undefined)
+  )?.data.did
+  if (did) {
+    console.warn('USING TEST ENV MODERATION')
+    BskyAgent.configure({appLabelers: [did]})
+  }
+}
+
+export function isSessionExpired(account: SessionAccount) {
+  try {
+    if (account.accessJwt) {
+      const decoded = jwtDecode(account.accessJwt)
+      if (decoded.exp) {
+        const didExpire = Date.now() >= decoded.exp * 1000
+        return didExpire
+      }
+    }
+  } catch (e) {
+    logger.error(`session: could not decode jwt`)
+  }
+  return true
+}
+
+export async function createAgentAndLogin({
+  service,
+  identifier,
+  password,
+  authFactorToken,
+}: {
+  service: string
+  identifier: string
+  password: string
+  authFactorToken?: string
+}) {
+  const agent = new BskyAgent({service})
+  await agent.login({identifier, password, authFactorToken})
+
+  const account = agentToSessionAccount(agent)
+  if (!agent.session || !account) {
+    throw new Error(`session: login failed to establish a session`)
+  }
+
+  const fetchingGates = tryFetchGates(account.did, 'prefer-fresh-gates')
+  await configureModerationForAccount(agent, account)
+
+  return {
+    agent,
+    account,
+    fetchingGates,
+  }
+}
+
+export async function createAgentAndCreateAccount({
+  service,
+  email,
+  password,
+  handle,
+  inviteCode,
+  verificationPhone,
+  verificationCode,
+}: Parameters<SessionApiContext['createAccount']>[0]) {
+  const agent = new BskyAgent({service})
+  await agent.createAccount({
+    email,
+    password,
+    handle,
+    inviteCode,
+    verificationPhone,
+    verificationCode,
+  })
+
+  const account = agentToSessionAccount(agent)!
+  if (!agent.session || !account) {
+    throw new Error(`session: createAccount failed to establish a session`)
+  }
+
+  const fetchingGates = tryFetchGates(account.did, 'prefer-fresh-gates')
+
+  if (!account.deactivated) {
+    /*dont await*/ agent.upsertProfile(_existing => {
+      return {
+        displayName: '',
+
+        // HACKFIX
+        // creating a bunch of identical profile objects is breaking the relay
+        // tossing this unspecced field onto it to reduce the size of the problem
+        // -prf
+        createdAt: new Date().toISOString(),
+      }
+    })
+  }
+
+  await configureModerationForAccount(agent, account)
+
+  return {
+    agent,
+    account,
+    fetchingGates,
+  }
+}
diff --git a/src/state/session/util/readLastActiveAccount.ts b/src/state/session/util/readLastActiveAccount.ts
deleted file mode 100644
index e0768b8a8..000000000
--- a/src/state/session/util/readLastActiveAccount.ts
+++ /dev/null
@@ -1,6 +0,0 @@
-import * as persisted from '#/state/persisted'
-
-export function readLastActiveAccount() {
-  const {currentAccount, accounts} = persisted.get('session')
-  return accounts.find(a => a.did === currentAccount?.did)
-}