about summary refs log tree commit diff
diff options
context:
space:
mode:
authorEric Bailey <git@esb.lol>2024-05-01 12:14:41 -0500
committerGitHub <noreply@github.com>2024-05-01 10:14:41 -0700
commit538ca8dff16e9973a0fd4c310dd89cfe05536f6b (patch)
tree24dc6be1907cdc834d6f3fe1a709025fb4fd43d7
parentd61b366b26dbe8b30ee30e121a5b1aa3edea356b (diff)
downloadvoidsky-538ca8dff16e9973a0fd4c310dd89cfe05536f6b.tar.zst
[Clipclops] 2 Clipped 2 Clopped (#3796)
* Add new pkg

* copy queries over to new file

* useConvoQuery

* useListConvos

* Use useListConvos

* extract useConvoQuery

* useGetConvoForMembers

* Delete unused

* exract useListConvos

* Replace imports

* Messages/List/index.tsx

* extract getconvoformembers

* MessageItem

* delete chatLog and rename query.ts

* Update import

* Clipclop service (#3794)

* Add Chat service

* Better handle deletions

* Rollback unneeded changes

* Better insertion order

* Use clipclops

* don't show FAB if error

* clean up imports

* Update Convo service

* Remove temp files

---------

Co-authored-by: Samuel Newman <mozzius@protonmail.com>
-rw-r--r--package.json1
-rw-r--r--src/components/dms/NewChat.tsx6
-rw-r--r--src/screens/Messages/Conversation/MessageItem.tsx15
-rw-r--r--src/screens/Messages/Conversation/MessagesList.tsx180
-rw-r--r--src/screens/Messages/Conversation/index.tsx8
-rw-r--r--src/screens/Messages/List/index.tsx49
-rw-r--r--src/screens/Messages/Temp/query/query.ts305
-rw-r--r--src/state/messages/__tests__/client.test.ts38
-rw-r--r--src/state/messages/convo.ts442
-rw-r--r--src/state/messages/index.tsx57
-rw-r--r--src/state/queries/messages/conversation.ts25
-rw-r--r--src/state/queries/messages/get-convo-for-members.ts35
-rw-r--r--src/state/queries/messages/list-converations.ts28
-rw-r--r--src/state/queries/messages/temp-headers.ts11
-rw-r--r--src/temp/dm/defs.ts195
-rw-r--r--src/temp/dm/deleteMessage.ts31
-rw-r--r--src/temp/dm/getChat.ts30
-rw-r--r--src/temp/dm/getChatForMembers.ts30
-rw-r--r--src/temp/dm/getChatLog.ts36
-rw-r--r--src/temp/dm/getChatMessages.ts37
-rw-r--r--src/temp/dm/getUserSettings.ts28
-rw-r--r--src/temp/dm/leaveChat.ts30
-rw-r--r--src/temp/dm/listChats.ts32
-rw-r--r--src/temp/dm/muteChat.ts30
-rw-r--r--src/temp/dm/sendMessage.ts31
-rw-r--r--src/temp/dm/sendMessageBatch.ts66
-rw-r--r--src/temp/dm/unmuteChat.ts30
-rw-r--r--src/temp/dm/updateChatRead.ts31
-rw-r--r--src/temp/dm/updateUserSettings.ts33
-rw-r--r--yarn.lock12
30 files changed, 752 insertions, 1130 deletions
diff --git a/package.json b/package.json
index 874a55c16..9df20604a 100644
--- a/package.json
+++ b/package.json
@@ -50,6 +50,7 @@
     "open-analyzer": "EXPO_PUBLIC_OPEN_ANALYZER=1 yarn build-web"
   },
   "dependencies": {
+    "@atproto-labs/api": "^0.12.8-clipclops.0",
     "@atproto/api": "^0.12.5",
     "@bam.tech/react-native-image-resizer": "^3.0.4",
     "@braintree/sanitize-url": "^6.0.2",
diff --git a/src/components/dms/NewChat.tsx b/src/components/dms/NewChat.tsx
index 3ad1ce7ee..55285daed 100644
--- a/src/components/dms/NewChat.tsx
+++ b/src/components/dms/NewChat.tsx
@@ -9,6 +9,7 @@ import {sanitizeDisplayName} from '#/lib/strings/display-names'
 import {sanitizeHandle} from '#/lib/strings/handles'
 import {isWeb} from '#/platform/detection'
 import {useModerationOpts} from '#/state/preferences/moderation-opts'
+import {useGetConvoForMembers} from '#/state/queries/messages/get-convo-for-members'
 import {useActorAutocompleteQuery} from 'state/queries/actor-autocomplete'
 import {FAB} from '#/view/com/util/fab/FAB'
 import * as Toast from '#/view/com/util/Toast'
@@ -17,7 +18,6 @@ import {atoms as a, useTheme, web} from '#/alf'
 import * as Dialog from '#/components/Dialog'
 import * as TextField from '#/components/forms/TextField'
 import {MagnifyingGlass2_Stroke2_Corner0_Rounded as Search} from '#/components/icons/MagnifyingGlass2'
-import {useGetChatFromMembers} from '../../screens/Messages/Temp/query/query'
 import {Button} from '../Button'
 import {Envelope_Stroke2_Corner0_Rounded as Envelope} from '../icons/Envelope'
 import {ListMaybePlaceholder} from '../Lists'
@@ -33,9 +33,9 @@ export function NewChat({
   const t = useTheme()
   const {_} = useLingui()
 
-  const {mutate: createChat} = useGetChatFromMembers({
+  const {mutate: createChat} = useGetConvoForMembers({
     onSuccess: data => {
-      onNewChat(data.chat.id)
+      onNewChat(data.convo.id)
     },
     onError: error => {
       Toast.show(error.message)
diff --git a/src/screens/Messages/Conversation/MessageItem.tsx b/src/screens/Messages/Conversation/MessageItem.tsx
index 85e1c5f32..ba10978e8 100644
--- a/src/screens/Messages/Conversation/MessageItem.tsx
+++ b/src/screens/Messages/Conversation/MessageItem.tsx
@@ -1,5 +1,6 @@
 import React, {useCallback, useMemo} from 'react'
 import {StyleProp, TextStyle, View} from 'react-native'
+import {ChatBskyConvoDefs} from '@atproto-labs/api'
 import {msg} from '@lingui/macro'
 import {useLingui} from '@lingui/react'
 
@@ -7,14 +8,16 @@ import {useSession} from '#/state/session'
 import {TimeElapsed} from '#/view/com/util/TimeElapsed'
 import {atoms as a, useTheme} from '#/alf'
 import {Text} from '#/components/Typography'
-import * as TempDmChatDefs from '#/temp/dm/defs'
 
 export function MessageItem({
   item,
   next,
 }: {
-  item: TempDmChatDefs.MessageView
-  next: TempDmChatDefs.MessageView | TempDmChatDefs.DeletedMessage | null
+  item: ChatBskyConvoDefs.MessageView
+  next:
+    | ChatBskyConvoDefs.MessageView
+    | ChatBskyConvoDefs.DeletedMessageView
+    | null
 }) {
   const t = useTheme()
   const {currentAccount} = useSession()
@@ -22,7 +25,7 @@ export function MessageItem({
   const isFromSelf = item.sender?.did === currentAccount?.did
 
   const isNextFromSelf =
-    TempDmChatDefs.isMessageView(next) &&
+    ChatBskyConvoDefs.isMessageView(next) &&
     next.sender?.did === currentAccount?.did
 
   const isLastInGroup = useMemo(() => {
@@ -32,7 +35,7 @@ export function MessageItem({
     }
 
     // or, if there's a 10 minute gap between this message and the next
-    if (TempDmChatDefs.isMessageView(next)) {
+    if (ChatBskyConvoDefs.isMessageView(next)) {
       const thisDate = new Date(item.sentAt)
       const nextDate = new Date(next.sentAt)
 
@@ -88,7 +91,7 @@ function Metadata({
   isLastInGroup,
   style,
 }: {
-  message: TempDmChatDefs.MessageView
+  message: ChatBskyConvoDefs.MessageView
   isLastInGroup: boolean
   style: StyleProp<TextStyle>
 }) {
diff --git a/src/screens/Messages/Conversation/MessagesList.tsx b/src/screens/Messages/Conversation/MessagesList.tsx
index 25aaf28c4..ca32a6d68 100644
--- a/src/screens/Messages/Conversation/MessagesList.tsx
+++ b/src/screens/Messages/Conversation/MessagesList.tsx
@@ -1,24 +1,15 @@
-import React, {useCallback, useMemo, useRef, useState} from 'react'
+import React, {useCallback, useMemo, useRef} from 'react'
 import {FlatList, View, ViewToken} from 'react-native'
-import {Alert} from 'react-native'
 import {KeyboardAvoidingView} from 'react-native-keyboard-controller'
 
-import {isWeb} from '#/platform/detection'
+import {useChat} from '#/state/messages'
+import {ChatProvider} from '#/state/messages'
+import {ConvoItem, ConvoStatus} from '#/state/messages/convo'
+import {isWeb} from 'platform/detection'
 import {MessageInput} from '#/screens/Messages/Conversation/MessageInput'
 import {MessageItem} from '#/screens/Messages/Conversation/MessageItem'
-import {
-  useChat,
-  useChatLogQuery,
-  useSendMessageMutation,
-} from '#/screens/Messages/Temp/query/query'
 import {Loader} from '#/components/Loader'
 import {Text} from '#/components/Typography'
-import * as TempDmChatDefs from '#/temp/dm/defs'
-
-type MessageWithNext = {
-  message: TempDmChatDefs.MessageView | TempDmChatDefs.DeletedMessage
-  next: TempDmChatDefs.MessageView | TempDmChatDefs.DeletedMessage | null
-}
 
 function MaybeLoader({isLoading}: {isLoading: boolean}) {
   return (
@@ -34,47 +25,43 @@ function MaybeLoader({isLoading}: {isLoading: boolean}) {
   )
 }
 
-function renderItem({item}: {item: MessageWithNext}) {
-  if (TempDmChatDefs.isMessageView(item.message))
-    return <MessageItem item={item.message} next={item.next} />
-
-  if (TempDmChatDefs.isDeletedMessage(item)) return <Text>Deleted message</Text>
+function renderItem({item}: {item: ConvoItem}) {
+  if (item.type === 'message') {
+    return <MessageItem item={item.message} next={item.nextMessage} />
+  } else if (item.type === 'deleted-message') {
+    return <Text>Deleted message</Text>
+  } else if (item.type === 'pending-message') {
+    return <Text>{item.message.text}</Text>
+  }
 
   return null
 }
 
-// TODO rm
-// TEMP: This is a temporary function to generate unique keys for mutation placeholders
-const generateUniqueKey = () => `_${Math.random().toString(36).substr(2, 9)}`
-
 function onScrollToEndFailed() {
   // Placeholder function. You have to give FlatList something or else it will error.
 }
 
-export function MessagesList({chatId}: {chatId: string}) {
-  const flatListRef = useRef<FlatList>(null)
-
-  // Whenever we reach the end (visually the top), we don't want to keep calling it. We will set `isFetching` to true
-  // once the request for new posts starts. Then, we will change it back to false after the content size changes.
-  const isFetching = useRef(false)
+export function MessagesList({convoId}: {convoId: string}) {
+  return (
+    <ChatProvider convoId={convoId}>
+      <MessagesListInner />
+    </ChatProvider>
+  )
+}
 
+export function MessagesListInner() {
+  const chat = useChat()
+  const flatListRef = useRef<FlatList>(null)
   // We use this to know if we should scroll after a new clop is added to the list
   const isAtBottom = useRef(false)
 
   // Because the viewableItemsChanged callback won't have access to the updated state, we use a ref to store the
   // total number of clops
   // TODO this needs to be set to whatever the initial number of messages is
-  const totalMessages = useRef(10)
+  // const totalMessages = useRef(10)
 
   // TODO later
 
-  const [_, setShowSpinner] = useState(false)
-
-  // Query Data
-  const {data: chat} = useChat(chatId)
-  const {mutate: sendMessage} = useSendMessageMutation(chatId)
-  useChatLogQuery()
-
   const [onViewableItemsChanged, viewabilityConfig] = useMemo(() => {
     return [
       (info: {viewableItems: Array<ViewToken>; changed: Array<ViewToken>}) => {
@@ -93,23 +80,11 @@ export function MessagesList({chatId}: {chatId: string}) {
     if (isAtBottom.current) {
       flatListRef.current?.scrollToOffset({offset: 0, animated: true})
     }
-
-    isFetching.current = false
-    setShowSpinner(false)
   }, [])
 
   const onEndReached = useCallback(() => {
-    if (isFetching.current) return
-    isFetching.current = true
-    setShowSpinner(true)
-
-    // Eventually we will add more here when we hit the top through RQuery
-    // We wouldn't actually use a timeout, but there would be a delay while loading
-    setTimeout(() => {
-      // Do something
-      setShowSpinner(false)
-    }, 1000)
-  }, [])
+    chat.service.fetchMessageHistory()
+  }, [chat])
 
   const onInputFocus = useCallback(() => {
     if (!isAtBottom.current) {
@@ -117,84 +92,51 @@ export function MessagesList({chatId}: {chatId: string}) {
     }
   }, [])
 
-  const onSendMessage = useCallback(
-    async (message: string) => {
-      if (!message) return
-
-      try {
-        sendMessage({
-          message,
-          tempId: generateUniqueKey(),
-        })
-      } catch (e: any) {
-        Alert.alert(e.toString())
-      }
-    },
-    [sendMessage],
-  )
-
   const onInputBlur = useCallback(() => {}, [])
 
-  const messages = useMemo(() => {
-    if (!chat) return []
-
-    const filtered = chat.messages
-      .filter(
-        (
-          message,
-        ): message is
-          | TempDmChatDefs.MessageView
-          | TempDmChatDefs.DeletedMessage => {
-          return (
-            TempDmChatDefs.isMessageView(message) ||
-            TempDmChatDefs.isDeletedMessage(message)
-          )
-        },
-      )
-      .reduce((acc, message) => {
-        // convert [n1, n2, n3, ...] to [{message: n1, next: n2}, {message: n2, next: n3}, {message: n3, next: n4}, ...]
-
-        return [...acc, {message, next: acc.at(-1)?.message ?? null}]
-      }, [] as MessageWithNext[])
-    totalMessages.current = filtered.length
-
-    return filtered
-  }, [chat])
-
   return (
     <KeyboardAvoidingView
       style={{flex: 1, marginBottom: isWeb ? 20 : 85}}
       behavior="padding"
       keyboardVerticalOffset={70}
       contentContainerStyle={{flex: 1}}>
-      <FlatList
-        data={messages}
-        keyExtractor={item => item.message.id}
-        renderItem={renderItem}
-        contentContainerStyle={{paddingHorizontal: 10}}
-        inverted={true}
-        // In the future, we might want to adjust this value. Not very concerning right now as long as we are only
-        // dealing with text. But whenever we have images or other media and things are taller, we will want to lower
-        // this...probably.
-        initialNumToRender={20}
-        // Same with the max to render per batch. Let's be safe for now though.
-        maxToRenderPerBatch={25}
-        removeClippedSubviews={true}
-        onEndReached={onEndReached}
-        onScrollToIndexFailed={onScrollToEndFailed}
-        onContentSizeChange={onContentSizeChange}
-        onViewableItemsChanged={onViewableItemsChanged}
-        viewabilityConfig={viewabilityConfig}
-        maintainVisibleContentPosition={{
-          minIndexForVisible: 1,
-        }}
-        ListFooterComponent={<MaybeLoader isLoading={false} />}
-        ref={flatListRef}
-        keyboardDismissMode="none"
-      />
+      {chat.state.status === ConvoStatus.Ready && (
+        <FlatList
+          data={chat.state.items}
+          keyExtractor={item => item.key}
+          renderItem={renderItem}
+          contentContainerStyle={{paddingHorizontal: 10}}
+          // In the future, we might want to adjust this value. Not very concerning right now as long as we are only
+          // dealing with text. But whenever we have images or other media and things are taller, we will want to lower
+          // this...probably.
+          initialNumToRender={20}
+          // Same with the max to render per batch. Let's be safe for now though.
+          maxToRenderPerBatch={25}
+          inverted={true}
+          onEndReached={onEndReached}
+          onScrollToIndexFailed={onScrollToEndFailed}
+          onContentSizeChange={onContentSizeChange}
+          onViewableItemsChanged={onViewableItemsChanged}
+          viewabilityConfig={viewabilityConfig}
+          maintainVisibleContentPosition={{
+            minIndexForVisible: 0,
+          }}
+          ListFooterComponent={
+            <MaybeLoader isLoading={chat.state.isFetchingHistory} />
+          }
+          removeClippedSubviews={true}
+          ref={flatListRef}
+          keyboardDismissMode="none"
+        />
+      )}
+
       <View style={{paddingHorizontal: 10}}>
         <MessageInput
-          onSendMessage={onSendMessage}
+          onSendMessage={text => {
+            chat.service.sendMessage({
+              text,
+            })
+          }}
           onFocus={onInputFocus}
           onBlur={onInputBlur}
         />
diff --git a/src/screens/Messages/Conversation/index.tsx b/src/screens/Messages/Conversation/index.tsx
index d06913857..9c5fd7912 100644
--- a/src/screens/Messages/Conversation/index.tsx
+++ b/src/screens/Messages/Conversation/index.tsx
@@ -9,13 +9,13 @@ import {NativeStackScreenProps} from '@react-navigation/native-stack'
 
 import {CommonNavigatorParams, NavigationProp} from '#/lib/routes/types'
 import {useGate} from '#/lib/statsig/statsig'
+import {useConvoQuery} from '#/state/queries/messages/conversation'
 import {BACK_HITSLOP} from 'lib/constants'
 import {isWeb} from 'platform/detection'
 import {useSession} from 'state/session'
 import {UserAvatar} from 'view/com/util/UserAvatar'
 import {CenteredView} from 'view/com/util/Views'
 import {MessagesList} from '#/screens/Messages/Conversation/MessagesList'
-import {useChatQuery} from '#/screens/Messages/Temp/query/query'
 import {atoms as a, useBreakpoints, useTheme} from '#/alf'
 import {Button, ButtonIcon} from '#/components/Button'
 import {DotGrid_Stroke2_Corner0_Rounded} from '#/components/icons/DotGrid'
@@ -29,11 +29,11 @@ type Props = NativeStackScreenProps<
 >
 export function MessagesConversationScreen({route}: Props) {
   const gate = useGate()
-  const chatId = route.params.conversation
+  const convoId = route.params.conversation
   const {currentAccount} = useSession()
   const myDid = currentAccount?.did
 
-  const {data: chat, isError: isError} = useChatQuery(chatId)
+  const {data: chat, isError: isError} = useConvoQuery(convoId)
   const otherProfile = React.useMemo(() => {
     return chat?.members?.find(m => m.did !== myDid)
   }, [chat?.members, myDid])
@@ -51,7 +51,7 @@ export function MessagesConversationScreen({route}: Props) {
   return (
     <CenteredView style={{flex: 1}} sideBorders>
       <Header profile={otherProfile} />
-      <MessagesList chatId={chatId} />
+      <MessagesList convoId={convoId} />
     </CenteredView>
   )
 }
diff --git a/src/screens/Messages/List/index.tsx b/src/screens/Messages/List/index.tsx
index ccf0d2044..65a2ff1e7 100644
--- a/src/screens/Messages/List/index.tsx
+++ b/src/screens/Messages/List/index.tsx
@@ -2,6 +2,7 @@
 
 import React, {useCallback, useMemo, useState} from 'react'
 import {View} from 'react-native'
+import {ChatBskyConvoDefs} from '@atproto-labs/api'
 import {msg, Trans} from '@lingui/macro'
 import {useLingui} from '@lingui/react'
 import {NativeStackScreenProps} from '@react-navigation/native-stack'
@@ -11,24 +12,22 @@ import {MessagesTabNavigatorParams} from '#/lib/routes/types'
 import {useGate} from '#/lib/statsig/statsig'
 import {cleanError} from '#/lib/strings/errors'
 import {logger} from '#/logger'
+import {useListConvos} from '#/state/queries/messages/list-converations'
 import {useSession} from '#/state/session'
 import {List} from '#/view/com/util/List'
 import {TimeElapsed} from '#/view/com/util/TimeElapsed'
 import {PreviewableUserAvatar} from '#/view/com/util/UserAvatar'
 import {ViewHeader} from '#/view/com/util/ViewHeader'
-import {useBreakpoints, useTheme} from '#/alf'
-import {atoms as a} from '#/alf'
+import {atoms as a, useBreakpoints, useTheme} from '#/alf'
 import {Button, ButtonIcon, ButtonText} from '#/components/Button'
 import {DialogControlProps, useDialogControl} from '#/components/Dialog'
+import {NewChat} from '#/components/dms/NewChat'
 import {Envelope_Stroke2_Corner0_Rounded as Envelope} from '#/components/icons/Envelope'
 import {SettingsSliderVertical_Stroke2_Corner0_Rounded as SettingsSlider} from '#/components/icons/SettingsSlider'
 import {Link} from '#/components/Link'
 import {ListFooter, ListMaybePlaceholder} from '#/components/Lists'
 import {Text} from '#/components/Typography'
-import * as TempDmChatDefs from '#/temp/dm/defs'
-import {NewChat} from '../../../components/dms/NewChat'
 import {ClipClopGate} from '../gate'
-import {useListChats} from '../Temp/query/query'
 
 type Props = NativeStackScreenProps<MessagesTabNavigatorParams, 'MessagesList'>
 export function MessagesListScreen({navigation}: Props) {
@@ -59,13 +58,13 @@ export function MessagesListScreen({navigation}: Props) {
     fetchNextPage,
     error,
     refetch,
-  } = useListChats()
+  } = useListConvos()
 
   const isError = !!error
 
   const conversations = useMemo(() => {
     if (data?.pages) {
-      return data.pages.flatMap(page => page.chats)
+      return data.pages.flatMap(page => page.convos)
     }
     return []
   }, [data])
@@ -99,9 +98,12 @@ export function MessagesListScreen({navigation}: Props) {
     navigation.navigate('MessagesSettings')
   }, [navigation])
 
-  const renderItem = useCallback(({item}: {item: TempDmChatDefs.ChatView}) => {
-    return <ChatListItem key={item.id} chat={item} />
-  }, [])
+  const renderItem = useCallback(
+    ({item}: {item: ChatBskyConvoDefs.ConvoView}) => {
+      return <ChatListItem key={item.id} convo={item} />
+    },
+    [],
+  )
 
   const gate = useGate()
   if (!gate('dms')) return <ClipClopGate />
@@ -119,7 +121,7 @@ export function MessagesListScreen({navigation}: Props) {
           errorMessage={cleanError(error)}
           onRetry={isError ? refetch : undefined}
         />
-        <NewChat onNewChat={onNewChat} control={newChatControl} />
+        {!isError && <NewChat onNewChat={onNewChat} control={newChatControl} />}
       </>
     )
   }
@@ -166,26 +168,26 @@ export function MessagesListScreen({navigation}: Props) {
   )
 }
 
-function ChatListItem({chat}: {chat: TempDmChatDefs.ChatView}) {
+function ChatListItem({convo}: {convo: ChatBskyConvoDefs.ConvoView}) {
   const t = useTheme()
   const {_} = useLingui()
   const {currentAccount} = useSession()
 
   let lastMessage = _(msg`No messages yet`)
   let lastMessageSentAt: string | null = null
-  if (TempDmChatDefs.isMessageView(chat.lastMessage)) {
-    if (chat.lastMessage.sender?.did === currentAccount?.did) {
-      lastMessage = _(msg`You: ${chat.lastMessage.text}`)
+  if (ChatBskyConvoDefs.isMessageView(convo.lastMessage)) {
+    if (convo.lastMessage.sender?.did === currentAccount?.did) {
+      lastMessage = _(msg`You: ${convo.lastMessage.text}`)
     } else {
-      lastMessage = chat.lastMessage.text
+      lastMessage = convo.lastMessage.text
     }
-    lastMessageSentAt = chat.lastMessage.sentAt
+    lastMessageSentAt = convo.lastMessage.sentAt
   }
-  if (TempDmChatDefs.isDeletedMessage(chat.lastMessage)) {
+  if (ChatBskyConvoDefs.isDeletedMessageView(convo.lastMessage)) {
     lastMessage = _(msg`Message deleted`)
   }
 
-  const otherUser = chat.members.find(
+  const otherUser = convo.members.find(
     member => member.did !== currentAccount?.did,
   )
 
@@ -194,7 +196,7 @@ function ChatListItem({chat}: {chat: TempDmChatDefs.ChatView}) {
   }
 
   return (
-    <Link to={`/messages/${chat.id}`} style={a.flex_1}>
+    <Link to={`/messages/${convo.id}`} style={a.flex_1}>
       {({hovered, pressed}) => (
         <View
           style={[
@@ -211,7 +213,8 @@ function ChatListItem({chat}: {chat: TempDmChatDefs.ChatView}) {
           </View>
           <View style={[a.flex_1]}>
             <Text numberOfLines={1} style={[a.text_md, a.leading_normal]}>
-              <Text style={[t.atoms.text, chat.unreadCount > 0 && a.font_bold]}>
+              <Text
+                style={[t.atoms.text, convo.unreadCount > 0 && a.font_bold]}>
                 {otherUser.displayName || otherUser.handle}
               </Text>{' '}
               {lastMessageSentAt ? (
@@ -233,14 +236,14 @@ function ChatListItem({chat}: {chat: TempDmChatDefs.ChatView}) {
               style={[
                 a.text_sm,
                 a.leading_snug,
-                chat.unreadCount > 0
+                convo.unreadCount > 0
                   ? a.font_bold
                   : t.atoms.text_contrast_medium,
               ]}>
               {lastMessage}
             </Text>
           </View>
-          {chat.unreadCount > 0 && (
+          {convo.unreadCount > 0 && (
             <View
               style={[
                 a.flex_0,
diff --git a/src/screens/Messages/Temp/query/query.ts b/src/screens/Messages/Temp/query/query.ts
deleted file mode 100644
index 02f31c029..000000000
--- a/src/screens/Messages/Temp/query/query.ts
+++ /dev/null
@@ -1,305 +0,0 @@
-import {
-  useInfiniteQuery,
-  useMutation,
-  useQuery,
-  useQueryClient,
-} from '@tanstack/react-query'
-
-import {useSession} from '#/state/session'
-import * as TempDmChatDefs from '#/temp/dm/defs'
-import * as TempDmChatGetChat from '#/temp/dm/getChat'
-import * as TempDmChatGetChatForMembers from '#/temp/dm/getChatForMembers'
-import * as TempDmChatGetChatLog from '#/temp/dm/getChatLog'
-import * as TempDmChatGetChatMessages from '#/temp/dm/getChatMessages'
-import * as TempDmChatListChats from '#/temp/dm/listChats'
-import {useDmServiceUrlStorage} from '../useDmServiceUrlStorage'
-
-/**
- * TEMPORARY, PLEASE DO NOT JUDGE ME REACT QUERY OVERLORDS 🙏
- * (and do not try this at home)
- */
-
-const useHeaders = () => {
-  const {currentAccount} = useSession()
-  return {
-    get Authorization() {
-      return currentAccount!.did
-    },
-  }
-}
-
-type Chat = {
-  chatId: string
-  messages: TempDmChatGetChatMessages.OutputSchema['messages']
-  lastCursor?: string
-  lastRev?: string
-}
-
-export function useChat(chatId: string) {
-  const queryClient = useQueryClient()
-  const headers = useHeaders()
-  const {serviceUrl} = useDmServiceUrlStorage()
-
-  return useQuery({
-    queryKey: ['chat', chatId],
-    queryFn: async () => {
-      const currentChat = queryClient.getQueryData(['chat', chatId])
-
-      if (currentChat) {
-        return currentChat as Chat
-      }
-
-      const messagesResponse = await fetch(
-        `${serviceUrl}/xrpc/temp.dm.getChatMessages?chatId=${chatId}`,
-        {
-          headers,
-        },
-      )
-
-      if (!messagesResponse.ok) throw new Error('Failed to fetch messages')
-
-      const messagesJson =
-        (await messagesResponse.json()) as TempDmChatGetChatMessages.OutputSchema
-
-      const chatResponse = await fetch(
-        `${serviceUrl}/xrpc/temp.dm.getChat?chatId=${chatId}`,
-        {
-          headers,
-        },
-      )
-
-      if (!chatResponse.ok) throw new Error('Failed to fetch chat')
-
-      const chatJson =
-        (await chatResponse.json()) as TempDmChatGetChat.OutputSchema
-
-      queryClient.setQueryData(['chatQuery', chatId], chatJson.chat)
-
-      const newChat = {
-        chatId,
-        messages: messagesJson.messages,
-        lastCursor: messagesJson.cursor,
-        lastRev: chatJson.chat.rev,
-      } satisfies Chat
-
-      queryClient.setQueryData(['chat', chatId], newChat)
-
-      return newChat
-    },
-  })
-}
-
-interface SendMessageMutationVariables {
-  message: string
-  tempId: string
-}
-
-export function createTempId() {
-  return Math.random().toString(36).substring(7).toString()
-}
-
-export function useSendMessageMutation(chatId: string) {
-  const queryClient = useQueryClient()
-  const headers = useHeaders()
-  const {serviceUrl} = useDmServiceUrlStorage()
-
-  return useMutation<
-    TempDmChatDefs.Message,
-    Error,
-    SendMessageMutationVariables,
-    unknown
-  >({
-    // eslint-disable-next-line @typescript-eslint/no-unused-vars
-    mutationFn: async ({message, tempId}) => {
-      const response = await fetch(
-        `${serviceUrl}/xrpc/temp.dm.sendMessage?chatId=${chatId}`,
-        {
-          method: 'POST',
-          headers: {
-            ...headers,
-            'Content-Type': 'application/json',
-          },
-          body: JSON.stringify({
-            chatId,
-            message: {
-              text: message,
-            },
-          }),
-        },
-      )
-
-      if (!response.ok) throw new Error('Failed to send message')
-
-      return response.json()
-    },
-    onMutate: async variables => {
-      queryClient.setQueryData(['chat', chatId], (prev: Chat) => {
-        return {
-          ...prev,
-          messages: [
-            {
-              $type: 'temp.dm.defs#messageView',
-              id: variables.tempId,
-              text: variables.message,
-              sender: {did: headers.Authorization}, // TODO a real DID get
-              sentAt: new Date().toISOString(),
-            },
-            ...prev.messages,
-          ],
-        }
-      })
-    },
-    onSuccess: (result, variables) => {
-      queryClient.setQueryData(['chat', chatId], (prev: Chat) => {
-        return {
-          ...prev,
-          messages: prev.messages.map(m =>
-            m.id === variables.tempId ? {...m, id: result.id} : m,
-          ),
-        }
-      })
-    },
-    onError: (_, variables) => {
-      console.log(_)
-      queryClient.setQueryData(['chat', chatId], (prev: Chat) => ({
-        ...prev,
-        messages: prev.messages.filter(m => m.id !== variables.tempId),
-      }))
-    },
-  })
-}
-
-export function useChatLogQuery() {
-  const queryClient = useQueryClient()
-  const headers = useHeaders()
-  const {serviceUrl} = useDmServiceUrlStorage()
-
-  return useQuery({
-    queryKey: ['chatLog'],
-    queryFn: async () => {
-      const prevLog = queryClient.getQueryData([
-        'chatLog',
-      ]) as TempDmChatGetChatLog.OutputSchema
-
-      try {
-        const response = await fetch(
-          `${serviceUrl}/xrpc/temp.dm.getChatLog?cursor=${
-            prevLog?.cursor ?? ''
-          }`,
-          {
-            headers,
-          },
-        )
-
-        if (!response.ok) throw new Error('Failed to fetch chat log')
-
-        const json =
-          (await response.json()) as TempDmChatGetChatLog.OutputSchema
-
-        if (json.logs.length > 0) {
-          queryClient.invalidateQueries({queryKey: ['chats']})
-        }
-
-        for (const log of json.logs) {
-          if (TempDmChatDefs.isLogCreateMessage(log)) {
-            queryClient.setQueryData(['chat', log.chatId], (prev: Chat) => {
-              // TODO hack filter out duplicates
-              if (prev?.messages.find(m => m.id === log.message.id)) return
-
-              return {
-                ...prev,
-                messages: [log.message, ...prev.messages],
-              }
-            })
-          }
-        }
-
-        return json
-      } catch (e) {
-        console.log(e)
-      }
-    },
-    refetchInterval: 5000,
-  })
-}
-
-export function useGetChatFromMembers({
-  onSuccess,
-  onError,
-}: {
-  onSuccess?: (data: TempDmChatGetChatForMembers.OutputSchema) => void
-  onError?: (error: Error) => void
-}) {
-  const queryClient = useQueryClient()
-  const headers = useHeaders()
-  const {serviceUrl} = useDmServiceUrlStorage()
-
-  return useMutation({
-    mutationFn: async (members: string[]) => {
-      const response = await fetch(
-        `${serviceUrl}/xrpc/temp.dm.getChatForMembers?members=${members.join(
-          ',',
-        )}`,
-        {headers},
-      )
-
-      if (!response.ok) throw new Error('Failed to fetch chat')
-
-      return (await response.json()) as TempDmChatGetChatForMembers.OutputSchema
-    },
-    onSuccess: data => {
-      queryClient.setQueryData(['chat', data.chat.id], {
-        chatId: data.chat.id,
-        messages: [],
-        lastRev: data.chat.rev,
-      })
-      onSuccess?.(data)
-    },
-    onError,
-  })
-}
-
-export function useListChats() {
-  const headers = useHeaders()
-  const {serviceUrl} = useDmServiceUrlStorage()
-
-  return useInfiniteQuery({
-    queryKey: ['chats'],
-    queryFn: async ({pageParam}) => {
-      const response = await fetch(
-        `${serviceUrl}/xrpc/temp.dm.listChats${
-          pageParam ? `?cursor=${pageParam}` : ''
-        }`,
-        {headers},
-      )
-
-      if (!response.ok) throw new Error('Failed to fetch chats')
-
-      return (await response.json()) as TempDmChatListChats.OutputSchema
-    },
-    initialPageParam: undefined as string | undefined,
-    getNextPageParam: lastPage => lastPage.cursor,
-  })
-}
-
-export function useChatQuery(chatId: string) {
-  const headers = useHeaders()
-  const {serviceUrl} = useDmServiceUrlStorage()
-
-  return useQuery({
-    queryKey: ['chatQuery', chatId],
-    queryFn: async () => {
-      const chatResponse = await fetch(
-        `${serviceUrl}/xrpc/temp.dm.getChat?chatId=${chatId}`,
-        {
-          headers,
-        },
-      )
-
-      if (!chatResponse.ok) throw new Error('Failed to fetch chat')
-
-      const json = (await chatResponse.json()) as TempDmChatGetChat.OutputSchema
-      return json.chat
-    },
-  })
-}
diff --git a/src/state/messages/__tests__/client.test.ts b/src/state/messages/__tests__/client.test.ts
new file mode 100644
index 000000000..cab1d9021
--- /dev/null
+++ b/src/state/messages/__tests__/client.test.ts
@@ -0,0 +1,38 @@
+import {describe, it} from '@jest/globals'
+
+describe(`#/state/dms/client`, () => {
+  describe(`ChatsService`, () => {
+    describe(`unread count`, () => {
+      it.todo(`marks a chat as read, decrements total unread count`)
+    })
+
+    describe(`log processing`, () => {
+      /*
+       * We receive a new chat log AND messages for it in the same batch. We
+       * need to first initialize the chat, then process the received logs.
+       */
+      describe(`handles new chats and subsequent messages received in same log batch`, () => {
+        it.todo(`receives new chat and messages`)
+        it.todo(
+          `receives new chat, new messages come in while still initializing new chat`,
+        )
+      })
+    })
+
+    describe(`reset state`, () => {
+      it.todo(`after period of inactivity, rehydrates entirely fresh state`)
+    })
+  })
+
+  describe(`ChatService`, () => {
+    describe(`history fetching`, () => {
+      it.todo(`fetches initial chat history`)
+      it.todo(`fetches additional chat history`)
+      it.todo(`handles history fetch failure`)
+    })
+
+    describe(`optimistic updates`, () => {
+      it.todo(`adds sending messages`)
+    })
+  })
+})
diff --git a/src/state/messages/convo.ts b/src/state/messages/convo.ts
new file mode 100644
index 000000000..a1de1dbed
--- /dev/null
+++ b/src/state/messages/convo.ts
@@ -0,0 +1,442 @@
+import {
+  BskyAgent,
+  ChatBskyConvoDefs,
+  ChatBskyConvoSendMessage,
+} from '@atproto-labs/api'
+import {EventEmitter} from 'eventemitter3'
+import {nanoid} from 'nanoid/non-secure'
+
+export type ConvoParams = {
+  convoId: string
+  agent: BskyAgent
+  __tempFromUserDid: string
+}
+
+export enum ConvoStatus {
+  Uninitialized = 'uninitialized',
+  Initializing = 'initializing',
+  Ready = 'ready',
+  Error = 'error',
+  Destroyed = 'destroyed',
+}
+
+export type ConvoItem =
+  | {
+      type: '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-message'
+      key: string
+      message: ChatBskyConvoSendMessage.InputSchema['message']
+    }
+
+export type ConvoState =
+  | {
+      status: ConvoStatus.Uninitialized
+    }
+  | {
+      status: ConvoStatus.Initializing
+    }
+  | {
+      status: ConvoStatus.Ready
+      items: ConvoItem[]
+      convo: ChatBskyConvoDefs.ConvoView
+      isFetchingHistory: boolean
+    }
+  | {
+      status: ConvoStatus.Error
+      error: any
+    }
+  | {
+      status: ConvoStatus.Destroyed
+    }
+
+export class Convo {
+  private convoId: string
+  private agent: BskyAgent
+  private __tempFromUserDid: string
+
+  private status: ConvoStatus = ConvoStatus.Uninitialized
+  private error: any
+  private convo: ChatBskyConvoDefs.ConvoView | undefined
+  private historyCursor: string | undefined | null = undefined
+  private isFetchingHistory = false
+  private eventsCursor: string | undefined = undefined
+
+  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 pendingEventIngestion: Promise<void> | undefined
+
+  constructor(params: ConvoParams) {
+    this.convoId = params.convoId
+    this.agent = params.agent
+    this.__tempFromUserDid = params.__tempFromUserDid
+  }
+
+  async initialize() {
+    if (this.status !== 'uninitialized') return
+    this.status = ConvoStatus.Initializing
+
+    try {
+      const response = await this.agent.api.chat.bsky.convo.getConvo(
+        {
+          convoId: this.convoId,
+        },
+        {
+          headers: {
+            Authorization: this.__tempFromUserDid,
+          },
+        },
+      )
+      const {convo} = response.data
+
+      this.convo = convo
+      this.status = ConvoStatus.Ready
+
+      this.commit()
+
+      await this.fetchMessageHistory()
+
+      this.pollEvents()
+    } catch (e) {
+      this.status = ConvoStatus.Error
+      this.error = e
+    }
+  }
+
+  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 fetchMessageHistory() {
+    if (this.status === ConvoStatus.Destroyed) return
+    // reached end
+    if (this.historyCursor === null) return
+    if (this.isFetchingHistory) return
+
+    this.isFetchingHistory = true
+    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))
+    }
+
+    const response = await this.agent.api.chat.bsky.convo.getMessages(
+      {
+        cursor: this.historyCursor,
+        convoId: this.convoId,
+        limit: 20,
+      },
+      {
+        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
+        }
+      }
+    }
+
+    this.isFetchingHistory = false
+    this.commit()
+  }
+
+  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,
+        },
+      },
+    )
+    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') {
+        /*
+         * 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.
+           *
+           * TODO there may be a better way to handle this
+           */
+          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
+              // TODO test this
+              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.
+             */
+            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.commit()
+  }
+
+  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) return
+
+    const tempId = nanoid()
+
+    this.pendingMessages.set(tempId, {
+      id: tempId,
+      message,
+    })
+    this.commit()
+
+    await new Promise(y => setTimeout(y, 500))
+    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.convo?.members.find(m => m.did === this.__tempFromUserDid),
+    })
+    this.pendingMessages.delete(tempId)
+
+    this.commit()
+  }
+
+  /*
+   * Items in reverse order, since FlatList inverts
+   */
+  get items(): ConvoItem[] {
+    const items: ConvoItem[] = []
+
+    // `newMessages` is in insertion order, unshift to reverse
+    this.newMessages.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,
+        })
+      }
+    })
+
+    // `newMessages` is in insertion order, unshift to reverse
+    this.pendingMessages.forEach(m => {
+      items.unshift({
+        type: 'pending-message',
+        key: m.id,
+        message: m.message,
+      })
+    })
+
+    this.pastMessages.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,
+        })
+      }
+    })
+
+    return items.map((item, i) => {
+      let nextMessage = null
+
+      if (
+        ChatBskyConvoDefs.isMessageView(item.message) ||
+        ChatBskyConvoDefs.isDeletedMessageView(item.message)
+      ) {
+        const next = items[i - 1]
+        if (
+          next &&
+          (ChatBskyConvoDefs.isMessageView(next.message) ||
+            ChatBskyConvoDefs.isDeletedMessageView(next.message))
+        ) {
+          nextMessage = next.message
+        }
+      }
+
+      return {
+        ...item,
+        nextMessage,
+      }
+    })
+  }
+
+  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,
+        }
+      }
+    }
+  }
+
+  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)
+  }
+}
diff --git a/src/state/messages/index.tsx b/src/state/messages/index.tsx
new file mode 100644
index 000000000..c59915253
--- /dev/null
+++ b/src/state/messages/index.tsx
@@ -0,0 +1,57 @@
+import React from 'react'
+import {BskyAgent} from '@atproto-labs/api'
+
+import {Convo, ConvoParams} 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']
+}>({
+  // @ts-ignore
+  service: null,
+  // @ts-ignore
+  state: null,
+})
+
+export function useChat() {
+  return React.useContext(ChatContext)
+}
+
+export function ChatProvider({
+  children,
+  convoId,
+}: Pick<ConvoParams, 'convoId'> & {children: React.ReactNode}) {
+  const {serviceUrl} = useDmServiceUrlStorage()
+  const {getAgent} = useAgent()
+  const [service] = React.useState(
+    () =>
+      new Convo({
+        convoId,
+        agent: new BskyAgent({
+          service: serviceUrl,
+        }),
+        __tempFromUserDid: getAgent().session?.did!,
+      }),
+  )
+  const [state, setState] = React.useState(service.state)
+
+  React.useEffect(() => {
+    service.initialize()
+  }, [service])
+
+  React.useEffect(() => {
+    const update = () => setState(service.state)
+    service.on('update', update)
+    return () => {
+      service.destroy()
+    }
+  }, [service])
+
+  return (
+    <ChatContext.Provider value={{state, service}}>
+      {children}
+    </ChatContext.Provider>
+  )
+}
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..8a58a98d8
--- /dev/null
+++ b/src/state/queries/messages/get-convo-for-members.ts
@@ -0,0 +1,35 @@
+import {BskyAgent, ChatBskyConvoGetConvoForMembers} from '@atproto-labs/api'
+import {useMutation, useQueryClient} from '@tanstack/react-query'
+
+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,
+  })
+}
diff --git a/src/state/queries/messages/list-converations.ts b/src/state/queries/messages/list-converations.ts
new file mode 100644
index 000000000..19f2674bd
--- /dev/null
+++ b/src/state/queries/messages/list-converations.ts
@@ -0,0 +1,28 @@
+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() {
+  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,
+  })
+}
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/temp/dm/defs.ts b/src/temp/dm/defs.ts
deleted file mode 100644
index 91f68365c..000000000
--- a/src/temp/dm/defs.ts
+++ /dev/null
@@ -1,195 +0,0 @@
-import {
-  AppBskyActorDefs,
-  AppBskyEmbedRecord,
-  AppBskyRichtextFacet,
-} from '@atproto/api'
-import {ValidationResult} from '@atproto/lexicon'
-
-export interface Message {
-  id?: string
-  text: string
-  /** Annotations of text (mentions, URLs, hashtags, etc) */
-  facets?: AppBskyRichtextFacet.Main[]
-  embed?: AppBskyEmbedRecord.Main | {$type: string; [k: string]: unknown}
-  [k: string]: unknown
-}
-
-export function isMessage(v: unknown): v is Message {
-  return isObj(v) && hasProp(v, '$type') && v.$type === 'temp.dm.defs#message'
-}
-
-export function validateMessage(v: unknown): ValidationResult {
-  return {
-    success: true,
-    value: v,
-  }
-}
-
-export interface MessageView {
-  id: string
-  rev: string
-  text: string
-  /** Annotations of text (mentions, URLs, hashtags, etc) */
-  facets?: AppBskyRichtextFacet.Main[]
-  embed?: AppBskyEmbedRecord.Main | {$type: string; [k: string]: unknown}
-  sender?: MessageViewSender
-  sentAt: string
-  [k: string]: unknown
-}
-
-export function isMessageView(v: unknown): v is MessageView {
-  return (
-    isObj(v) && hasProp(v, '$type') && v.$type === 'temp.dm.defs#messageView'
-  )
-}
-
-export function validateMessageView(v: unknown): ValidationResult {
-  return {
-    success: true,
-    value: v,
-  }
-}
-
-export interface DeletedMessage {
-  id: string
-  rev?: string
-  sender?: MessageViewSender
-  sentAt: string
-  [k: string]: unknown
-}
-
-export function isDeletedMessage(v: unknown): v is DeletedMessage {
-  return (
-    isObj(v) && hasProp(v, '$type') && v.$type === 'temp.dm.defs#deletedMessage'
-  )
-}
-
-export function validateDeletedMessage(v: unknown): ValidationResult {
-  return {
-    success: true,
-    value: v,
-  }
-}
-
-export interface MessageViewSender {
-  did: string
-  [k: string]: unknown
-}
-
-export function isMessageViewSender(v: unknown): v is MessageViewSender {
-  return (
-    isObj(v) &&
-    hasProp(v, '$type') &&
-    v.$type === 'temp.dm.defs#messageViewSender'
-  )
-}
-
-export function validateMessageViewSender(v: unknown): ValidationResult {
-  return {
-    success: true,
-    value: v,
-  }
-}
-
-export interface ChatView {
-  id: string
-  rev: string
-  members: AppBskyActorDefs.ProfileViewBasic[]
-  lastMessage?:
-    | MessageView
-    | DeletedMessage
-    | {$type: string; [k: string]: unknown}
-  unreadCount: number
-  [k: string]: unknown
-}
-
-export function isChatView(v: unknown): v is ChatView {
-  return isObj(v) && hasProp(v, '$type') && v.$type === 'temp.dm.defs#chatView'
-}
-
-export function validateChatView(v: unknown): ValidationResult {
-  return {
-    success: true,
-    value: v,
-  }
-}
-
-export type IncomingMessageSetting =
-  | 'all'
-  | 'none'
-  | 'following'
-  | (string & {})
-
-export interface LogBeginChat {
-  rev: string
-  chatId: string
-  [k: string]: unknown
-}
-
-export function isLogBeginChat(v: unknown): v is LogBeginChat {
-  return (
-    isObj(v) && hasProp(v, '$type') && v.$type === 'temp.dm.defs#logBeginChat'
-  )
-}
-
-export function validateLogBeginChat(v: unknown): ValidationResult {
-  return {
-    success: true,
-    value: v,
-  }
-}
-
-export interface LogCreateMessage {
-  rev: string
-  chatId: string
-  message: MessageView | DeletedMessage | {$type: string; [k: string]: unknown}
-  [k: string]: unknown
-}
-
-export function isLogCreateMessage(v: unknown): v is LogCreateMessage {
-  return (
-    isObj(v) &&
-    hasProp(v, '$type') &&
-    v.$type === 'temp.dm.defs#logCreateMessage'
-  )
-}
-
-export function validateLogCreateMessage(v: unknown): ValidationResult {
-  return {
-    success: true,
-    value: v,
-  }
-}
-
-export interface LogDeleteMessage {
-  rev: string
-  chatId: string
-  message: MessageView | DeletedMessage | {$type: string; [k: string]: unknown}
-  [k: string]: unknown
-}
-
-export function isLogDeleteMessage(v: unknown): v is LogDeleteMessage {
-  return (
-    isObj(v) &&
-    hasProp(v, '$type') &&
-    v.$type === 'temp.dm.defs#logDeleteMessage'
-  )
-}
-
-export function validateLogDeleteMessage(v: unknown): ValidationResult {
-  return {
-    success: true,
-    value: v,
-  }
-}
-
-export function isObj(v: unknown): v is Record<string, unknown> {
-  return typeof v === 'object' && v !== null
-}
-
-export function hasProp<K extends PropertyKey>(
-  data: object,
-  prop: K,
-): data is Record<K, unknown> {
-  return prop in data
-}
diff --git a/src/temp/dm/deleteMessage.ts b/src/temp/dm/deleteMessage.ts
deleted file mode 100644
index d9fa1f9cf..000000000
--- a/src/temp/dm/deleteMessage.ts
+++ /dev/null
@@ -1,31 +0,0 @@
-import {Headers, XRPCError} from '@atproto/xrpc'
-
-import * as TempDmDefs from './defs'
-
-export interface QueryParams {}
-
-export interface InputSchema {
-  chatId: string
-  messageId: string
-  [k: string]: unknown
-}
-
-export type OutputSchema = TempDmDefs.DeletedMessage
-
-export interface CallOptions {
-  headers?: Headers
-  qp?: QueryParams
-  encoding: 'application/json'
-}
-
-export interface Response {
-  success: boolean
-  headers: Headers
-  data: OutputSchema
-}
-
-export function toKnownErr(e: any) {
-  if (e instanceof XRPCError) {
-  }
-  return e
-}
diff --git a/src/temp/dm/getChat.ts b/src/temp/dm/getChat.ts
deleted file mode 100644
index d0a7b891c..000000000
--- a/src/temp/dm/getChat.ts
+++ /dev/null
@@ -1,30 +0,0 @@
-import {Headers, XRPCError} from '@atproto/xrpc'
-
-import * as TempDmDefs from './defs'
-
-export interface QueryParams {
-  chatId: string
-}
-
-export type InputSchema = undefined
-
-export interface OutputSchema {
-  chat: TempDmDefs.ChatView
-  [k: string]: unknown
-}
-
-export interface CallOptions {
-  headers?: Headers
-}
-
-export interface Response {
-  success: boolean
-  headers: Headers
-  data: OutputSchema
-}
-
-export function toKnownErr(e: any) {
-  if (e instanceof XRPCError) {
-  }
-  return e
-}
diff --git a/src/temp/dm/getChatForMembers.ts b/src/temp/dm/getChatForMembers.ts
deleted file mode 100644
index 0c9962c8b..000000000
--- a/src/temp/dm/getChatForMembers.ts
+++ /dev/null
@@ -1,30 +0,0 @@
-import {Headers, XRPCError} from '@atproto/xrpc'
-
-import * as TempDmDefs from './defs'
-
-export interface QueryParams {
-  members: string[]
-}
-
-export type InputSchema = undefined
-
-export interface OutputSchema {
-  chat: TempDmDefs.ChatView
-  [k: string]: unknown
-}
-
-export interface CallOptions {
-  headers?: Headers
-}
-
-export interface Response {
-  success: boolean
-  headers: Headers
-  data: OutputSchema
-}
-
-export function toKnownErr(e: any) {
-  if (e instanceof XRPCError) {
-  }
-  return e
-}
diff --git a/src/temp/dm/getChatLog.ts b/src/temp/dm/getChatLog.ts
deleted file mode 100644
index 9d310d908..000000000
--- a/src/temp/dm/getChatLog.ts
+++ /dev/null
@@ -1,36 +0,0 @@
-import {Headers, XRPCError} from '@atproto/xrpc'
-
-import * as TempDmDefs from './defs'
-
-export interface QueryParams {
-  cursor?: string
-}
-
-export type InputSchema = undefined
-
-export interface OutputSchema {
-  cursor?: string
-  logs: (
-    | TempDmDefs.LogBeginChat
-    | TempDmDefs.LogCreateMessage
-    | TempDmDefs.LogDeleteMessage
-    | {$type: string; [k: string]: unknown}
-  )[]
-  [k: string]: unknown
-}
-
-export interface CallOptions {
-  headers?: Headers
-}
-
-export interface Response {
-  success: boolean
-  headers: Headers
-  data: OutputSchema
-}
-
-export function toKnownErr(e: any) {
-  if (e instanceof XRPCError) {
-  }
-  return e
-}
diff --git a/src/temp/dm/getChatMessages.ts b/src/temp/dm/getChatMessages.ts
deleted file mode 100644
index 54ae21910..000000000
--- a/src/temp/dm/getChatMessages.ts
+++ /dev/null
@@ -1,37 +0,0 @@
-import {Headers, XRPCError} from '@atproto/xrpc'
-
-import * as TempDmDefs from './defs'
-
-export interface QueryParams {
-  chatId: string
-  limit?: number
-  cursor?: string
-}
-
-export type InputSchema = undefined
-
-export interface OutputSchema {
-  cursor?: string
-  messages: (
-    | TempDmDefs.MessageView
-    | TempDmDefs.DeletedMessage
-    | {$type: string; [k: string]: unknown}
-  )[]
-  [k: string]: unknown
-}
-
-export interface CallOptions {
-  headers?: Headers
-}
-
-export interface Response {
-  success: boolean
-  headers: Headers
-  data: OutputSchema
-}
-
-export function toKnownErr(e: any) {
-  if (e instanceof XRPCError) {
-  }
-  return e
-}
diff --git a/src/temp/dm/getUserSettings.ts b/src/temp/dm/getUserSettings.ts
deleted file mode 100644
index 792c697b4..000000000
--- a/src/temp/dm/getUserSettings.ts
+++ /dev/null
@@ -1,28 +0,0 @@
-import {Headers, XRPCError} from '@atproto/xrpc'
-
-import * as TempDmDefs from './defs'
-
-export interface QueryParams {}
-
-export type InputSchema = undefined
-
-export interface OutputSchema {
-  allowIncoming: TempDmDefs.IncomingMessageSetting
-  [k: string]: unknown
-}
-
-export interface CallOptions {
-  headers?: Headers
-}
-
-export interface Response {
-  success: boolean
-  headers: Headers
-  data: OutputSchema
-}
-
-export function toKnownErr(e: any) {
-  if (e instanceof XRPCError) {
-  }
-  return e
-}
diff --git a/src/temp/dm/leaveChat.ts b/src/temp/dm/leaveChat.ts
deleted file mode 100644
index e116f2775..000000000
--- a/src/temp/dm/leaveChat.ts
+++ /dev/null
@@ -1,30 +0,0 @@
-import {Headers, XRPCError} from '@atproto/xrpc'
-
-export interface QueryParams {}
-
-export interface InputSchema {
-  chatId: string
-  [k: string]: unknown
-}
-
-export interface OutputSchema {
-  [k: string]: unknown
-}
-
-export interface CallOptions {
-  headers?: Headers
-  qp?: QueryParams
-  encoding: 'application/json'
-}
-
-export interface Response {
-  success: boolean
-  headers: Headers
-  data: OutputSchema
-}
-
-export function toKnownErr(e: any) {
-  if (e instanceof XRPCError) {
-  }
-  return e
-}
diff --git a/src/temp/dm/listChats.ts b/src/temp/dm/listChats.ts
deleted file mode 100644
index 0f9cb0c6a..000000000
--- a/src/temp/dm/listChats.ts
+++ /dev/null
@@ -1,32 +0,0 @@
-import {Headers, XRPCError} from '@atproto/xrpc'
-
-import * as TempDmDefs from './defs'
-
-export interface QueryParams {
-  limit?: number
-  cursor?: string
-}
-
-export type InputSchema = undefined
-
-export interface OutputSchema {
-  cursor?: string
-  chats: TempDmDefs.ChatView[]
-  [k: string]: unknown
-}
-
-export interface CallOptions {
-  headers?: Headers
-}
-
-export interface Response {
-  success: boolean
-  headers: Headers
-  data: OutputSchema
-}
-
-export function toKnownErr(e: any) {
-  if (e instanceof XRPCError) {
-  }
-  return e
-}
diff --git a/src/temp/dm/muteChat.ts b/src/temp/dm/muteChat.ts
deleted file mode 100644
index e116f2775..000000000
--- a/src/temp/dm/muteChat.ts
+++ /dev/null
@@ -1,30 +0,0 @@
-import {Headers, XRPCError} from '@atproto/xrpc'
-
-export interface QueryParams {}
-
-export interface InputSchema {
-  chatId: string
-  [k: string]: unknown
-}
-
-export interface OutputSchema {
-  [k: string]: unknown
-}
-
-export interface CallOptions {
-  headers?: Headers
-  qp?: QueryParams
-  encoding: 'application/json'
-}
-
-export interface Response {
-  success: boolean
-  headers: Headers
-  data: OutputSchema
-}
-
-export function toKnownErr(e: any) {
-  if (e instanceof XRPCError) {
-  }
-  return e
-}
diff --git a/src/temp/dm/sendMessage.ts b/src/temp/dm/sendMessage.ts
deleted file mode 100644
index 24a4cf733..000000000
--- a/src/temp/dm/sendMessage.ts
+++ /dev/null
@@ -1,31 +0,0 @@
-import {Headers, XRPCError} from '@atproto/xrpc'
-
-import * as TempDmDefs from './defs'
-
-export interface QueryParams {}
-
-export interface InputSchema {
-  chatId: string
-  message: TempDmDefs.Message
-  [k: string]: unknown
-}
-
-export type OutputSchema = TempDmDefs.MessageView
-
-export interface CallOptions {
-  headers?: Headers
-  qp?: QueryParams
-  encoding: 'application/json'
-}
-
-export interface Response {
-  success: boolean
-  headers: Headers
-  data: OutputSchema
-}
-
-export function toKnownErr(e: any) {
-  if (e instanceof XRPCError) {
-  }
-  return e
-}
diff --git a/src/temp/dm/sendMessageBatch.ts b/src/temp/dm/sendMessageBatch.ts
deleted file mode 100644
index c2ce1d82c..000000000
--- a/src/temp/dm/sendMessageBatch.ts
+++ /dev/null
@@ -1,66 +0,0 @@
-import {ValidationResult} from '@atproto/lexicon'
-import {Headers, XRPCError} from '@atproto/xrpc'
-
-import * as TempDmDefs from './defs'
-
-export interface QueryParams {}
-
-export interface InputSchema {
-  items: BatchItem[]
-  [k: string]: unknown
-}
-
-export interface OutputSchema {
-  items: TempDmDefs.MessageView[]
-  [k: string]: unknown
-}
-
-export interface CallOptions {
-  headers?: Headers
-  qp?: QueryParams
-  encoding: 'application/json'
-}
-
-export interface Response {
-  success: boolean
-  headers: Headers
-  data: OutputSchema
-}
-
-export function toKnownErr(e: any) {
-  if (e instanceof XRPCError) {
-  }
-  return e
-}
-
-export interface BatchItem {
-  chatId: string
-  message: TempDmDefs.Message
-  [k: string]: unknown
-}
-
-export function isBatchItem(v: unknown): v is BatchItem {
-  return (
-    isObj(v) &&
-    hasProp(v, '$type') &&
-    v.$type === 'temp.dm.sendMessageBatch#batchItem'
-  )
-}
-
-export function validateBatchItem(v: unknown): ValidationResult {
-  return {
-    success: true,
-    value: v,
-  }
-}
-
-export function isObj(v: unknown): v is Record<string, unknown> {
-  return typeof v === 'object' && v !== null
-}
-
-export function hasProp<K extends PropertyKey>(
-  data: object,
-  prop: K,
-): data is Record<K, unknown> {
-  return prop in data
-}
diff --git a/src/temp/dm/unmuteChat.ts b/src/temp/dm/unmuteChat.ts
deleted file mode 100644
index e116f2775..000000000
--- a/src/temp/dm/unmuteChat.ts
+++ /dev/null
@@ -1,30 +0,0 @@
-import {Headers, XRPCError} from '@atproto/xrpc'
-
-export interface QueryParams {}
-
-export interface InputSchema {
-  chatId: string
-  [k: string]: unknown
-}
-
-export interface OutputSchema {
-  [k: string]: unknown
-}
-
-export interface CallOptions {
-  headers?: Headers
-  qp?: QueryParams
-  encoding: 'application/json'
-}
-
-export interface Response {
-  success: boolean
-  headers: Headers
-  data: OutputSchema
-}
-
-export function toKnownErr(e: any) {
-  if (e instanceof XRPCError) {
-  }
-  return e
-}
diff --git a/src/temp/dm/updateChatRead.ts b/src/temp/dm/updateChatRead.ts
deleted file mode 100644
index 7eec7e4ac..000000000
--- a/src/temp/dm/updateChatRead.ts
+++ /dev/null
@@ -1,31 +0,0 @@
-import {Headers, XRPCError} from '@atproto/xrpc'
-
-import * as TempDmDefs from './defs'
-
-export interface QueryParams {}
-
-export interface InputSchema {
-  chatId: string
-  messageId?: string
-  [k: string]: unknown
-}
-
-export type OutputSchema = TempDmDefs.ChatView
-
-export interface CallOptions {
-  headers?: Headers
-  qp?: QueryParams
-  encoding: 'application/json'
-}
-
-export interface Response {
-  success: boolean
-  headers: Headers
-  data: OutputSchema
-}
-
-export function toKnownErr(e: any) {
-  if (e instanceof XRPCError) {
-  }
-  return e
-}
diff --git a/src/temp/dm/updateUserSettings.ts b/src/temp/dm/updateUserSettings.ts
deleted file mode 100644
index f88122f5a..000000000
--- a/src/temp/dm/updateUserSettings.ts
+++ /dev/null
@@ -1,33 +0,0 @@
-import {Headers, XRPCError} from '@atproto/xrpc'
-
-import * as TempDmDefs from './defs'
-
-export interface QueryParams {}
-
-export interface InputSchema {
-  allowIncoming?: TempDmDefs.IncomingMessageSetting
-  [k: string]: unknown
-}
-
-export interface OutputSchema {
-  allowIncoming: TempDmDefs.IncomingMessageSetting
-  [k: string]: unknown
-}
-
-export interface CallOptions {
-  headers?: Headers
-  qp?: QueryParams
-  encoding: 'application/json'
-}
-
-export interface Response {
-  success: boolean
-  headers: Headers
-  data: OutputSchema
-}
-
-export function toKnownErr(e: any) {
-  if (e instanceof XRPCError) {
-  }
-  return e
-}
diff --git a/yarn.lock b/yarn.lock
index 5d62929d4..8c014e28e 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -34,6 +34,18 @@
     jsonpointer "^5.0.0"
     leven "^3.1.0"
 
+"@atproto-labs/api@^0.12.8-clipclops.0":
+  version "0.12.8-clipclops.0"
+  resolved "https://registry.yarnpkg.com/@atproto-labs/api/-/api-0.12.8-clipclops.0.tgz#1c5d41d3396e439a0b645f7e1ccf500cc4b42580"
+  integrity sha512-YYDtWWk6BR+aRBVja/1v+gceNK81lkmF5bi6O4pTmJhFt/321XATx/ql8uTWta4VnVThoFeNPG6nLr7hs8b9cA==
+  dependencies:
+    "@atproto/common-web" "^0.3.0"
+    "@atproto/lexicon" "^0.4.0"
+    "@atproto/syntax" "^0.3.0"
+    "@atproto/xrpc" "^0.5.0"
+    multiformats "^9.9.0"
+    tlds "^1.234.0"
+
 "@atproto/api@^0.12.3":
   version "0.12.3"
   resolved "https://registry.yarnpkg.com/@atproto/api/-/api-0.12.3.tgz#5b7b1c7d4210ee9315961504900c8409395cbb17"