about summary refs log tree commit diff
diff options
context:
space:
mode:
authorSamuel Newman <mozzius@protonmail.com>2025-02-03 14:37:24 -0800
committerGitHub <noreply@github.com>2025-02-03 22:37:24 +0000
commit32b28d666229ac24cf7b1ac328d1566fb089e1a1 (patch)
tree2e721117c9a859ca1cae52e1c15642d5e6db4d5b
parentfa8607b861e0719d76778aa14af0745313640e33 (diff)
downloadvoidsky-32b28d666229ac24cf7b1ac328d1566fb089e1a1.tar.zst
Fix convo header loading state (#7603)
* get initial convo state from cache

* undo useConvoQuery changes

* fix shadowing situation with new hook
-rw-r--r--src/components/dms/ConvoMenu.tsx13
-rw-r--r--src/components/dms/MessagesListBlockedFooter.tsx21
-rw-r--r--src/components/dms/MessagesListHeader.tsx26
-rw-r--r--src/screens/Messages/ChatList.tsx1
-rw-r--r--src/screens/Messages/Conversation.tsx59
-rw-r--r--src/screens/Messages/components/ChatListItem.tsx12
-rw-r--r--src/state/cache/profile-shadow.ts38
-rw-r--r--src/state/messages/convo/agent.ts32
-rw-r--r--src/state/messages/convo/index.tsx25
-rw-r--r--src/state/messages/convo/types.ts15
-rw-r--r--src/state/queries/messages/conversation.ts16
11 files changed, 174 insertions, 84 deletions
diff --git a/src/components/dms/ConvoMenu.tsx b/src/components/dms/ConvoMenu.tsx
index 5b4b68149..29b6aeab1 100644
--- a/src/components/dms/ConvoMenu.tsx
+++ b/src/components/dms/ConvoMenu.tsx
@@ -73,13 +73,14 @@ let ConvoMenu = ({
   const isBlocking = userBlock || !!listBlocks.length
   const isDeletedAccount = profile.handle === 'missing.invalid'
 
+  const convoId = initialConvo.id
   const {data: convo} = useConvoQuery(initialConvo)
 
   const onNavigateToProfile = useCallback(() => {
     navigation.navigate('Profile', {name: profile.did})
   }, [navigation, profile.did])
 
-  const {mutate: muteConvo} = useMuteConvo(convo?.id, {
+  const {mutate: muteConvo} = useMuteConvo(convoId, {
     onSuccess: data => {
       if (data.convo.muted) {
         Toast.show(_(msg`Chat muted`))
@@ -152,11 +153,7 @@ let ConvoMenu = ({
               {showMarkAsRead && (
                 <Menu.Item
                   label={_(msg`Mark as read`)}
-                  onPress={() =>
-                    markAsRead({
-                      convoId: convo?.id,
-                    })
-                  }>
+                  onPress={() => markAsRead({convoId})}>
                   <Menu.ItemText>
                     <Trans>Mark as read</Trans>
                   </Menu.ItemText>
@@ -222,7 +219,7 @@ let ConvoMenu = ({
 
       <LeaveConvoPrompt
         control={leaveConvoControl}
-        convoId={convo.id}
+        convoId={convoId}
         currentScreen={currentScreen}
       />
       {latestReportableMessage ? (
@@ -230,7 +227,7 @@ let ConvoMenu = ({
           currentScreen={currentScreen}
           params={{
             type: 'convoMessage',
-            convoId: convo.id,
+            convoId: convoId,
             message: latestReportableMessage,
           }}
           control={reportControl}
diff --git a/src/components/dms/MessagesListBlockedFooter.tsx b/src/components/dms/MessagesListBlockedFooter.tsx
index ec7ba2855..19a7cc9c2 100644
--- a/src/components/dms/MessagesListBlockedFooter.tsx
+++ b/src/components/dms/MessagesListBlockedFooter.tsx
@@ -1,6 +1,6 @@
 import React from 'react'
 import {View} from 'react-native'
-import {AppBskyActorDefs, ModerationCause} from '@atproto/api'
+import {AppBskyActorDefs, ModerationDecision} from '@atproto/api'
 import {msg, Trans} from '@lingui/macro'
 import {useLingui} from '@lingui/react'
 
@@ -19,15 +19,12 @@ export function MessagesListBlockedFooter({
   recipient: initialRecipient,
   convoId,
   hasMessages,
-  blockInfo,
+  moderation,
 }: {
   recipient: AppBskyActorDefs.ProfileViewBasic
   convoId: string
   hasMessages: boolean
-  blockInfo: {
-    listBlocks: ModerationCause[]
-    userBlock: ModerationCause | undefined
-  }
+  moderation: ModerationDecision
 }) {
   const t = useTheme()
   const {gtMobile} = useBreakpoints()
@@ -39,7 +36,17 @@ export function MessagesListBlockedFooter({
   const reportControl = useDialogControl()
   const blockedByListControl = useDialogControl()
 
-  const {listBlocks, userBlock} = blockInfo
+  const {listBlocks, userBlock} = React.useMemo(() => {
+    const modui = moderation.ui('profileView')
+    const blocks = modui.alerts.filter(alert => alert.type === 'blocking')
+    const listBlocks = blocks.filter(alert => alert.source.type === 'list')
+    const userBlock = blocks.find(alert => alert.source.type === 'user')
+    return {
+      listBlocks,
+      userBlock,
+    }
+  }, [moderation])
+
   const isBlocking = !!userBlock || !!listBlocks.length
 
   const onUnblockPress = React.useCallback(() => {
diff --git a/src/components/dms/MessagesListHeader.tsx b/src/components/dms/MessagesListHeader.tsx
index 6ac64a712..f8d9b290d 100644
--- a/src/components/dms/MessagesListHeader.tsx
+++ b/src/components/dms/MessagesListHeader.tsx
@@ -15,7 +15,7 @@ import {makeProfileLink} from '#/lib/routes/links'
 import {NavigationProp} from '#/lib/routes/types'
 import {sanitizeDisplayName} from '#/lib/strings/display-names'
 import {isWeb} from '#/platform/detection'
-import {useProfileShadow} from '#/state/cache/profile-shadow'
+import {Shadow} from '#/state/cache/profile-shadow'
 import {isConvoActive, useConvo} from '#/state/messages/convo'
 import {PreviewableUserAvatar} from '#/view/com/util/UserAvatar'
 import {atoms as a, useBreakpoints, useTheme, web} from '#/alf'
@@ -30,20 +30,27 @@ const PFP_SIZE = isWeb ? 40 : 34
 export let MessagesListHeader = ({
   profile,
   moderation,
-  blockInfo,
 }: {
-  profile?: AppBskyActorDefs.ProfileViewBasic
+  profile?: Shadow<AppBskyActorDefs.ProfileViewBasic>
   moderation?: ModerationDecision
-  blockInfo?: {
-    listBlocks: ModerationCause[]
-    userBlock?: ModerationCause
-  }
 }): React.ReactNode => {
   const t = useTheme()
   const {_} = useLingui()
   const {gtTablet} = useBreakpoints()
   const navigation = useNavigation<NavigationProp>()
 
+  const blockInfo = React.useMemo(() => {
+    if (!moderation) return
+    const modui = moderation.ui('profileView')
+    const blocks = modui.alerts.filter(alert => alert.type === 'blocking')
+    const listBlocks = blocks.filter(alert => alert.source.type === 'list')
+    const userBlock = blocks.find(alert => alert.source.type === 'user')
+    return {
+      listBlocks,
+      userBlock,
+    }
+  }, [moderation])
+
   const onPressBack = useCallback(() => {
     if (isWeb) {
       navigation.replace('Messages', {})
@@ -127,11 +134,11 @@ export let MessagesListHeader = ({
 MessagesListHeader = React.memo(MessagesListHeader)
 
 function HeaderReady({
-  profile: profileUnshadowed,
+  profile,
   moderation,
   blockInfo,
 }: {
-  profile: AppBskyActorDefs.ProfileViewBasic
+  profile: Shadow<AppBskyActorDefs.ProfileViewBasic>
   moderation: ModerationDecision
   blockInfo: {
     listBlocks: ModerationCause[]
@@ -141,7 +148,6 @@ function HeaderReady({
   const {_} = useLingui()
   const t = useTheme()
   const convoState = useConvo()
-  const profile = useProfileShadow(profileUnshadowed)
 
   const isDeletedAccount = profile?.handle === 'missing.invalid'
   const displayName = isDeletedAccount
diff --git a/src/screens/Messages/ChatList.tsx b/src/screens/Messages/ChatList.tsx
index 178e94dd4..9647d6902 100644
--- a/src/screens/Messages/ChatList.tsx
+++ b/src/screens/Messages/ChatList.tsx
@@ -236,7 +236,6 @@ export function MessagesScreen({navigation, route}: Props) {
         onEndReachedThreshold={isNative ? 1.5 : 0}
         initialNumToRender={initialNumToRender}
         windowSize={11}
-        // @ts-ignore our .web version only -sfn
         desktopFixedHeight
         sideBorders={false}
       />
diff --git a/src/screens/Messages/Conversation.tsx b/src/screens/Messages/Conversation.tsx
index b8b0bfe0d..f51822952 100644
--- a/src/screens/Messages/Conversation.tsx
+++ b/src/screens/Messages/Conversation.tsx
@@ -1,6 +1,10 @@
 import React, {useCallback} from 'react'
 import {View} from 'react-native'
-import {AppBskyActorDefs, moderateProfile, ModerationOpts} from '@atproto/api'
+import {
+  AppBskyActorDefs,
+  moderateProfile,
+  ModerationDecision,
+} from '@atproto/api'
 import {msg} from '@lingui/macro'
 import {useLingui} from '@lingui/react'
 import {useFocusEffect, useNavigation} from '@react-navigation/native'
@@ -10,7 +14,7 @@ import {useEmail} from '#/lib/hooks/useEmail'
 import {useEnableKeyboardControllerScreen} from '#/lib/hooks/useEnableKeyboardController'
 import {CommonNavigatorParams, NavigationProp} from '#/lib/routes/types'
 import {isWeb} from '#/platform/detection'
-import {useProfileShadow} from '#/state/cache/profile-shadow'
+import {Shadow, useMaybeProfileShadow} from '#/state/cache/profile-shadow'
 import {ConvoProvider, isConvoActive, useConvo} from '#/state/messages/convo'
 import {ConvoStatus} from '#/state/messages/convo/types'
 import {useCurrentConvoId} from '#/state/messages/current-convo-id'
@@ -72,9 +76,15 @@ function Inner() {
   const {_} = useLingui()
 
   const moderationOpts = useModerationOpts()
-  const {data: recipient} = useProfileQuery({
+  const {data: recipientUnshadowed} = useProfileQuery({
     did: convoState.recipients?.[0].did,
   })
+  const recipient = useMaybeProfileShadow(recipientUnshadowed)
+
+  const moderation = React.useMemo(() => {
+    if (!recipient || !moderationOpts) return null
+    return moderateProfile(recipient, moderationOpts)
+  }, [recipient, moderationOpts])
 
   // Because we want to give the list a chance to asynchronously scroll to the end before it is visible to the user,
   // we use `hasScrolled` to determine when to render. With that said however, there is a chance that the chat will be
@@ -110,11 +120,16 @@ function Inner() {
 
   return (
     <Layout.Center style={[a.flex_1]}>
-      {!readyToShow && <MessagesListHeader />}
+      {!readyToShow &&
+        (moderation ? (
+          <MessagesListHeader moderation={moderation} profile={recipient} />
+        ) : (
+          <MessagesListHeader />
+        ))}
       <View style={[a.flex_1]}>
-        {moderationOpts && recipient ? (
+        {moderation && recipient ? (
           <InnerReady
-            moderationOpts={moderationOpts}
+            moderation={moderation}
             recipient={recipient}
             hasScrolled={hasScrolled}
             setHasScrolled={setHasScrolled}
@@ -144,38 +159,22 @@ function Inner() {
 }
 
 function InnerReady({
-  moderationOpts,
-  recipient: recipientUnshadowed,
+  moderation,
+  recipient,
   hasScrolled,
   setHasScrolled,
 }: {
-  moderationOpts: ModerationOpts
-  recipient: AppBskyActorDefs.ProfileViewBasic
+  moderation: ModerationDecision
+  recipient: Shadow<AppBskyActorDefs.ProfileViewBasic>
   hasScrolled: boolean
   setHasScrolled: React.Dispatch<React.SetStateAction<boolean>>
 }) {
   const {_} = useLingui()
   const convoState = useConvo()
   const navigation = useNavigation<NavigationProp>()
-  const recipient = useProfileShadow(recipientUnshadowed)
   const verifyEmailControl = useDialogControl()
   const {needsEmailVerification} = useEmail()
 
-  const moderation = React.useMemo(() => {
-    return moderateProfile(recipient, moderationOpts)
-  }, [recipient, moderationOpts])
-
-  const blockInfo = React.useMemo(() => {
-    const modui = moderation.ui('profileView')
-    const blocks = modui.alerts.filter(alert => alert.type === 'blocking')
-    const listBlocks = blocks.filter(alert => alert.source.type === 'list')
-    const userBlock = blocks.find(alert => alert.source.type === 'user')
-    return {
-      listBlocks,
-      userBlock,
-    }
-  }, [moderation])
-
   React.useEffect(() => {
     if (needsEmailVerification) {
       verifyEmailControl.open()
@@ -184,11 +183,7 @@ function InnerReady({
 
   return (
     <>
-      <MessagesListHeader
-        profile={recipient}
-        moderation={moderation}
-        blockInfo={blockInfo}
-      />
+      <MessagesListHeader profile={recipient} moderation={moderation} />
       {isConvoActive(convoState) && (
         <MessagesList
           hasScrolled={hasScrolled}
@@ -199,7 +194,7 @@ function InnerReady({
               recipient={recipient}
               convoId={convoState.convo.id}
               hasMessages={convoState.items.length > 0}
-              blockInfo={blockInfo}
+              moderation={moderation}
             />
           }
         />
diff --git a/src/screens/Messages/components/ChatListItem.tsx b/src/screens/Messages/components/ChatListItem.tsx
index 11aada71b..a64e9e549 100644
--- a/src/screens/Messages/components/ChatListItem.tsx
+++ b/src/screens/Messages/components/ChatListItem.tsx
@@ -9,6 +9,7 @@ import {
 } from '@atproto/api'
 import {msg} from '@lingui/macro'
 import {useLingui} from '@lingui/react'
+import {useQueryClient} from '@tanstack/react-query'
 
 import {GestureActionView} from '#/lib/custom-animations/GestureActionView'
 import {useHaptics} from '#/lib/haptics'
@@ -23,7 +24,11 @@ import {
 import {isNative} from '#/platform/detection'
 import {useProfileShadow} from '#/state/cache/profile-shadow'
 import {useModerationOpts} from '#/state/preferences/moderation-opts'
-import {useMarkAsReadMutation} from '#/state/queries/messages/conversation'
+import {
+  precacheConvoQuery,
+  useMarkAsReadMutation,
+} from '#/state/queries/messages/conversation'
+import {precacheProfile} from '#/state/queries/profile'
 import {useSession} from '#/state/session'
 import {TimeElapsed} from '#/view/com/util/TimeElapsed'
 import {PreviewableUserAvatar} from '#/view/com/util/UserAvatar'
@@ -89,6 +94,7 @@ function ChatListItemReady({
     [profile, moderationOpts],
   )
   const playHaptic = useHaptics()
+  const queryClient = useQueryClient()
   const isUnread = convo.unreadCount > 0
 
   const blockInfo = useMemo(() => {
@@ -198,6 +204,8 @@ function ChatListItemReady({
 
   const onPress = useCallback(
     (e: GestureResponderEvent) => {
+      precacheProfile(queryClient, profile)
+      precacheConvoQuery(queryClient, convo)
       decrementBadgeCount(convo.unreadCount)
       if (isDeletedAccount) {
         e.preventDefault()
@@ -207,7 +215,7 @@ function ChatListItemReady({
         logEvent('chat:open', {logContext: 'ChatsList'})
       }
     },
-    [convo.unreadCount, isDeletedAccount, menuControl],
+    [isDeletedAccount, menuControl, queryClient, profile, convo],
   )
 
   const onLongPress = useCallback(() => {
diff --git a/src/state/cache/profile-shadow.ts b/src/state/cache/profile-shadow.ts
index afd3f1935..4d823ec8e 100644
--- a/src/state/cache/profile-shadow.ts
+++ b/src/state/cache/profile-shadow.ts
@@ -63,6 +63,44 @@ export function useProfileShadow<
   }, [profile, shadow])
 }
 
+/**
+ * Same as useProfileShadow, but allows for the profile to be undefined.
+ * This is useful for when the profile is not guaranteed to be loaded yet.
+ */
+export function useMaybeProfileShadow<
+  TProfileView extends AppBskyActorDefs.ProfileView,
+>(profile?: TProfileView): Shadow<TProfileView> | undefined {
+  const [shadow, setShadow] = useState(() =>
+    profile ? shadows.get(profile) : undefined,
+  )
+  const [prevPost, setPrevPost] = useState(profile)
+  if (profile !== prevPost) {
+    setPrevPost(profile)
+    setShadow(profile ? shadows.get(profile) : undefined)
+  }
+
+  useEffect(() => {
+    if (!profile) return
+    function onUpdate() {
+      if (!profile) return
+      setShadow(shadows.get(profile))
+    }
+    emitter.addListener(profile.did, onUpdate)
+    return () => {
+      emitter.removeListener(profile.did, onUpdate)
+    }
+  }, [profile])
+
+  return useMemo(() => {
+    if (!profile) return undefined
+    if (shadow) {
+      return mergeShadow(profile, shadow)
+    } else {
+      return castAsShadow(profile)
+    }
+  }, [profile, shadow])
+}
+
 export function updateProfileShadow(
   queryClient: QueryClient,
   did: string,
diff --git a/src/state/messages/convo/agent.ts b/src/state/messages/convo/agent.ts
index 53d77046a..91dd59813 100644
--- a/src/state/messages/convo/agent.ts
+++ b/src/state/messages/convo/agent.ts
@@ -81,7 +81,7 @@ export class Convo {
   convoId: string
   convo: ChatBskyConvoDefs.ConvoView | undefined
   sender: AppBskyActorDefs.ProfileViewBasic | undefined
-  recipients: AppBskyActorDefs.ProfileViewBasic[] | undefined = undefined
+  recipients: AppBskyActorDefs.ProfileViewBasic[] | undefined
   snapshot: ConvoState | undefined
 
   constructor(params: ConvoParams) {
@@ -91,6 +91,10 @@ export class Convo {
     this.events = params.events
     this.senderUserDid = params.agent.session?.did!
 
+    if (params.placeholderData) {
+      this.setupPlaceholderData(params.placeholderData)
+    }
+
     this.subscribe = this.subscribe.bind(this)
     this.getSnapshot = this.getSnapshot.bind(this)
     this.sendMessage = this.sendMessage.bind(this)
@@ -131,10 +135,10 @@ export class Convo {
         return {
           status: ConvoStatus.Initializing,
           items: [],
-          convo: undefined,
+          convo: this.convo,
           error: undefined,
-          sender: undefined,
-          recipients: undefined,
+          sender: this.sender,
+          recipients: this.recipients,
           isFetchingHistory: this.isFetchingHistory,
           deleteMessage: undefined,
           sendMessage: undefined,
@@ -176,10 +180,10 @@ export class Convo {
         return {
           status: ConvoStatus.Uninitialized,
           items: [],
-          convo: undefined,
+          convo: this.convo,
           error: undefined,
-          sender: undefined,
-          recipients: undefined,
+          sender: this.sender,
+          recipients: this.recipients,
           isFetchingHistory: false,
           deleteMessage: undefined,
           sendMessage: undefined,
@@ -424,6 +428,20 @@ export class Convo {
     }
   }
 
+  /**
+   * Initialises the convo with placeholder data, if provided. We still refetch it before rendering the convo,
+   * but this allows us to render the convo header immediately.
+   */
+  private setupPlaceholderData(
+    data: NonNullable<ConvoParams['placeholderData']>,
+  ) {
+    this.convo = data.convo
+    this.sender = data.convo.members.find(m => m.did === this.senderUserDid)
+    this.recipients = data.convo.members.filter(
+      m => m.did !== this.senderUserDid,
+    )
+  }
+
   private async setup() {
     try {
       const {convo, sender, recipients} = await this.fetchConvo()
diff --git a/src/state/messages/convo/index.tsx b/src/state/messages/convo/index.tsx
index 10ec2a348..a1750bdf0 100644
--- a/src/state/messages/convo/index.tsx
+++ b/src/state/messages/convo/index.tsx
@@ -1,4 +1,5 @@
 import React, {useContext, useState, useSyncExternalStore} from 'react'
+import {ChatBskyConvoDefs} from '@atproto/api'
 import {useFocusEffect} from '@react-navigation/native'
 import {useQueryClient} from '@tanstack/react-query'
 
@@ -14,7 +15,10 @@ import {
 } from '#/state/messages/convo/types'
 import {isConvoActive} from '#/state/messages/convo/util'
 import {useMessagesEventBus} from '#/state/messages/events'
-import {useMarkAsReadMutation} from '#/state/queries/messages/conversation'
+import {
+  RQKEY as getConvoKey,
+  useMarkAsReadMutation,
+} from '#/state/queries/messages/conversation'
 import {RQKEY as ListConvosQueryKey} from '#/state/queries/messages/list-conversations'
 import {RQKEY as createProfileQueryKey} from '#/state/queries/profile'
 import {useAgent} from '#/state/session'
@@ -60,14 +64,17 @@ export function ConvoProvider({
   const queryClient = useQueryClient()
   const agent = useAgent()
   const events = useMessagesEventBus()
-  const [convo] = useState(
-    () =>
-      new Convo({
-        convoId,
-        agent,
-        events,
-      }),
-  )
+  const [convo] = useState(() => {
+    const placeholder = queryClient.getQueryData<ChatBskyConvoDefs.ConvoView>(
+      getConvoKey(convoId),
+    )
+    return new Convo({
+      convoId,
+      agent,
+      events,
+      placeholderData: placeholder ? {convo: placeholder} : undefined,
+    })
+  })
   const service = useSyncExternalStore(convo.subscribe, convo.getSnapshot)
   const {mutate: markAsRead} = useMarkAsReadMutation()
 
diff --git a/src/state/messages/convo/types.ts b/src/state/messages/convo/types.ts
index 21772262e..9f1707c71 100644
--- a/src/state/messages/convo/types.ts
+++ b/src/state/messages/convo/types.ts
@@ -11,6 +11,9 @@ export type ConvoParams = {
   convoId: string
   agent: BskyAgent
   events: MessagesEventBus
+  placeholderData?: {
+    convo: ChatBskyConvoDefs.ConvoView
+  }
 }
 
 export enum ConvoStatus {
@@ -142,10 +145,10 @@ type FetchMessageHistory = () => Promise<void>
 export type ConvoStateUninitialized = {
   status: ConvoStatus.Uninitialized
   items: []
-  convo: undefined
+  convo: ChatBskyConvoDefs.ConvoView | undefined
   error: undefined
-  sender: undefined
-  recipients: undefined
+  sender: AppBskyActorDefs.ProfileViewBasic | undefined
+  recipients: AppBskyActorDefs.ProfileViewBasic[] | undefined
   isFetchingHistory: false
   deleteMessage: undefined
   sendMessage: undefined
@@ -154,10 +157,10 @@ export type ConvoStateUninitialized = {
 export type ConvoStateInitializing = {
   status: ConvoStatus.Initializing
   items: []
-  convo: undefined
+  convo: ChatBskyConvoDefs.ConvoView | undefined
   error: undefined
-  sender: undefined
-  recipients: undefined
+  sender: AppBskyActorDefs.ProfileViewBasic | undefined
+  recipients: AppBskyActorDefs.ProfileViewBasic[] | undefined
   isFetchingHistory: boolean
   deleteMessage: undefined
   sendMessage: undefined
diff --git a/src/state/queries/messages/conversation.ts b/src/state/queries/messages/conversation.ts
index 9edde4aaf..260524524 100644
--- a/src/state/queries/messages/conversation.ts
+++ b/src/state/queries/messages/conversation.ts
@@ -1,5 +1,10 @@
 import {ChatBskyConvoDefs} from '@atproto/api'
-import {useMutation, useQuery, useQueryClient} from '@tanstack/react-query'
+import {
+  QueryClient,
+  useMutation,
+  useQuery,
+  useQueryClient,
+} from '@tanstack/react-query'
 
 import {STALE} from '#/state/queries'
 import {DM_SERVICE_HEADERS} from '#/state/queries/messages/const'
@@ -20,7 +25,7 @@ export function useConvoQuery(convo: ChatBskyConvoDefs.ConvoView) {
   return useQuery({
     queryKey: RQKEY(convo.id),
     queryFn: async () => {
-      const {data} = await agent.api.chat.bsky.convo.getConvo(
+      const {data} = await agent.chat.bsky.convo.getConvo(
         {convoId: convo.id},
         {headers: DM_SERVICE_HEADERS},
       )
@@ -31,6 +36,13 @@ export function useConvoQuery(convo: ChatBskyConvoDefs.ConvoView) {
   })
 }
 
+export function precacheConvoQuery(
+  queryClient: QueryClient,
+  convo: ChatBskyConvoDefs.ConvoView,
+) {
+  queryClient.setQueryData(RQKEY(convo.id), convo)
+}
+
 export function useMarkAsReadMutation() {
   const optimisticUpdate = useOnMarkAsRead()
   const queryClient = useQueryClient()