diff --git a/README.md b/README.md index b5c24fc9..a666e0d4 100644 --- a/README.md +++ b/README.md @@ -130,7 +130,7 @@ const reaction = await conversation.messages.addReaction(msgId, { Delete reaction: ```ts -await conversation.messages.removeReaction(msgId, type) +await conversation.messages.removeReaction(reactionId) ``` ### Reaction object @@ -292,8 +292,7 @@ conversation.reactions.add(reactionType) Remove reaction ```ts -conversation.reactions.delete(reaction) -conversation.reactions.delete(reactionType) +conversation.reactions.delete(reactionId) ``` ## Typing indicator diff --git a/src/ChatApi.ts b/src/ChatApi.ts index 4353eb54..ee1870fb 100644 --- a/src/ChatApi.ts +++ b/src/ChatApi.ts @@ -24,6 +24,14 @@ export interface UpdateMessageResponse { id: string; } +export interface AddReactionResponse { + id: string; +} + +export interface DeleteReactionResponse { + id: string; +} + /** * Chat SDK Backend */ @@ -109,4 +117,27 @@ export class ChatApi { }); if (!response.ok) throw new ErrorInfo(response.statusText, response.status, 4000); } + + async addMessageReaction(conversationId: string, messageId: string, type: string): Promise { + const response = await fetch(`${this.baseUrl}/v1/conversations/${conversationId}/messages/${messageId}/reactions`, { + method: 'POST', + headers: { + 'ably-clientId': this.clientId, + }, + body: JSON.stringify({ type }), + }); + if (!response.ok) throw new ErrorInfo(response.statusText, response.status, 4000); + return response.json(); + } + + async deleteMessageReaction(reactionId: string): Promise { + const response = await fetch(`${this.baseUrl}/v1/conversations/reactions/${reactionId}`, { + method: 'DELETE', + headers: { + 'ably-clientId': this.clientId, + }, + }); + if (!response.ok) throw new ErrorInfo(response.statusText, response.status, 4000); + return response.json(); + } } diff --git a/src/Messages.test.ts b/src/Messages.test.ts index 913a3c68..aef46a91 100644 --- a/src/Messages.test.ts +++ b/src/Messages.test.ts @@ -183,4 +183,116 @@ describe('Messages', () => { }); }); }); + + describe('adding message reaction', () => { + it('should return reaction if chat backend request come before realtime', async (context) => { + const { chatApi, realtime } = context; + vi.spyOn(chatApi, 'addMessageReaction').mockResolvedValue({ id: 'reactionId' }); + + const conversation = new Conversation('conversationId', realtime, chatApi); + const reactionPromise = conversation.messages.addReaction('messageId', 'like'); + + context.emulateBackendPublish({ + clientId: 'clientId', + data: { + id: 'reactionId', + message_id: 'messageId', + type: 'like', + client_id: 'clientId', + }, + }); + + const reaction = await reactionPromise; + + expect(reaction).toContain({ + id: 'reactionId', + message_id: 'messageId', + type: 'like', + client_id: 'clientId', + }); + }); + + it('should return reaction if chat backend request come after realtime', async (context) => { + const { chatApi, realtime } = context; + + vi.spyOn(chatApi, 'addMessageReaction').mockImplementation(async (conversationId, messageId, type) => { + context.emulateBackendPublish({ + clientId: 'clientId', + data: { + id: 'reactionId', + message_id: messageId, + type, + client_id: 'clientId', + }, + }); + return { id: 'reactionId' }; + }); + + const conversation = new Conversation('conversationId', realtime, chatApi); + const reaction = await conversation.messages.addReaction('messageId', 'like'); + + expect(reaction).toContain({ + id: 'reactionId', + message_id: 'messageId', + type: 'like', + client_id: 'clientId', + }); + }); + }); + + describe('deleting message reaction', () => { + it('should return reaction if chat backend request come before realtime', async (context) => { + const { chatApi, realtime } = context; + vi.spyOn(chatApi, 'deleteMessageReaction').mockResolvedValue(undefined); + + const conversation = new Conversation('conversationId', realtime, chatApi); + const reactionPromise = conversation.messages.removeReaction('reactionId'); + + context.emulateBackendPublish({ + clientId: 'clientId', + data: { + id: 'reactionId', + message_id: 'messageId', + type: 'like', + client_id: 'clientId', + }, + }); + + const reaction = await reactionPromise; + + expect(reaction).toContain({ + id: 'reactionId', + message_id: 'messageId', + type: 'like', + client_id: 'clientId', + }); + }); + + it('should return reaction if chat backend request come after realtime', async (context) => { + const { chatApi, realtime } = context; + + vi.spyOn(chatApi, 'deleteMessageReaction').mockImplementation(async (reactionId) => { + context.emulateBackendPublish({ + clientId: 'clientId', + data: { + id: reactionId, + message_id: 'messageId', + type: 'like', + client_id: 'clientId', + }, + }); + return { id: reactionId }; + }); + + const conversation = new Conversation('conversationId', realtime, chatApi); + const reaction = await conversation.messages.removeReaction('reactionId'); + + expect(reaction).toContain({ + id: 'reactionId', + message_id: 'messageId', + type: 'like', + client_id: 'clientId', + }); + }); + }); }); diff --git a/src/Messages.ts b/src/Messages.ts index fc63c6e0..6f9f57f8 100644 --- a/src/Messages.ts +++ b/src/Messages.ts @@ -1,8 +1,8 @@ import { Types } from 'ably/promises'; import { ChatApi } from './ChatApi.js'; -import { Message } from './entities.js'; +import { Message, Reaction } from './entities.js'; import RealtimeChannelPromise = Types.RealtimeChannelPromise; -import { MessageEvents } from './events.js'; +import { MessageEvents, ReactionEvents } from './events.js'; export const enum Direction { forwards = 'forwards', @@ -43,14 +43,14 @@ export class Messages { } async send(text: string): Promise { - return this.makeApiCallAndWaitForRealtimeResult(MessageEvents.created, async () => { + return this.makeMessageApiCallAndWaitForRealtimeResult(MessageEvents.created, async () => { const { id } = await this.chatApi.sendMessage(this.conversationId, text); return id; }); } async edit(messageId: string, text: string): Promise { - return this.makeApiCallAndWaitForRealtimeResult(MessageEvents.deleted, async () => { + return this.makeMessageApiCallAndWaitForRealtimeResult(MessageEvents.deleted, async () => { await this.chatApi.editMessage(this.conversationId, messageId, text); return messageId; }); @@ -61,12 +61,26 @@ export class Messages { async delete(messageIdOrMessage: string | Message): Promise { const messageId = typeof messageIdOrMessage === 'string' ? messageIdOrMessage : messageIdOrMessage.id; - return this.makeApiCallAndWaitForRealtimeResult(MessageEvents.deleted, async () => { + return this.makeMessageApiCallAndWaitForRealtimeResult(MessageEvents.deleted, async () => { await this.chatApi.deleteMessage(this.conversationId, messageId); return messageId; }); } + async addReaction(messageId: string, reactionType: string) { + return this.makeReactionApiCallAndWaitForRealtimeResult(ReactionEvents.added, async () => { + const { id } = await this.chatApi.addMessageReaction(this.conversationId, messageId, reactionType); + return id; + }); + } + + async removeReaction(reactionId: string) { + return this.makeReactionApiCallAndWaitForRealtimeResult(ReactionEvents.deleted, async () => { + await this.chatApi.deleteMessageReaction(reactionId); + return reactionId; + }); + } + async subscribe(event: MessageEvents, listener: MessageListener) { const channelListener = ({ name, data }: Types.Message) => { listener({ @@ -84,7 +98,7 @@ export class Messages { this.channel.unsubscribe(event, channelListener); } - private async makeApiCallAndWaitForRealtimeResult(event: MessageEvents, apiCall: () => Promise) { + private async makeMessageApiCallAndWaitForRealtimeResult(event: MessageEvents, apiCall: () => Promise) { const queuedMessages: Record = {}; let waitingMessageId: string | null = null; @@ -121,4 +135,42 @@ export class Messages { }; }); } + + private async makeReactionApiCallAndWaitForRealtimeResult(event: ReactionEvents, apiCall: () => Promise) { + const queuedReaction: Record = {}; + + let waitingReactionId: string | null = null; + let resolver: ((reaction: Reaction) => void) | null = null; + + const waiter = ({ data }: Types.Message) => { + const reaction: Reaction = data; + if (waitingReactionId === null) { + queuedReaction[reaction.id] = reaction; + } else if (waitingReactionId === reaction.id) { + resolver?.(reaction); + resolver = null; + } + }; + + await this.channel.subscribe(event, waiter); + + try { + const reactionId = await apiCall(); + if (queuedReaction[reactionId]) { + this.channel.unsubscribe(event, waiter); + return queuedReaction[reactionId]; + } + waitingReactionId = reactionId; + } catch (e) { + this.channel.unsubscribe(event, waiter); + throw e; + } + + return new Promise((resolve) => { + resolver = (reaction) => { + this.channel.unsubscribe(event, waiter); + resolve(reaction); + }; + }); + } } diff --git a/src/events.ts b/src/events.ts index c70c96e7..74240ea7 100644 --- a/src/events.ts +++ b/src/events.ts @@ -3,3 +3,8 @@ export const enum MessageEvents { updated = 'message.updated', deleted = 'message.deleted', } + +export const enum ReactionEvents { + added = 'reaction.added', + deleted = 'reaction.deleted', +}