diff --git a/README.md b/README.md index abeac84e..648c9b96 100644 --- a/README.md +++ b/README.md @@ -270,6 +270,23 @@ const message = await room.messages.send({ }); ``` +### Deleting messages + +To delete a message, call `delete` on the `room.messages` property, with the original message you want to delete. + +You can supply optional parameters to the `delete` method to provide additional context for the deletion. + +These additional parameters are: +* `description`: a string that can be used to inform others as to why the message was deleted. +* `metadata`: a map of extra information that can be attached to the deletion message. + +The return of this call will be the deleted message, as it would appear to other subscribers of the room. +This is a _soft delete_ and the message will still be available in the history, but with the `deletedAt` property set. + +```ts +const deletedMessage = await room.messages.delete(message, { description: 'This message was deleted for ...' }); +``` + ### Subscribing to incoming messages To subscribe to incoming messages, call `subscribe` with your listener. diff --git a/demo/package-lock.json b/demo/package-lock.json index 9da72a37..d97d6dc3 100644 --- a/demo/package-lock.json +++ b/demo/package-lock.json @@ -13,7 +13,8 @@ "clsx": "^2.1.1", "nanoid": "^5.0.7", "react": "^18.3.1", - "react-dom": "^18.3.1" + "react-dom": "^18.3.1", + "react-icons": "^5.3.0" }, "devDependencies": { "@types/react": "^18.3.3", @@ -19849,6 +19850,15 @@ "react": "^18.3.1" } }, + "node_modules/react-icons": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/react-icons/-/react-icons-5.3.0.tgz", + "integrity": "sha512-DnUk8aFbTyQPSkCfF8dbX6kQjXA9DktMeJqfjrg6cK9vwQVMxmcA3BfP4QoiztVmEHtwlTgLFsPuH2NskKT6eg==", + "license": "MIT", + "peerDependencies": { + "react": "*" + } + }, "node_modules/react-refresh": { "version": "0.14.2", "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.14.2.tgz", diff --git a/demo/package.json b/demo/package.json index 6c360021..c1b1839f 100644 --- a/demo/package.json +++ b/demo/package.json @@ -18,7 +18,8 @@ "clsx": "^2.1.1", "nanoid": "^5.0.7", "react": "^18.3.1", - "react-dom": "^18.3.1" + "react-dom": "^18.3.1", + "react-icons": "^5.3.0" }, "devDependencies": { "@types/react": "^18.3.3", diff --git a/demo/src/components/MessageComponent/MessageComponent.tsx b/demo/src/components/MessageComponent/MessageComponent.tsx index 93cf1e80..096014cd 100644 --- a/demo/src/components/MessageComponent/MessageComponent.tsx +++ b/demo/src/components/MessageComponent/MessageComponent.tsx @@ -1,6 +1,7 @@ import { Message } from '@ably/chat'; -import React, { useCallback } from 'react'; +import React, { useCallback, useState } from 'react'; import clsx from 'clsx'; +import { FaTrash } from 'react-icons/fa6'; function twoDigits(input: number): string { if (input === 0) { @@ -18,13 +19,23 @@ interface MessageProps { message: Message; onMessageClick?(id: string): void; + + onMessageDelete?(msg: Message): void; } -export const MessageComponent: React.FC = ({ id, self = false, message, onMessageClick }) => { +export const MessageComponent: React.FC = ({ + id, + self = false, + message, + onMessageClick, + onMessageDelete, +}) => { const handleMessageClick = useCallback(() => { onMessageClick?.(id); }, [id, onMessageClick]); + const [hovered, setHovered] = useState(false); + let displayCreatedAt: string; if (Date.now() - message.createdAt.getTime() < 1000 * 60 * 60 * 24) { // last 24h show the time @@ -43,14 +54,21 @@ export const MessageComponent: React.FC = ({ id, self = false, mes twoDigits(message.createdAt.getMinutes()); } + const handleDelete = useCallback(() => { + // Add your delete handling logic here + onMessageDelete?.(message); + }, [message, onMessageDelete]); + return (
setHovered(true)} + onMouseLeave={() => setHovered(false)} >
= ({ id, self = false, mes })} > {message.text} + {hovered && ( + { + e.stopPropagation(); + handleDelete(); + }} + /> + )}
diff --git a/demo/src/containers/Chat/Chat.tsx b/demo/src/containers/Chat/Chat.tsx index e25b66fa..e135d19e 100644 --- a/demo/src/containers/Chat/Chat.tsx +++ b/demo/src/containers/Chat/Chat.tsx @@ -4,7 +4,14 @@ import { MessageInput } from '../../components/MessageInput'; import { useChatClient, useChatConnection, useMessages, useRoomReactions, useTyping } from '@ably/chat/react'; import { ReactionInput } from '../../components/ReactionInput'; import { ConnectionStatusComponent } from '../../components/ConnectionStatusComponent/ConnectionStatusComponent.tsx'; -import { ConnectionStatus, Message, MessageEventPayload, PaginatedResult, Reaction } from '@ably/chat'; +import { + ConnectionStatus, + Message, + MessageEventPayload, + MessageEvents, + PaginatedResult, + Reaction, +} from '@ably/chat'; export const Chat = () => { const chatClient = useChatClient(); @@ -15,9 +22,26 @@ export const Chat = () => { const isConnected: boolean = currentStatus === ConnectionStatus.Connected; - const { send: sendMessage, getPreviousMessages } = useMessages({ + const { + send: sendMessage, + getPreviousMessages, + deleteMessage, + } = useMessages({ listener: (message: MessageEventPayload) => { - setMessages((prevMessage) => [...prevMessage, message.message]); + switch (message.type) { + case MessageEvents.Created: + setMessages((prevMessage) => [...prevMessage, message.message]); + break; + case MessageEvents.Deleted: + setMessages((prevMessage) => { + return prevMessage.filter((m) => { + return m.timeserial !== message.message.timeserial; + }); + }); + break; + default: + console.error('Unknown message', message); + } }, onDiscontinuity: (discontinuity) => { console.log('Discontinuity', discontinuity); @@ -47,7 +71,9 @@ export const Chat = () => { if (getPreviousMessages && loading) { getPreviousMessages({ limit: 50 }) .then((result: PaginatedResult) => { - setMessages(result.items.reverse()); + // reverse the messages so they are displayed in the correct order + // and don't include deleted messages + setMessages(result.items.filter((m) => !m.isDeleted).reverse()); setLoading(false); }) .catch((error: unknown) => { @@ -144,6 +170,15 @@ export const Chat = () => { key={msg.timeserial} self={msg.clientId === clientId} message={msg} + onMessageDelete={(msg) => { + deleteMessage(msg, { description: 'deleted by user' }).then((deletedMessage: Message) => { + setMessages((prevMessages) => { + return prevMessages.filter((m) => { + return m.timeserial !== deletedMessage.timeserial; + }); + }); + }); + }} > ))}
diff --git a/src/core/chat-api.ts b/src/core/chat-api.ts index b4da1c18..9c0aa604 100644 --- a/src/core/chat-api.ts +++ b/src/core/chat-api.ts @@ -1,7 +1,7 @@ import * as Ably from 'ably'; import { Logger } from './logger.js'; -import { DefaultMessage, Message, MessageHeaders, MessageMetadata } from './message.js'; +import { DefaultMessage, Message, MessageActionMetadata, MessageHeaders, MessageMetadata } from './message.js'; import { OccupancyEvent } from './occupancy.js'; import { PaginatedResult } from './query.js'; @@ -31,6 +31,23 @@ interface SendMessageParams { headers?: MessageHeaders; } +interface DeleteMessageParams { + description?: string; + metadata?: MessageActionMetadata; +} + +export interface DeleteMessageResponse { + /** + * The serial of the deletion action. + */ + serial: string; + + /** + * The timestamp of the deletion action. + */ + deletedAt: number; +} + /** * Chat SDK Backend */ @@ -68,6 +85,23 @@ export class ChatApi { }); } + async deleteMessage( + roomId: string, + timeserial: string, + params?: DeleteMessageParams, + ): Promise { + const body: { description?: string; metadata?: MessageActionMetadata } = { + description: params?.description, + metadata: params?.metadata, + }; + return this._makeAuthorizedRequest( + `/chat/v2/rooms/${roomId}/messages/${timeserial}/delete`, + 'POST', + body, + {}, + ); + } + async sendMessage(roomId: string, params: SendMessageParams): Promise { const body: { text: string; @@ -90,10 +124,11 @@ export class ChatApi { private async _makeAuthorizedRequest( url: string, - method: 'POST' | 'GET' | ' PUT' | 'DELETE' | 'PATCH', + method: 'POST' | 'GET' | 'PUT' | 'DELETE' | 'PATCH', body?: unknown, + params?: unknown, ): Promise { - const response = await this._realtime.request(method, url, this._apiProtocolVersion, {}, body); + const response = await this._realtime.request(method, url, this._apiProtocolVersion, params, body); if (!response.success) { this._logger.error('ChatApi._makeAuthorizedRequest(); failed to make request', { url, diff --git a/src/core/index.ts b/src/core/index.ts index f3d61cdb..5d5ef9ff 100644 --- a/src/core/index.ts +++ b/src/core/index.ts @@ -27,8 +27,15 @@ export { } from './helpers.js'; export type { LogContext, Logger, LogHandler } from './logger.js'; export { LogLevel } from './logger.js'; -export type { Message, MessageActionDetails, MessageHeaders, MessageMetadata } from './message.js'; export type { + Message, + MessageActionDetails, + MessageActionMetadata, + MessageHeaders, + MessageMetadata, +} from './message.js'; +export type { + DeleteMessageParams, MessageEventPayload, MessageListener, Messages, diff --git a/src/core/message.ts b/src/core/message.ts index 1bce5680..f5f9abd1 100644 --- a/src/core/message.ts +++ b/src/core/message.ts @@ -17,7 +17,7 @@ export type MessageHeaders = Headers; export type MessageMetadata = Metadata; /** - * {@link ActionMetadata} type for a chat message {@link MessageActionDetails}. + * {@link ActionMetadata} type for a chat messages {@link MessageActionDetails}. */ export type MessageActionMetadata = ActionMetadata; @@ -34,7 +34,7 @@ export interface MessageActionDetails { */ description?: string; /** - * The optional {@link MessageActionMetadata} associated with the update or deletion. + * The optional metadata associated with the update or deletion. */ metadata?: MessageActionMetadata; } diff --git a/src/core/messages.ts b/src/core/messages.ts index deaa3501..10cb59ef 100644 --- a/src/core/messages.ts +++ b/src/core/messages.ts @@ -13,7 +13,7 @@ import { import { ErrorCodes } from './errors.js'; import { ChatMessageActions, MessageEvents, RealtimeMessageNames } from './events.js'; import { Logger } from './logger.js'; -import { DefaultMessage, Message, MessageHeaders, MessageMetadata } from './message.js'; +import { DefaultMessage, Message, MessageActionMetadata, MessageHeaders, MessageMetadata } from './message.js'; import { parseMessage } from './message-parser.js'; import { PaginatedResult } from './query.js'; import { addListenerToChannelWithoutAttach } from './realtime-extensions.js'; @@ -77,6 +77,22 @@ export interface QueryOptions { direction?: 'forwards' | 'backwards'; } +/** + * The parameters supplied to delete a message. + */ +export interface DeleteMessageParams { + /** + * The optional description for deleting messages. + */ + description?: string; + + /** + * The metadata that will be added to the deletion request. Defaults to empty. + * + */ + metadata?: MessageActionMetadata; +} + /** * Params for sending a text message. Only `text` is mandatory. */ @@ -199,6 +215,25 @@ export interface Messages extends EmitsDiscontinuities { */ send(params: SendMessageParams): Promise; + /** + * Delete a message in the chat room. + * + * This method uses the Ably Chat API REST endpoint for deleting messages. + * It performs a `soft` delete, meaning the message is marked as deleted. + * + * Note that the Promise may resolve before OR after the message is deleted + * from the realtime channel. This means you may see the message that was just + * deleted in a callback to `subscribe` before the returned promise resolves. + * + * Should you wish to restore a deleted message, and providing you have the appropriate permissions, + * you can simply send an update to the original message. + * + * @returns A promise that resolves when the message was deleted. + * @param message - The message to delete. + * @param deleteMessageParams - The optional parameters for deleting the message. + */ + delete(message: Message, deleteMessageParams?: DeleteMessageParams): Promise; + /** * Get the underlying Ably realtime channel used for the messages in this chat room. * @@ -448,12 +483,11 @@ export class DefaultMessages * @throws {@link ErrorInfo} if headers defines any headers prefixed with reserved words. */ async send(params: SendMessageParams): Promise { - this._logger.trace('Messages.send();'); + this._logger.trace('Messages.send();', { params }); const { text, metadata, headers } = params; const response = await this._chatApi.sendMessage(this._roomId, { text, headers, metadata }); - return new DefaultMessage( response.timeserial, this._clientId, @@ -467,6 +501,34 @@ export class DefaultMessages ); } + /** + * @inheritdoc Messages + */ + async delete(message: Message, params?: DeleteMessageParams): Promise { + this._logger.trace('Messages.delete();', { params }); + const response = await this._chatApi.deleteMessage(this._roomId, message.timeserial, params); + const deletedMessage: Message = new DefaultMessage( + message.timeserial, + message.clientId, + message.roomId, + message.text, + message.createdAt, + message.metadata, + message.headers, + ChatMessageActions.MessageDelete, + response.serial, + response.deletedAt ? new Date(response.deletedAt) : undefined, + undefined, + { + clientId: this._clientId, + description: params?.description, + metadata: params?.metadata, + }, + ); + this._logger.debug('Messages.delete(); message deleted successfully', { deletedMessage }); + return deletedMessage; + } + /** * @inheritdoc Messages */ diff --git a/src/react/README.md b/src/react/README.md index 1a2e592d..3d6856a1 100644 --- a/src/react/README.md +++ b/src/react/README.md @@ -184,30 +184,38 @@ This hook allows you to access the `Messages` instance of a `Room` from your Rea **To use this hook, the component calling it must be a child of a `ChatRoomProvider`.** -### Sending And Getting Messages +### Sending, Deleting And Getting Messages The hook will provide the `Messages` instance, should you wish to interact with it directly, a `send` method -that can be used to send a messages to the room, and a `get` method that can be used to retrieve messages from the room. +that can be used to send a message to the room, +a `deleteMessage` method that can be used to delete a message from the room, +and a `get` method that can be used to retrieve messages from the room. ```tsx import { useMessages } from '@ably/chat/react'; +import { Message } from '@ably/chat'; const MyComponent = () => { - const { send, get } = useMessages(); - + const { send, get, deleteMessage } = useMessages(); + const [message, setMessage] = useState(); const handleGetMessages = () => { // fetch the last 3 messages, oldest to newest get({ limit: 3, direction: 'forwards' }).then((result) => console.log('Previous messages: ', result.items)); }; const handleMessageSend = () => { - send({ text: 'Hello, World!' }); + send({ text: 'Hello, World!' }).then((sentMessage) => setMessage(sentMessage)); + }; + + const handleMessageDelete = (message: Message) => { + deleteMessage(message, { description: 'deleted by user' }); }; return (
+
); }; diff --git a/src/react/hooks/use-messages.ts b/src/react/hooks/use-messages.ts index 842513ba..14ae6458 100644 --- a/src/react/hooks/use-messages.ts +++ b/src/react/hooks/use-messages.ts @@ -1,4 +1,5 @@ import { + DeleteMessageParams, Message, MessageListener, Messages, @@ -31,6 +32,11 @@ export interface UseMessagesResponse extends ChatStatusResponse { */ readonly get: Messages['get']; + /** + * A shortcut to the {@link Messages.delete} method. + */ + readonly deleteMessage: Messages['delete']; + /** * Provides access to the underlying {@link Messages} instance of the room. */ @@ -89,6 +95,11 @@ export const useMessages = (params?: UseMessagesParams): UseMessagesResponse => const onDiscontinuityRef = useEventListenerRef(params?.onDiscontinuity); const send = useCallback((params: SendMessageParams) => room.messages.send(params), [room]); + + const deleteMessage = useCallback( + (message: Message, deleteMessageParams?: DeleteMessageParams) => room.messages.delete(message, deleteMessageParams), + [room], + ); const get = useCallback((options: QueryOptions) => room.messages.get(options), [room]); const [getPreviousMessages, setGetPreviousMessages] = useState(); @@ -142,6 +153,7 @@ export const useMessages = (params?: UseMessagesParams): UseMessagesResponse => return { send, get, + deleteMessage, messages: room.messages, getPreviousMessages, connectionStatus, diff --git a/test/core/chat-api.test.ts b/test/core/chat-api.test.ts index 2280ed15..f57544e0 100644 --- a/test/core/chat-api.test.ts +++ b/test/core/chat-api.test.ts @@ -27,7 +27,7 @@ describe('config', () => { statusCode: 400, }) .then(() => { - expect(realtime.request).toHaveBeenCalledWith('GET', '/chat/v1/rooms/test/occupancy', 3, {}, undefined); + expect(realtime.request).toHaveBeenCalledWith('GET', '/chat/v1/rooms/test/occupancy', 3, undefined, undefined); }); }); diff --git a/test/core/chat.integration.test.ts b/test/core/chat.integration.test.ts index cfa1f41a..d3601cc0 100644 --- a/test/core/chat.integration.test.ts +++ b/test/core/chat.integration.test.ts @@ -22,7 +22,7 @@ const waitForConnectionStatus = (chat: ChatClient, state: ConnectionStatus) => { // Set a timeout to reject the promise if the status is not reached setInterval(() => { off(); - reject(new Error(`Connection status ${state} not reached`)); + reject(new Error(`Connection state ${state} not reached`)); }, 5000); }); }; diff --git a/test/core/messages.integration.test.ts b/test/core/messages.integration.test.ts index c5b71c59..52f8ab76 100644 --- a/test/core/messages.integration.test.ts +++ b/test/core/messages.integration.test.ts @@ -1,7 +1,9 @@ +import { ChatMessageActions } from '@ably/chat'; import * as Ably from 'ably'; import { beforeEach, describe, expect, it } from 'vitest'; import { ChatClient } from '../../src/core/chat.ts'; +import { MessageEvents } from '../../src/core/events.ts'; import { Message } from '../../src/core/message.ts'; import { RealtimeChannelWithOptions } from '../../src/core/realtime-extensions.ts'; import { RoomOptionsDefaults } from '../../src/core/room-options.ts'; @@ -79,6 +81,66 @@ describe('messages integration', () => { ]); }); + it('should be able to delete and receive deletion messages', async (context) => { + const { chat } = context; + + const room = getRandomRoom(chat); + + // Attach the room + await room.attach(); + + // Subscribe to messages and filter them when they arrive + const messages: Message[] = []; + const deletions: Message[] = []; + room.messages.subscribe((messageEvent) => { + switch (messageEvent.type) { + case MessageEvents.Created: { + messages.push(messageEvent.message); + break; + } + case MessageEvents.Deleted: { + deletions.push(messageEvent.message); + break; + } + default: { + throw new Error('Unexpected message event type'); + } + } + }); + + // send a message, and then delete it + const message1 = await room.messages.send({ text: 'Hello there!' }); + const deletedMessage1 = await room.messages.delete(message1, { + description: 'Deleted message', + metadata: { key: 'value' }, + }); + + // Wait up to 5 seconds for the promises to resolve + await waitForMessages(messages, 1); + await waitForMessages(deletions, 1); + + // Check that the message was received + expect(messages).toEqual([ + expect.objectContaining({ + text: 'Hello there!', + clientId: chat.clientId, + timeserial: message1.timeserial, + }), + ]); + // Check that the deletion was received + expect(deletions).toEqual([ + expect.objectContaining({ + text: 'Hello there!', + clientId: chat.clientId, + timeserial: deletedMessage1.timeserial, + deletedAt: deletedMessage1.deletedAt, + deletedBy: chat.clientId, + latestAction: ChatMessageActions.MessageDelete, + latestActionSerial: deletedMessage1.latestActionSerial, + }), + ]); + }); + it('should be able to retrieve chat history', async (context) => { const { chat } = context; @@ -114,6 +176,35 @@ describe('messages integration', () => { expect(history.hasNext()).toBe(false); }); + // At the moment, the history API does not materialize deleted messages in the history. + it.skip('should be able to retrieve chat deletion in history', async (context) => { + const { chat } = context; + + const room = getRandomRoom(chat); + + // Publish 1 messages + const message1 = await room.messages.send({ text: 'Hello there!' }); + + // Delete the message + const deletedMessage1 = await room.messages.delete(message1, { description: 'Deleted message' }); + + // Do a history request to get the deleted message + const history = await room.messages.get({ limit: 3, direction: 'forwards' }); + + expect(history.items).toEqual([ + expect.objectContaining({ + text: 'Hello there!', + clientId: chat.clientId, + timeserial: deletedMessage1.timeserial, + deletedAt: deletedMessage1.deletedAt, + deletedBy: chat.clientId, + }), + ]); + + // We shouldn't have a "next" link in the response + expect(history.hasNext()).toBe(false); + }); + it('should be able to paginate chat history', async (context) => { const { chat } = context; diff --git a/test/core/messages.test.ts b/test/core/messages.test.ts index 2df0fbda..9ae29679 100644 --- a/test/core/messages.test.ts +++ b/test/core/messages.test.ts @@ -3,7 +3,7 @@ import { RealtimeChannel } from 'ably'; import { beforeEach, describe, expect, it, vi } from 'vitest'; import { ChatApi, GetMessagesQueryParams } from '../../src/core/chat-api.ts'; -import { ChatMessageActions } from '../../src/core/events.ts'; +import { ChatMessageActions, MessageEvents } from '../../src/core/events.ts'; import { Message } from '../../src/core/message.ts'; import { DefaultMessages, MessageEventPayload } from '../../src/core/messages.ts'; import { Room } from '../../src/core/room.ts'; @@ -84,6 +84,35 @@ describe('Messages', () => { }), ); }); + + it('should be able to delete a message and get it back from response', async (context) => { + const { chatApi } = context; + const sendTimestamp = Date.now(); + vi.spyOn(chatApi, 'sendMessage').mockResolvedValue({ + timeserial: 'abcdefghij@1672531200000-123', + createdAt: sendTimestamp, + }); + + const deleteTimestamp = Date.now(); + vi.spyOn(chatApi, 'deleteMessage').mockResolvedValue({ + serial: 'abcdefghij@1672531200000-123:0', + deletedAt: deleteTimestamp, + }); + + const message1 = await context.room.messages.send({ text: 'hello there' }); + const deleteMessage1 = await context.room.messages.delete(message1); + + expect(deleteMessage1).toEqual( + expect.objectContaining({ + timeserial: 'abcdefghij@1672531200000-123', + text: 'hello there', + clientId: 'clientId', + deletedAt: new Date(deleteTimestamp), + deletedBy: 'clientId', + roomId: context.room.roomId, + }), + ); + }); }); describe('headers and metadata', () => { @@ -178,15 +207,33 @@ describe('Messages', () => { })); }); - it('unsubscribing from messages', (context) => { + it('unsubscribes from messages', (context) => { const { room } = context; - const receivedMessages: Message[] = []; + const receivedDeletions: Message[] = []; + const receivedUpdates: Message[] = []; const listener = (message: MessageEventPayload) => { - receivedMessages.push(message.message); + switch (message.type) { + case MessageEvents.Created: { + receivedMessages.push(message.message); + break; + } + case MessageEvents.Deleted: { + receivedDeletions.push(message.message); + break; + } + case MessageEvents.Updated: { + receivedUpdates.push(message.message); + break; + } + } }; const { unsubscribe } = room.messages.subscribe(listener); + + let publishTimestamp = Date.now(); + let updateTimestamp = Date.now() + 500; + let deletionTimestamp = Date.now() + 1000; context.emulateBackendPublish({ clientId: 'yoda', name: 'chat.message', @@ -196,11 +243,47 @@ describe('Messages', () => { serial: 'abcdefghij@1672531200000-123', action: ChatMessageActions.MessageCreate, extras: {}, - timestamp: Date.now(), + timestamp: publishTimestamp, + }); + context.emulateBackendPublish({ + clientId: 'yoda', + name: 'chat.message', + data: { + text: 'I have the high ground now', + }, + serial: 'abcdefghij@1672531200000-123', + action: ChatMessageActions.MessageUpdate, + extras: {}, + timestamp: publishTimestamp, + updatedAt: updateTimestamp, + updateSerial: 'abcdefghij@1672531200000-123:0', + operation: { + clientId: 'yoda', + }, + }); + context.emulateBackendPublish({ + clientId: 'yoda', + name: 'chat.message', + data: { + text: 'I have the high ground now', + }, + serial: 'abcdefghij@1672531200000-123', + action: ChatMessageActions.MessageDelete, + extras: {}, + timestamp: publishTimestamp, + updatedAt: deletionTimestamp, + updateSerial: 'abcdefghij@1672531200000-123:0', + operation: { + clientId: 'yoda', + }, }); unsubscribe(); + // try to send and delete a new message + publishTimestamp = Date.now(); + updateTimestamp = Date.now() + 500; + deletionTimestamp = Date.now() + 1000; context.emulateBackendPublish({ clientId: 'yoda2', name: 'chat.message', @@ -212,10 +295,46 @@ describe('Messages', () => { extras: {}, timestamp: Date.now(), }); + context.emulateBackendPublish({ + clientId: 'yoda2', + name: 'chat.message', + data: { + text: 'I have the high ground now', + }, + serial: 'abcdefghij@1672531200000-123', + action: ChatMessageActions.MessageUpdate, + extras: {}, + updatedAt: updateTimestamp, + updateSerial: 'abcdefghij@1672531200000-123:0', + operation: { + clientId: 'yoda', + }, + timestamp: Date.now(), + }); + context.emulateBackendPublish({ + clientId: 'yoda2', + name: 'chat.message', + data: { + text: 'may the fourth be with you', + }, + serial: 'abcdefghij@1672531200000-123', + action: ChatMessageActions.MessageDelete, + extras: {}, + timestamp: publishTimestamp, + updatedAt: deletionTimestamp, + updateSerial: 'abcdefghij@1672531200000-123:0', + operation: { + clientId: 'yoda2', + }, + }); - // We should have only received one message + // We should have only received one message and one deletion expect(receivedMessages).toHaveLength(1); + expect(receivedDeletions).toHaveLength(1); + expect(receivedUpdates).toHaveLength(1); expect(receivedMessages[0]?.clientId).toEqual('yoda'); + expect(receivedDeletions[0]?.clientId).toEqual('yoda'); + expect(receivedUpdates[0]?.clientId).toEqual('yoda'); // A double off should not throw unsubscribe(); @@ -223,19 +342,53 @@ describe('Messages', () => { it('unsubscribing from all messages', (context) => { const { room } = context; - const receivedMessages: Message[] = []; + const receivedDeletions: Message[] = []; + const receivedUpdates: Message[] = []; + const listener = (message: MessageEventPayload) => { - receivedMessages.push(message.message); + switch (message.type) { + case MessageEvents.Created: { + receivedMessages.push(message.message); + break; + } + case MessageEvents.Deleted: { + receivedDeletions.push(message.message); + break; + } + case MessageEvents.Updated: { + receivedUpdates.push(message.message); + break; + } + } }; const receivedMessages2: Message[] = []; + const receivedDeletions2: Message[] = []; + const receivedUpdates2: Message[] = []; + const listener2 = (message: MessageEventPayload) => { - receivedMessages2.push(message.message); + switch (message.type) { + case MessageEvents.Created: { + receivedMessages2.push(message.message); + break; + } + case MessageEvents.Deleted: { + receivedDeletions2.push(message.message); + break; + } + case MessageEvents.Updated: { + receivedUpdates2.push(message.message); + break; + } + } }; const { unsubscribe } = room.messages.subscribe(listener); const { unsubscribe: unsubscribe2 } = room.messages.subscribe(listener2); + let publishTimestamp = Date.now(); + let updateTimestamp = Date.now() + 500; + let deletionTimestamp = Date.now() + 1000; context.emulateBackendPublish({ clientId: 'yoda', name: 'chat.message', @@ -245,11 +398,46 @@ describe('Messages', () => { serial: 'abcdefghij@1672531200000-123', action: ChatMessageActions.MessageCreate, extras: {}, - timestamp: Date.now(), + timestamp: publishTimestamp, + }); + context.emulateBackendPublish({ + clientId: 'yoda', + name: 'chat.message', + data: { + text: 'I have the high ground now', + }, + serial: 'abcdefghij@1672531200000-123', + action: ChatMessageActions.MessageUpdate, + updatedAt: updateTimestamp, + updateSerial: 'abcdefghij@1672531200000-123:0', + operation: { + clientId: 'yoda', + }, + extras: {}, + timestamp: publishTimestamp, + }); + context.emulateBackendPublish({ + clientId: 'yoda', + name: 'chat.message', + data: { + text: 'I have the high ground now', + }, + serial: 'abcdefghij@1672531200000-123', + action: ChatMessageActions.MessageDelete, + extras: {}, + timestamp: publishTimestamp, + updatedAt: deletionTimestamp, + updateSerial: 'abcdefghij@1672531200000-123:0', + operation: { + clientId: 'yoda', + }, }); room.messages.unsubscribeAll(); + publishTimestamp = Date.now(); + updateTimestamp = Date.now() + 500; + deletionTimestamp = Date.now() + 1000; context.emulateBackendPublish({ clientId: 'yoda2', name: 'chat.message', @@ -261,13 +449,56 @@ describe('Messages', () => { extras: {}, timestamp: Date.now(), }); + context.emulateBackendPublish({ + clientId: 'yoda', + name: 'chat.message', + data: { + text: 'I have the high ground now', + }, + serial: 'abcdefghij@1672531200000-123', + action: ChatMessageActions.MessageUpdate, + updatedAt: updateTimestamp, + updateSerial: 'abcdefghij@1672531200000-123:0', + operation: { + clientId: 'yoda', + }, + extras: {}, + timestamp: publishTimestamp, + }); + context.emulateBackendPublish({ + clientId: 'yoda2', + name: 'chat.message', + data: { + text: 'I have the high ground now', + }, + serial: 'abcdefghij@1672531200000-123', + action: ChatMessageActions.MessageDelete, + extras: {}, + timestamp: publishTimestamp, + updatedAt: deletionTimestamp, + updateSerial: 'abcdefghij@1672531200000-123:0', + operation: { + clientId: 'yoda2', + }, + }); // We should have only received one message expect(receivedMessages).toHaveLength(1); expect(receivedMessages[0]?.clientId).toEqual('yoda'); + expect(receivedMessages2).toHaveLength(1); expect(receivedMessages2[0]?.clientId).toEqual('yoda'); + expect(receivedDeletions).toHaveLength(1); + expect(receivedDeletions[0]?.clientId).toEqual('yoda'); + expect(receivedDeletions2).toHaveLength(1); + expect(receivedDeletions2[0]?.clientId).toEqual('yoda'); + + expect(receivedUpdates).toHaveLength(1); + expect(receivedUpdates[0]?.clientId).toEqual('yoda'); + expect(receivedUpdates2).toHaveLength(1); + expect(receivedUpdates2[0]?.clientId).toEqual('yoda'); + // A double off should not throw unsubscribe(); unsubscribe2(); @@ -288,6 +519,20 @@ describe('Messages', () => { timestamp: Date.now(), }, ], + [ + 'unknown action name', + { + clientId: 'yoda2', + name: 'message.foo', + data: { + text: 'may the fourth be with you', + }, + serial: 'abcdefghij@1672531200000-123', + action: 'message.unknown', + extras: {}, + timestamp: Date.now(), + }, + ], [ 'no data', { @@ -339,20 +584,19 @@ describe('Messages', () => { ], [ - 'no timeserial', + 'no serial', { clientId: 'yoda2', name: 'chat.message', data: { text: 'may the fourth be with you', }, - extras: {}, action: ChatMessageActions.MessageCreate, timestamp: Date.now(), }, ], [ - 'timeserial invalid', + 'serial invalid', { name: 'chat.message', clientId: 'yoda2', diff --git a/test/react/hooks/use-messages.integration.test.tsx b/test/react/hooks/use-messages.integration.test.tsx index 27b51494..c5dbf470 100644 --- a/test/react/hooks/use-messages.integration.test.tsx +++ b/test/react/hooks/use-messages.integration.test.tsx @@ -1,4 +1,4 @@ -import { ChatClient, Message, MessageListener, RoomOptionsDefaults, RoomStatus } from '@ably/chat'; +import { ChatClient, Message, MessageEvents, MessageListener, RoomOptionsDefaults, RoomStatus } from '@ably/chat'; import { cleanup, render, waitFor } from '@testing-library/react'; import React, { useEffect } from 'react'; import { afterEach, describe, expect, it, vi } from 'vitest'; @@ -73,6 +73,60 @@ describe('useMessages', () => { expect(messagesRoomTwo[0]?.text).toBe('hello world'); }, 10000); + it('should delete messages correctly', async () => { + // create new clients + const chatClientOne = newChatClient() as unknown as ChatClient; + const chatClientTwo = newChatClient() as unknown as ChatClient; + + // create a second room and attach it, so we can listen for deletions + const roomId = randomRoomId(); + const roomTwo = chatClientTwo.rooms.get(roomId, RoomOptionsDefaults); + await roomTwo.attach(); + + // start listening for deletions + const deletionsRoomTwo: Message[] = []; + roomTwo.messages.subscribe((message) => { + if (message.type === MessageEvents.Deleted) { + deletionsRoomTwo.push(message.message); + } + }); + + const TestComponent = () => { + const { send, deleteMessage, roomStatus } = useMessages(); + + useEffect(() => { + if (roomStatus === RoomStatus.Attached) { + void send({ text: 'hello world' }).then((message) => { + void deleteMessage(message, { + description: 'deleted', + metadata: { reason: 'test' }, + }); + }); + } + }, [roomStatus]); + + return null; + }; + + const TestProvider = () => ( + + + + + + ); + + render(); + + // expect a message to be received by the second room + await waitForMessages(deletionsRoomTwo, 1); + expect(deletionsRoomTwo[0]?.isDeleted).toBe(true); + expect(deletionsRoomTwo[0]?.deletedBy).toBe(chatClientOne.clientId); + }, 10000); + it('should receive messages on a subscribed listener', async () => { // create new clients const chatClientOne = newChatClient() as unknown as ChatClient; diff --git a/test/react/hooks/use-messages.test.tsx b/test/react/hooks/use-messages.test.tsx index 980024dd..3ee2e414 100644 --- a/test/react/hooks/use-messages.test.tsx +++ b/test/react/hooks/use-messages.test.tsx @@ -12,6 +12,7 @@ import * as Ably from 'ably'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { ChatMessageActions } from '../../../src/core/events.ts'; +import { DefaultMessage } from '../../../src/core/message.ts'; import { PaginatedResult } from '../../../src/core/query.ts'; import { useMessages } from '../../../src/react/hooks/use-messages.ts'; import { makeTestLogger } from '../../helper/logger.ts'; @@ -151,7 +152,7 @@ describe('useMessages', () => { expect(mockGetPreviousMessages).toHaveBeenCalledTimes(1); }); - it('should correctly call the send and get message methods', async () => { + it('should correctly call the methods exposed by the hook', async () => { const { result } = renderHook(() => useMessages()); // spy on the send method of the messages instance @@ -160,14 +161,35 @@ describe('useMessages', () => { // spy on the get method of the messages instance const getSpy = vi.spyOn(mockRoom.messages, 'get').mockResolvedValue({} as unknown as PaginatedResult); + const deleteSpy = vi.spyOn(mockRoom.messages, 'delete').mockResolvedValue({} as unknown as Message); + + const message = new DefaultMessage( + '108TeGZDQBderu97202638@1719948956834-0', + 'client-1', + 'some-room', + 'I have the high ground now', + new Date(1719948956834), + {}, + {}, + ChatMessageActions.MessageCreate, + '108TeGZDQBderu97202638@1719948956834-0:0', + ); // call both methods and ensure they call the underlying messages methods await act(async () => { await result.current.send({ text: 'test message' }); await result.current.get({ limit: 10 }); + await result.current.deleteMessage(message, { + description: 'deleted', + metadata: { reason: 'test' }, + }); }); expect(sendSpy).toHaveBeenCalledWith({ text: 'test message' }); expect(getSpy).toHaveBeenCalledWith({ limit: 10 }); + expect(deleteSpy).toHaveBeenCalledWith(message, { + description: 'deleted', + metadata: { reason: 'test' }, + }); }); it('should handle rerender if the room instance changes', () => {