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.ts20
-rw-r--r--src/state/messages/convo.ts811
-rw-r--r--src/state/messages/index.tsx36
-rw-r--r--src/state/modals/index.tsx1
-rw-r--r--src/state/queries/actor-autocomplete.ts6
-rw-r--r--src/state/queries/messages/list-converations.ts3
-rw-r--r--src/state/session/index.tsx438
-rw-r--r--src/state/session/types.ts7
8 files changed, 822 insertions, 500 deletions
diff --git a/src/state/messages/__tests__/convo.test.ts b/src/state/messages/__tests__/convo.test.ts
index 03f9218ff..44fe16fef 100644
--- a/src/state/messages/__tests__/convo.test.ts
+++ b/src/state/messages/__tests__/convo.test.ts
@@ -1,15 +1,23 @@
 import {describe, it} from '@jest/globals'
 
 describe(`#/state/messages/convo`, () => {
-  describe(`status states`, () => {
+  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(`destroy`, () => {
-      it.todo(`cannot be interacted with when destroyed`)
-      it.todo(`polling is stopped when destroyed`)
-      it.todo(`events are cleaned up when destroyed`)
-    })
+  describe(`read states`, () => {
+    it.todo(`should mark messages as read as they come in`)
   })
 
   describe(`history fetching`, () => {
diff --git a/src/state/messages/convo.ts b/src/state/messages/convo.ts
index 4bc9913f8..81ab94f43 100644
--- a/src/state/messages/convo.ts
+++ b/src/state/messages/convo.ts
@@ -4,9 +4,9 @@ import {
   ChatBskyConvoDefs,
   ChatBskyConvoSendMessage,
 } from '@atproto-labs/api'
-import {EventEmitter} from 'eventemitter3'
 import {nanoid} from 'nanoid/non-secure'
 
+import {logger} from '#/logger'
 import {isNative} from '#/platform/detection'
 
 export type ConvoParams = {
@@ -18,9 +18,21 @@ export type ConvoParams = {
 export enum ConvoStatus {
   Uninitialized = 'uninitialized',
   Initializing = 'initializing',
+  Resuming = 'resuming',
   Ready = 'ready',
   Error = 'error',
-  Destroyed = 'destroyed',
+  Backgrounded = 'backgrounded',
+  Suspended = 'suspended',
+}
+
+export enum ConvoItemError {
+  HistoryFailed = 'historyFailed',
+  ResumeFailed = 'resumeFailed',
+  PollFailed = 'pollFailed',
+}
+
+export enum ConvoError {
+  InitFailed = 'initFailed',
 }
 
 export type ConvoItem =
@@ -47,28 +59,110 @@ export type ConvoItem =
       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.Error
-      error: any
+      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.Destroyed
+      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'} {
@@ -84,15 +178,19 @@ export class Convo {
   private agent: BskyAgent
   private __tempFromUserDid: string
 
+  private pollInterval = ACTIVE_POLL_INTERVAL
   private status: ConvoStatus = ConvoStatus.Uninitialized
-  private error: any
+  private error:
+    | {
+        code: ConvoError
+        exception?: Error
+        retry: () => void
+      }
+    | undefined
   private historyCursor: string | undefined | null = undefined
   private isFetchingHistory = false
   private eventsCursor: string | undefined = undefined
-
-  convoId: string
-  convo: ChatBskyConvoDefs.ConvoView | undefined
-  sender: AppBskyActorDefs.ProfileViewBasic | undefined
+  private pollingFailure = false
 
   private pastMessages: Map<
     string,
@@ -106,87 +204,214 @@ export class Convo {
     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)
   }
 
-  async initialize() {
-    if (this.status !== 'uninitialized') return
-    this.status = ConvoStatus.Initializing
+  private commit() {
+    this.snapshot = undefined
+    this.subscribers.forEach(subscriber => subscriber())
+  }
 
-    try {
-      const response = await this.agent.api.chat.bsky.convo.getConvo(
-        {
-          convoId: this.convoId,
-        },
-        {
-          headers: {
-            Authorization: this.__tempFromUserDid,
-          },
-        },
-      )
-      const {convo} = response.data
+  private subscribers: (() => void)[] = []
 
-      this.convo = convo
-      this.sender = this.convo.members.find(
-        m => m.did === this.__tempFromUserDid,
-      )
-      this.status = ConvoStatus.Ready
+  subscribe(subscriber: () => void) {
+    if (this.subscribers.length === 0) this.init()
 
-      this.commit()
+    this.subscribers.push(subscriber)
 
-      await this.fetchMessageHistory()
+    return () => {
+      this.subscribers = this.subscribers.filter(s => s !== subscriber)
+      if (this.subscribers.length === 0) this.suspend()
+    }
+  }
 
-      this.pollEvents()
-    } catch (e) {
-      this.status = ConvoStatus.Error
-      this.error = e
+  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,
+        }
+      }
     }
   }
 
-  private async pollEvents() {
-    if (this.status === ConvoStatus.Destroyed) return
-    if (this.pendingEventIngestion) return
-    setTimeout(async () => {
-      this.pendingEventIngestion = this.ingestLatestEvents()
-      await this.pendingEventIngestion
-      this.pendingEventIngestion = undefined
-      this.pollEvents()
-    }, 5e3)
+  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 fetchMessageHistory() {
-    if (this.status === ConvoStatus.Destroyed) return
-    // reached end
-    if (this.historyCursor === null) return
-    if (this.isFetchingHistory) return
+  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.isFetchingHistory = true
+        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()
+  }
 
-    /*
-     * Delay if paginating while scrolled.
-     *
-     * TODO why does the FlatList jump without this delay?
-     *
-     * Tbh it feels a little more natural with a slight delay.
-     */
-    if (this.pastMessages.size > 0) {
-      await new Promise(y => setTimeout(y, 500))
-    }
+  async suspend() {
+    logger.debug('Convo: suspended', {}, logger.DebugContext.convo)
+    this.status = ConvoStatus.Suspended
+    this.commit()
+  }
 
-    const response = await this.agent.api.chat.bsky.convo.getMessages(
+  async refreshConvo() {
+    const response = await this.agent.api.chat.bsky.convo.getConvo(
       {
-        cursor: this.historyCursor,
         convoId: this.convoId,
-        limit: isNative ? 25 : 50,
       },
       {
         headers: {
@@ -194,100 +419,244 @@ export class Convo {
         },
       },
     )
-    const {cursor, messages} = response.data
+    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
 
-    this.historyCursor = cursor || null
+    /*
+     * Don't fetch again if a fetch is already in progress
+     */
+    if (this.isFetchingHistory) return
 
-    for (const message of messages) {
-      if (
-        ChatBskyConvoDefs.isMessageView(message) ||
-        ChatBskyConvoDefs.isDeletedMessageView(message)
-      ) {
-        this.pastMessages.set(message.id, message)
+    /*
+     * 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
 
-        // set to latest rev
+    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 (
-          message.rev > (this.eventsCursor = this.eventsCursor || message.rev)
+          ChatBskyConvoDefs.isMessageView(message) ||
+          ChatBskyConvoDefs.isDeletedMessageView(message)
         ) {
-          this.eventsCursor = message.rev
+          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()
     }
+  }
 
-    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() {
-    if (this.status === ConvoStatus.Destroyed) return
-
-    const response = await this.agent.api.chat.bsky.convo.getLog(
-      {
-        cursor: this.eventsCursor,
-      },
-      {
-        headers: {
-          Authorization: this.__tempFromUserDid,
+    try {
+      // throw new Error('UNCOMMENT TO TEST POLL FAILURE')
+      const response = await this.agent.api.chat.bsky.convo.getLog(
+        {
+          cursor: this.eventsCursor,
         },
-      },
-    )
-    const {logs} = response.data
+        {
+          headers: {
+            Authorization: this.__tempFromUserDid,
+          },
+        },
+      )
+      const {logs} = response.data
 
-    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') {
+      let needsCommit = false
+
+      for (const log of logs) {
         /*
-         * We only care about new events
+         * If there's a rev, we should handle it. If there's not a rev, we don't
+         * know what it is.
          */
-        if (log.rev > (this.eventsCursor = this.eventsCursor || log.rev)) {
+        if (typeof log.rev === 'string') {
           /*
-           * Update rev regardless of if it's a log type we care about or not
+           * We only care about new events
            */
-          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 (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
 
-          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)
-          } 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.
+             * This is VERY important. We don't want to insert any messages from
+             * your other chats.
              */
-            if (this.pastMessages.has(log.message.id)) {
+            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)
+            ) {
               /*
-               * 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)
+               * Update if we have this in state. If we don't, don't worry about it.
                */
-              this.pastMessages.delete(log.message.id)
+              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()
 
     /*
@@ -343,6 +712,12 @@ export class Convo {
   }
 
   async batchRetryPendingMessages() {
+    logger.debug(
+      `Convo: retrying ${this.pendingMessages.size} pending messages`,
+      {},
+      logger.DebugContext.convo,
+    )
+
     this.footerItems.delete('pending-retry')
     this.commit()
 
@@ -393,32 +768,43 @@ export class Convo {
     }
   }
 
-  async sendMessage(message: ChatBskyConvoSendMessage.InputSchema['message']) {
-    if (this.status === ConvoStatus.Destroyed) return
-    // Ignore empty messages for now since they have no other purpose atm
-    if (!message.text.trim()) return
-
-    const tempId = nanoid()
+  async deleteMessage(messageId: string) {
+    logger.debug('Convo: delete message', {}, logger.DebugContext.convo)
 
-    this.pendingMessages.set(tempId, {
-      id: tempId,
-      message,
-    })
+    this.deletedMessages.add(messageId)
     this.commit()
 
-    if (!this.isProcessingPendingMessages) {
-      this.processPendingMessages()
+    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
    */
-  get items(): ConvoItem[] {
+  getItems(): ConvoItem[] {
     const items: ConvoItem[] = []
 
-    // `newMessages` is in insertion order, unshift to reverse
-    this.newMessages.forEach(m => {
+    this.headerItems.forEach(item => {
+      items.push(item)
+    })
+
+    this.pastMessages.forEach(m => {
       if (ChatBskyConvoDefs.isMessageView(m)) {
         items.unshift({
           type: 'message',
@@ -436,27 +822,7 @@ export class Convo {
       }
     })
 
-    // `newMessages` is in insertion order, unshift to reverse
-    this.pendingMessages.forEach(m => {
-      items.unshift({
-        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.unshift(item)
-    })
-
-    this.pastMessages.forEach(m => {
+    this.newMessages.forEach(m => {
       if (ChatBskyConvoDefs.isMessageView(m)) {
         items.push({
           type: 'message',
@@ -474,88 +840,61 @@ export class Convo {
       }
     })
 
-    return items.map((item, i) => {
-      let nextMessage = null
-      const isMessage = isConvoItemMessage(item)
+    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)
+    })
 
-      if (isMessage) {
-        if (
-          isMessage &&
-          (ChatBskyConvoDefs.isMessageView(item.message) ||
-            ChatBskyConvoDefs.isDeletedMessageView(item.message))
-        ) {
-          const next = items[i - 1]
+    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 (
-            isConvoItemMessage(next) &&
-            next &&
-            (ChatBskyConvoDefs.isMessageView(next.message) ||
-              ChatBskyConvoDefs.isDeletedMessageView(next.message))
+            isMessage &&
+            (ChatBskyConvoDefs.isMessageView(item.message) ||
+              ChatBskyConvoDefs.isDeletedMessageView(item.message))
           ) {
-            nextMessage = next.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
-    })
-  }
-
-  destroy() {
-    this.status = ConvoStatus.Destroyed
-    this.commit()
-  }
-
-  get state(): ConvoState {
-    switch (this.status) {
-      case ConvoStatus.Initializing: {
-        return {
-          status: ConvoStatus.Initializing,
-        }
-      }
-      case ConvoStatus.Ready: {
-        return {
-          status: ConvoStatus.Ready,
-          items: this.items,
-          convo: this.convo!,
-          isFetchingHistory: this.isFetchingHistory,
-        }
-      }
-      case ConvoStatus.Error: {
-        return {
-          status: ConvoStatus.Error,
-          error: this.error,
-        }
-      }
-      case ConvoStatus.Destroyed: {
-        return {
-          status: ConvoStatus.Destroyed,
-        }
-      }
-      default: {
-        return {
-          status: ConvoStatus.Uninitialized,
+          return {
+            ...item,
+            nextMessage,
+          }
         }
-      }
-    }
-  }
-
-  private _emitter = new EventEmitter()
 
-  private commit() {
-    this._emitter.emit('update')
-  }
-
-  on(event: 'update', cb: () => void) {
-    this._emitter.on(event, cb)
-  }
-
-  off(event: 'update', cb: () => void) {
-    this._emitter.off(event, cb)
+        return item
+      })
   }
 }
diff --git a/src/state/messages/index.tsx b/src/state/messages/index.tsx
index cdc5a4db2..22c4242e2 100644
--- a/src/state/messages/index.tsx
+++ b/src/state/messages/index.tsx
@@ -1,14 +1,12 @@
-import React, {useContext, useEffect, useMemo, useState} from 'react'
+import React, {useContext, useState, useSyncExternalStore} from 'react'
 import {BskyAgent} from '@atproto-labs/api'
+import {useFocusEffect} from '@react-navigation/native'
 
-import {Convo, ConvoParams} from '#/state/messages/convo'
+import {Convo, ConvoParams, ConvoState} from '#/state/messages/convo'
 import {useAgent} from '#/state/session'
 import {useDmServiceUrlStorage} from '#/screens/Messages/Temp/useDmServiceUrlStorage'
 
-const ChatContext = React.createContext<{
-  service: Convo
-  state: Convo['state']
-} | null>(null)
+const ChatContext = React.createContext<ConvoState | null>(null)
 
 export function useChat() {
   const ctx = useContext(ChatContext)
@@ -24,7 +22,7 @@ export function ChatProvider({
 }: Pick<ConvoParams, 'convoId'> & {children: React.ReactNode}) {
   const {serviceUrl} = useDmServiceUrlStorage()
   const {getAgent} = useAgent()
-  const [service] = useState(
+  const [convo] = useState(
     () =>
       new Convo({
         convoId,
@@ -34,21 +32,17 @@ export function ChatProvider({
         __tempFromUserDid: getAgent().session?.did!,
       }),
   )
-  const [state, setState] = useState(service.state)
+  const service = useSyncExternalStore(convo.subscribe, convo.getSnapshot)
 
-  useEffect(() => {
-    service.initialize()
-  }, [service])
+  useFocusEffect(
+    React.useCallback(() => {
+      convo.resume()
 
-  useEffect(() => {
-    const update = () => setState(service.state)
-    service.on('update', update)
-    return () => {
-      service.destroy()
-    }
-  }, [service])
-
-  const value = useMemo(() => ({service, state}), [service, state])
+      return () => {
+        convo.background()
+      }
+    }, [convo]),
+  )
 
-  return <ChatContext.Provider value={value}>{children}</ChatContext.Provider>
+  return <ChatContext.Provider value={service}>{children}</ChatContext.Provider>
 }
diff --git a/src/state/modals/index.tsx b/src/state/modals/index.tsx
index 0f61a9711..cf82bcd07 100644
--- a/src/state/modals/index.tsx
+++ b/src/state/modals/index.tsx
@@ -47,6 +47,7 @@ export interface EditImageModal {
 export interface CropImageModal {
   name: 'crop-image'
   uri: string
+  dimensions?: {width: number; height: number}
   onSelect: (img?: RNImage) => void
 }
 
diff --git a/src/state/queries/actor-autocomplete.ts b/src/state/queries/actor-autocomplete.ts
index 430f6978a..8708a244b 100644
--- a/src/state/queries/actor-autocomplete.ts
+++ b/src/state/queries/actor-autocomplete.ts
@@ -24,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/messages/list-converations.ts b/src/state/queries/messages/list-converations.ts
index 19f2674bd..1e4ecb6d7 100644
--- a/src/state/queries/messages/list-converations.ts
+++ b/src/state/queries/messages/list-converations.ts
@@ -7,7 +7,7 @@ import {useHeaders} from './temp-headers'
 export const RQKEY = ['convo-list']
 type RQPageParam = string | undefined
 
-export function useListConvos() {
+export function useListConvos({refetchInterval}: {refetchInterval: number}) {
   const headers = useHeaders()
   const {serviceUrl} = useDmServiceUrlStorage()
 
@@ -24,5 +24,6 @@ export function useListConvos() {
     },
     initialPageParam: undefined as RQPageParam,
     getNextPageParam: lastPage => lastPage.cursor,
+    refetchInterval,
   })
 }
diff --git a/src/state/session/index.tsx b/src/state/session/index.tsx
index 582680e97..276e3b97b 100644
--- a/src/state/session/index.tsx
+++ b/src/state/session/index.tsx
@@ -1,5 +1,5 @@
 import React from 'react'
-import {AtpPersistSessionHandler, BskyAgent} from '@atproto/api'
+import {AtpSessionData, AtpSessionEvent, BskyAgent} from '@atproto/api'
 
 import {track} from '#/lib/analytics/analytics'
 import {networkRetry} from '#/lib/async/retry'
@@ -35,8 +35,6 @@ const PUBLIC_BSKY_AGENT = new BskyAgent({service: PUBLIC_BSKY_SERVICE})
 configureModerationForGuest()
 
 const StateContext = React.createContext<SessionStateContext>({
-  isInitialLoad: true,
-  isSwitchingAccounts: false,
   accounts: [],
   currentAccount: undefined,
   hasSession: false,
@@ -47,9 +45,7 @@ const ApiContext = React.createContext<SessionApiContext>({
   login: async () => {},
   logout: async () => {},
   initSession: async () => {},
-  resumeSession: async () => {},
   removeAccount: () => {},
-  selectAccount: async () => {},
   updateCurrentAccount: () => {},
   clearCurrentAccount: () => {},
 })
@@ -60,33 +56,26 @@ function __getAgent() {
   return __globalAgent
 }
 
+type AgentState = {
+  readonly agent: BskyAgent
+  readonly did: string | undefined
+}
+
 type State = {
   accounts: SessionStateContext['accounts']
-  currentAccountDid: string | undefined
+  currentAgentState: AgentState
   needsPersist: boolean
 }
 
 export function Provider({children}: React.PropsWithChildren<{}>) {
-  const [isInitialLoad, setIsInitialLoad] = React.useState(true)
-  const [isSwitchingAccounts, setIsSwitchingAccounts] = React.useState(false)
-  const [state, setState] = React.useState<State>({
+  const [state, setState] = React.useState<State>(() => ({
     accounts: persisted.get('session').accounts,
-    currentAccountDid: undefined, // assume logged out to start
-    needsPersist: false,
-  })
-
-  const upsertAccount = React.useCallback(
-    (account: SessionAccount, expired = false) => {
-      setState(s => {
-        return {
-          accounts: [account, ...s.accounts.filter(a => a.did !== account.did)],
-          currentAccountDid: expired ? undefined : account.did,
-          needsPersist: true,
-        }
-      })
+    currentAgentState: {
+      agent: PUBLIC_BSKY_AGENT,
+      did: undefined, // assume logged out to start
     },
-    [setState],
-  )
+    needsPersist: false,
+  }))
 
   const clearCurrentAccount = React.useCallback(() => {
     logger.warn(`session: clear current account`)
@@ -94,80 +83,112 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
     configureModerationForGuest()
     setState(s => ({
       accounts: s.accounts,
-      currentAccountDid: undefined,
+      currentAgentState: {
+        agent: PUBLIC_BSKY_AGENT,
+        did: undefined,
+      },
       needsPersist: true,
     }))
   }, [setState])
 
-  const createPersistSessionHandler = React.useCallback(
+  const onAgentSessionChange = React.useCallback(
     (
       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
-        }
-
-        // 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,
-        })
+      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
+      }
 
-        if (expired) {
-          logger.warn(`session: expired`)
-          emitSessionDropped()
-        }
+      // 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(),
 
         /*
-         * 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.
+         * Tokens are undefined if the session expires, or if creation fails for
+         * any reason e.g. tokens are invalid, network error, etc.
          */
-        persistSessionCallback({
-          expired,
-          refreshedAccount,
-        })
+        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 {
+          accounts: [
+            refreshedAccount,
+            ...s.accounts.filter(a => a.did !== refreshedAccount.did),
+          ],
+          currentAgentState: s.currentAgentState,
+          needsPersist: true,
+        }
+      })
     },
     [],
   )
@@ -197,26 +218,28 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
         },
       )
 
-      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, createPersistSessionHandler],
+    [onAgentSessionChange],
   )
 
   const login = React.useCallback<SessionApiContext['login']>(
@@ -229,35 +252,39 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
         authFactorToken,
       })
 
-      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, createPersistSessionHandler],
+    [onAgentSessionChange],
   )
 
   const logout = React.useCallback<SessionApiContext['logout']>(
     async logContext => {
       logger.debug(`session: logout`)
-      clearCurrentAccount()
+      logger.warn(`session: clear current account`)
+      __globalAgent = PUBLIC_BSKY_AGENT
+      configureModerationForGuest()
       setState(s => {
         return {
           accounts: s.accounts.map(a => ({
@@ -265,13 +292,16 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
             refreshJwt: undefined,
             accessJwt: undefined,
           })),
-          currentAccountDid: s.currentAccountDid,
+          currentAgentState: {
+            did: undefined,
+            agent: PUBLIC_BSKY_AGENT,
+          },
           needsPersist: true,
         }
       })
       logEvent('account:loggedOut', {logContext})
     },
-    [clearCurrentAccount, setState],
+    [setState],
   )
 
   const initSession = React.useCallback<SessionApiContext['initSession']>(
@@ -286,16 +316,9 @@ 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
@@ -305,8 +328,8 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
         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,
       }
@@ -314,23 +337,22 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
       if (isSessionExpired(account)) {
         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
-          // TODO: Should this update currentAccountDid?
-        }
+        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`)
 
@@ -338,7 +360,19 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
 
         __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 (accountOrSessionDeactivated) {
           // don't attempt to resume
@@ -349,26 +383,6 @@ 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
-            // TODO: Should this update currentAccountDid?
-          })
       }
 
       async function resumeSessionWithFreshAccount(): Promise<SessionAccount> {
@@ -386,22 +400,7 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
         return sessionAccount
       }
     },
-    [upsertAccount, clearCurrentAccount, createPersistSessionHandler],
-  )
-
-  const resumeSession = React.useCallback<SessionApiContext['resumeSession']>(
-    async account => {
-      try {
-        if (account) {
-          await initSession(account)
-        }
-      } catch (e) {
-        logger.error(`session: resumeSession failed`, {message: e})
-      } finally {
-        setIsInitialLoad(false)
-      }
-    },
-    [initSession],
+    [onAgentSessionChange],
   )
 
   const removeAccount = React.useCallback<SessionApiContext['removeAccount']>(
@@ -409,7 +408,7 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
       setState(s => {
         return {
           accounts: s.accounts.filter(a => a.did !== account.did),
-          currentAccountDid: s.currentAccountDid,
+          currentAgentState: s.currentAgentState,
           needsPersist: true,
         }
       })
@@ -423,23 +422,19 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
     account => {
       setState(s => {
         const currentAccount = s.accounts.find(
-          a => a.did === s.currentAccountDid,
+          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 {
@@ -447,7 +442,7 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
             updatedAccount,
             ...s.accounts.filter(a => a.did !== currentAccount.did),
           ],
-          currentAccountDid: s.currentAccountDid,
+          currentAgentState: s.currentAgentState,
           needsPersist: true,
         }
       })
@@ -455,30 +450,13 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
     [setState],
   )
 
-  const selectAccount = React.useCallback<SessionApiContext['selectAccount']>(
-    async (account, logContext) => {
-      setIsSwitchingAccounts(true)
-      try {
-        await initSession(account)
-        setIsSwitchingAccounts(false)
-        logEvent('account:loggedIn', {logContext, withPassword: false})
-      } catch (e) {
-        // reset this in case of error
-        setIsSwitchingAccounts(false)
-        // but other listeners need a throw
-        throw e
-      }
-    },
-    [initSession],
-  )
-
   React.useEffect(() => {
     if (state.needsPersist) {
       state.needsPersist = false
       persisted.write('session', {
         accounts: state.accounts,
         currentAccount: state.accounts.find(
-          a => a.did === state.currentAccountDid,
+          a => a.did === state.currentAgentState.did,
         ),
       })
     }
@@ -489,16 +467,21 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
       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 (selectedAccount && selectedAccount.refreshJwt) {
-        if (selectedAccount.did !== state.currentAccountDid) {
+        if (selectedAccount.did !== state.currentAgentState.did) {
           logger.debug(`session: persisted onUpdate, switching accounts`, {
             from: {
-              did: state.currentAccountDid,
+              did: state.currentAgentState.did,
             },
             to: {
               did: selectedAccount.did,
@@ -516,8 +499,9 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
            */
           // @ts-ignore we checked for `refreshJwt` above
           __globalAgent.session = selectedAccount
+          // TODO: This needs a setState.
         }
-      } else if (!selectedAccount && state.currentAccountDid) {
+      } else if (!selectedAccount && state.currentAgentState.did) {
         logger.debug(
           `session: persisted onUpdate, logging out`,
           {},
@@ -530,28 +514,30 @@ 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(() => ({
-        accounts: persistedSession.accounts,
-        currentAccountDid: selectedAccount?.did,
-        needsPersist: false, // Synced from another tab. Don't persist to avoid cycles.
-      }))
     })
-  }, [state, setState, clearCurrentAccount, initSession])
+  }, [state, setState, initSession])
 
   const stateContext = React.useMemo(
     () => ({
       accounts: state.accounts,
       currentAccount: state.accounts.find(
-        a => a.did === state.currentAccountDid,
+        a => a.did === state.currentAgentState.did,
       ),
-      isInitialLoad,
-      isSwitchingAccounts,
-      hasSession: !!state.currentAccountDid,
+      hasSession: !!state.currentAgentState.did,
     }),
-    [state, isInitialLoad, isSwitchingAccounts],
+    [state],
   )
 
   const api = React.useMemo(
@@ -560,9 +546,7 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
       login,
       logout,
       initSession,
-      resumeSession,
       removeAccount,
-      selectAccount,
       updateCurrentAccount,
       clearCurrentAccount,
     }),
@@ -571,9 +555,7 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
       login,
       logout,
       initSession,
-      resumeSession,
       removeAccount,
-      selectAccount,
       updateCurrentAccount,
       clearCurrentAccount,
     ],
diff --git a/src/state/session/types.ts b/src/state/session/types.ts
index fbfac82e9..b3252f777 100644
--- a/src/state/session/types.ts
+++ b/src/state/session/types.ts
@@ -6,8 +6,6 @@ export type SessionAccount = PersistedAccount
 export type SessionStateContext = {
   accounts: SessionAccount[]
   currentAccount: SessionAccount | undefined
-  isInitialLoad: boolean
-  isSwitchingAccounts: boolean
   hasSession: boolean
 }
 export type SessionApiContext = {
@@ -46,12 +44,7 @@ export type SessionApiContext = {
    */
   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<