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/convo/agent.ts192
-rw-r--r--src/state/messages/convo/index.tsx14
-rw-r--r--src/state/messages/convo/types.ts26
-rw-r--r--src/state/queries/messages/list-conversations.tsx67
4 files changed, 263 insertions, 36 deletions
diff --git a/src/state/messages/convo/agent.ts b/src/state/messages/convo/agent.ts
index f6a8d6dc4..909213975 100644
--- a/src/state/messages/convo/agent.ts
+++ b/src/state/messages/convo/agent.ts
@@ -1,9 +1,9 @@
 import {
-  BskyAgent,
-  ChatBskyActorDefs,
+  type AtpAgent,
+  type ChatBskyActorDefs,
   ChatBskyConvoDefs,
-  ChatBskyConvoGetLog,
-  ChatBskyConvoSendMessage,
+  type ChatBskyConvoGetLog,
+  type ChatBskyConvoSendMessage,
 } from '@atproto/api'
 import {XRPCError} from '@atproto/xrpc'
 import EventEmitter from 'eventemitter3'
@@ -19,19 +19,19 @@ import {
   NETWORK_FAILURE_STATUSES,
 } from '#/state/messages/convo/const'
 import {
-  ConvoDispatch,
+  type ConvoDispatch,
   ConvoDispatchEvent,
-  ConvoError,
+  type ConvoError,
   ConvoErrorCode,
-  ConvoEvent,
-  ConvoItem,
+  type ConvoEvent,
+  type ConvoItem,
   ConvoItemError,
-  ConvoParams,
-  ConvoState,
+  type ConvoParams,
+  type ConvoState,
   ConvoStatus,
 } from '#/state/messages/convo/types'
-import {MessagesEventBus} from '#/state/messages/events/agent'
-import {MessagesEventBusError} from '#/state/messages/events/types'
+import {type MessagesEventBus} from '#/state/messages/events/agent'
+import {type MessagesEventBusError} from '#/state/messages/events/types'
 import {DM_SERVICE_HEADERS} from '#/state/queries/messages/const'
 
 const logger = Logger.create(Logger.Context.ConversationAgent)
@@ -50,7 +50,7 @@ export function isConvoItemMessage(
 export class Convo {
   private id: string
 
-  private agent: BskyAgent
+  private agent: AtpAgent
   private events: MessagesEventBus
   private senderUserDid: string
 
@@ -106,6 +106,8 @@ export class Convo {
     this.onFirehoseConnect = this.onFirehoseConnect.bind(this)
     this.onFirehoseError = this.onFirehoseError.bind(this)
     this.markConvoAccepted = this.markConvoAccepted.bind(this)
+    this.addReaction = this.addReaction.bind(this)
+    this.removeReaction = this.removeReaction.bind(this)
   }
 
   private commit() {
@@ -147,6 +149,8 @@ export class Convo {
           sendMessage: undefined,
           fetchMessageHistory: undefined,
           markConvoAccepted: undefined,
+          addReaction: undefined,
+          removeReaction: undefined,
         }
       }
       case ConvoStatus.Disabled:
@@ -165,6 +169,8 @@ export class Convo {
           sendMessage: this.sendMessage,
           fetchMessageHistory: this.fetchMessageHistory,
           markConvoAccepted: this.markConvoAccepted,
+          addReaction: this.addReaction,
+          removeReaction: this.removeReaction,
         }
       }
       case ConvoStatus.Error: {
@@ -180,6 +186,8 @@ export class Convo {
           sendMessage: undefined,
           fetchMessageHistory: undefined,
           markConvoAccepted: undefined,
+          addReaction: undefined,
+          removeReaction: undefined,
         }
       }
       default: {
@@ -195,6 +203,8 @@ export class Convo {
           sendMessage: undefined,
           fetchMessageHistory: undefined,
           markConvoAccepted: undefined,
+          addReaction: undefined,
+          removeReaction: undefined,
         }
       }
     }
@@ -760,6 +770,22 @@ export class Convo {
               this.deletedMessages.delete(ev.message.id)
               needsCommit = true
             }
+          } else if (
+            (ChatBskyConvoDefs.isLogAddReaction(ev) ||
+              ChatBskyConvoDefs.isLogRemoveReaction(ev)) &&
+            ChatBskyConvoDefs.isMessageView(ev.message)
+          ) {
+            /*
+             * Update if we have this in state - replace message wholesale. If we don't, don't worry about it.
+             */
+            if (this.pastMessages.has(ev.message.id)) {
+              this.pastMessages.set(ev.message.id, ev.message)
+              needsCommit = true
+            }
+            if (this.newMessages.has(ev.message.id)) {
+              this.newMessages.set(ev.message.id, ev.message)
+              needsCommit = true
+            }
           }
         }
       }
@@ -1141,4 +1167,144 @@ export class Convo {
         return item
       })
   }
+
+  /**
+   * Add an emoji reaction to a message
+   *
+   * @param messageId - the id of the message to add the reaction to
+   * @param emoji - must be one grapheme
+   */
+  async addReaction(messageId: string, emoji: string) {
+    const optimisticReaction = {
+      value: emoji,
+      sender: {did: this.senderUserDid},
+      createdAt: new Date().toISOString(),
+    }
+    let restore: null | (() => void) = null
+    if (this.pastMessages.has(messageId)) {
+      const prevMessage = this.pastMessages.get(messageId)
+      if (
+        ChatBskyConvoDefs.isMessageView(prevMessage) &&
+        // skip optimistic update if reaction already exists
+        !prevMessage.reactions?.find(
+          reaction =>
+            reaction.sender.did === this.senderUserDid &&
+            reaction.value === emoji,
+        )
+      ) {
+        if (prevMessage.reactions) {
+          if (
+            prevMessage.reactions.filter(
+              reaction => reaction.sender.did === this.senderUserDid,
+            ).length >= 5
+          ) {
+            throw new Error('Maximum reactions reached')
+          }
+        }
+        this.pastMessages.set(messageId, {
+          ...prevMessage,
+          reactions: [...(prevMessage.reactions ?? []), optimisticReaction],
+        })
+        this.commit()
+        restore = () => {
+          this.pastMessages.set(messageId, prevMessage)
+          this.commit()
+        }
+      }
+    } else if (this.newMessages.has(messageId)) {
+      const prevMessage = this.newMessages.get(messageId)
+      if (
+        ChatBskyConvoDefs.isMessageView(prevMessage) &&
+        !prevMessage.reactions?.find(reaction => reaction.value === emoji)
+      ) {
+        if (prevMessage.reactions && prevMessage.reactions.length >= 5)
+          throw new Error('Maximum reactions reached')
+        this.newMessages.set(messageId, {
+          ...prevMessage,
+          reactions: [...(prevMessage.reactions ?? []), optimisticReaction],
+        })
+        this.commit()
+        restore = () => {
+          this.newMessages.set(messageId, prevMessage)
+          this.commit()
+        }
+      }
+    }
+
+    try {
+      logger.info(`Adding reaction ${emoji} to message ${messageId}`)
+      const {data} = await this.agent.chat.bsky.convo.addReaction(
+        {messageId, value: emoji, convoId: this.convoId},
+        {encoding: 'application/json', headers: DM_SERVICE_HEADERS},
+      )
+      if (ChatBskyConvoDefs.isMessageView(data.message)) {
+        if (this.pastMessages.has(messageId)) {
+          this.pastMessages.set(messageId, data.message)
+          this.commit()
+        } else if (this.newMessages.has(messageId)) {
+          this.newMessages.set(messageId, data.message)
+          this.commit()
+        }
+      }
+    } catch (error) {
+      if (restore) restore()
+      throw error
+    }
+  }
+
+  /*
+   * Remove a reaction from a message.
+   *
+   * @param messageId - The ID of the message to remove the reaction from.
+   * @param emoji - The emoji to remove.
+   */
+  async removeReaction(messageId: string, emoji: string) {
+    let restore: null | (() => void) = null
+    if (this.pastMessages.has(messageId)) {
+      const prevMessage = this.pastMessages.get(messageId)
+      if (ChatBskyConvoDefs.isMessageView(prevMessage)) {
+        this.pastMessages.set(messageId, {
+          ...prevMessage,
+          reactions: prevMessage.reactions?.filter(
+            reaction =>
+              reaction.value !== emoji ||
+              reaction.sender.did !== this.senderUserDid,
+          ),
+        })
+        this.commit()
+        restore = () => {
+          this.pastMessages.set(messageId, prevMessage)
+          this.commit()
+        }
+      }
+    } else if (this.newMessages.has(messageId)) {
+      const prevMessage = this.newMessages.get(messageId)
+      if (ChatBskyConvoDefs.isMessageView(prevMessage)) {
+        this.newMessages.set(messageId, {
+          ...prevMessage,
+          reactions: prevMessage.reactions?.filter(
+            reaction =>
+              reaction.value !== emoji ||
+              reaction.sender.did !== this.senderUserDid,
+          ),
+        })
+        this.commit()
+        restore = () => {
+          this.newMessages.set(messageId, prevMessage)
+          this.commit()
+        }
+      }
+    }
+
+    try {
+      logger.info(`Removing reaction ${emoji} from message ${messageId}`)
+      await this.agent.chat.bsky.convo.removeReaction(
+        {messageId, value: emoji, convoId: this.convoId},
+        {encoding: 'application/json', headers: DM_SERVICE_HEADERS},
+      )
+    } catch (error) {
+      if (restore) restore()
+      throw error
+    }
+  }
 }
diff --git a/src/state/messages/convo/index.tsx b/src/state/messages/convo/index.tsx
index f004566e8..a53f08900 100644
--- a/src/state/messages/convo/index.tsx
+++ b/src/state/messages/convo/index.tsx
@@ -1,17 +1,17 @@
 import React, {useContext, useState, useSyncExternalStore} from 'react'
-import {ChatBskyConvoDefs} from '@atproto/api'
+import {type ChatBskyConvoDefs} from '@atproto/api'
 import {useFocusEffect} from '@react-navigation/native'
 import {useQueryClient} from '@tanstack/react-query'
 
 import {useAppState} from '#/lib/hooks/useAppState'
 import {Convo} from '#/state/messages/convo/agent'
 import {
-  ConvoParams,
-  ConvoState,
-  ConvoStateBackgrounded,
-  ConvoStateDisabled,
-  ConvoStateReady,
-  ConvoStateSuspended,
+  type ConvoParams,
+  type ConvoState,
+  type ConvoStateBackgrounded,
+  type ConvoStateDisabled,
+  type ConvoStateReady,
+  type ConvoStateSuspended,
 } from '#/state/messages/convo/types'
 import {isConvoActive} from '#/state/messages/convo/util'
 import {useMessagesEventBus} from '#/state/messages/events'
diff --git a/src/state/messages/convo/types.ts b/src/state/messages/convo/types.ts
index 83499de2e..705387793 100644
--- a/src/state/messages/convo/types.ts
+++ b/src/state/messages/convo/types.ts
@@ -1,11 +1,11 @@
 import {
-  BskyAgent,
-  ChatBskyActorDefs,
-  ChatBskyConvoDefs,
-  ChatBskyConvoSendMessage,
+  type BskyAgent,
+  type ChatBskyActorDefs,
+  type ChatBskyConvoDefs,
+  type ChatBskyConvoSendMessage,
 } from '@atproto/api'
 
-import {MessagesEventBus} from '#/state/messages/events/agent'
+import {type MessagesEventBus} from '#/state/messages/events/agent'
 
 export type ConvoParams = {
   convoId: string
@@ -142,6 +142,8 @@ type SendMessage = (
 ) => void
 type FetchMessageHistory = () => Promise<void>
 type MarkConvoAccepted = () => void
+type AddReaction = (messageId: string, reaction: string) => Promise<void>
+type RemoveReaction = (messageId: string, reaction: string) => Promise<void>
 
 export type ConvoStateUninitialized = {
   status: ConvoStatus.Uninitialized
@@ -155,6 +157,8 @@ export type ConvoStateUninitialized = {
   sendMessage: undefined
   fetchMessageHistory: undefined
   markConvoAccepted: undefined
+  addReaction: undefined
+  removeReaction: undefined
 }
 export type ConvoStateInitializing = {
   status: ConvoStatus.Initializing
@@ -168,6 +172,8 @@ export type ConvoStateInitializing = {
   sendMessage: undefined
   fetchMessageHistory: undefined
   markConvoAccepted: undefined
+  addReaction: undefined
+  removeReaction: undefined
 }
 export type ConvoStateReady = {
   status: ConvoStatus.Ready
@@ -181,6 +187,8 @@ export type ConvoStateReady = {
   sendMessage: SendMessage
   fetchMessageHistory: FetchMessageHistory
   markConvoAccepted: MarkConvoAccepted
+  addReaction: AddReaction
+  removeReaction: RemoveReaction
 }
 export type ConvoStateBackgrounded = {
   status: ConvoStatus.Backgrounded
@@ -194,6 +202,8 @@ export type ConvoStateBackgrounded = {
   sendMessage: SendMessage
   fetchMessageHistory: FetchMessageHistory
   markConvoAccepted: MarkConvoAccepted
+  addReaction: AddReaction
+  removeReaction: RemoveReaction
 }
 export type ConvoStateSuspended = {
   status: ConvoStatus.Suspended
@@ -207,6 +217,8 @@ export type ConvoStateSuspended = {
   sendMessage: SendMessage
   fetchMessageHistory: FetchMessageHistory
   markConvoAccepted: MarkConvoAccepted
+  addReaction: AddReaction
+  removeReaction: RemoveReaction
 }
 export type ConvoStateError = {
   status: ConvoStatus.Error
@@ -220,6 +232,8 @@ export type ConvoStateError = {
   sendMessage: undefined
   fetchMessageHistory: undefined
   markConvoAccepted: undefined
+  addReaction: undefined
+  removeReaction: undefined
 }
 export type ConvoStateDisabled = {
   status: ConvoStatus.Disabled
@@ -233,6 +247,8 @@ export type ConvoStateDisabled = {
   sendMessage: SendMessage
   fetchMessageHistory: FetchMessageHistory
   markConvoAccepted: MarkConvoAccepted
+  addReaction: AddReaction
+  removeReaction: RemoveReaction
 }
 export type ConvoState =
   | ConvoStateUninitialized
diff --git a/src/state/queries/messages/list-conversations.tsx b/src/state/queries/messages/list-conversations.tsx
index f5fce6347..066c25e21 100644
--- a/src/state/queries/messages/list-conversations.tsx
+++ b/src/state/queries/messages/list-conversations.tsx
@@ -1,19 +1,13 @@
-import React, {
-  createContext,
-  useCallback,
-  useContext,
-  useEffect,
-  useMemo,
-} from 'react'
+import {createContext, useCallback, useContext, useEffect, useMemo} from 'react'
 import {
   ChatBskyConvoDefs,
-  ChatBskyConvoListConvos,
+  type ChatBskyConvoListConvos,
   moderateProfile,
-  ModerationOpts,
+  type ModerationOpts,
 } from '@atproto/api'
 import {
-  InfiniteData,
-  QueryClient,
+  type InfiniteData,
+  type QueryClient,
   useInfiniteQuery,
   useQueryClient,
 } from '@tanstack/react-query'
@@ -316,6 +310,57 @@ export function ListConvosProviderInner({
                   rev: logRef.rev,
                 })),
             )
+          } else if (ChatBskyConvoDefs.isLogAddReaction(log)) {
+            const logRef: ChatBskyConvoDefs.LogAddReaction = log
+            queryClient.setQueriesData(
+              {queryKey: [RQKEY_ROOT]},
+              (old?: ConvoListQueryData) =>
+                optimisticUpdate(logRef.convoId, old, convo => ({
+                  ...convo,
+                  lastMessage: {
+                    $type: 'chat.bsky.convo.defs#messageAndReactionView',
+                    reaction: logRef.reaction,
+                    message: logRef.message,
+                  },
+                  rev: logRef.rev,
+                })),
+            )
+          } else if (ChatBskyConvoDefs.isLogRemoveReaction(log)) {
+            if (ChatBskyConvoDefs.isMessageView(log.message)) {
+              for (const [_queryKey, queryData] of queryClient.getQueriesData<
+                InfiniteData<ChatBskyConvoListConvos.OutputSchema>
+              >({
+                queryKey: [RQKEY_ROOT],
+              })) {
+                if (!queryData?.pages) {
+                  continue
+                }
+
+                for (const page of queryData.pages) {
+                  for (const convo of page.convos) {
+                    if (
+                      // if the convo is the same
+                      log.convoId === convo.id &&
+                      ChatBskyConvoDefs.isMessageAndReactionView(
+                        convo.lastMessage,
+                      ) &&
+                      ChatBskyConvoDefs.isMessageView(
+                        convo.lastMessage.message,
+                      ) &&
+                      // ...and the message is the same
+                      convo.lastMessage.message.id === log.message.id &&
+                      // ...and the reaction is the same
+                      convo.lastMessage.reaction.sender.did ===
+                        log.reaction.sender.did &&
+                      convo.lastMessage.reaction.value === log.reaction.value
+                    ) {
+                      // refetch, because we don't know what the last message is now
+                      debouncedRefetch()
+                    }
+                  }
+                }
+              }
+            }
           }
         }
       },