diff --git a/README.md b/README.md index c7d7a9b8..a736f355 100644 --- a/README.md +++ b/README.md @@ -281,6 +281,60 @@ const message = await room.messages.send({ }); ``` +### Updating messages + +To update an existing message, call `update` on the `room.messages` property, with the original message you want to update, +the updated fields, and optional operation details to provide extra context for the update. + +The optional operation details are: +* `description`: a string that can be used to inform others as to why the message was updated. +* `metadata`: a map of extra information that can be attached to the update operation. + +Example +```typescript +const updatedMessage = await room.messages.update(message, + { + text: "hello, this is edited", + }, + { + description: "edit example", + }, +); +``` + +`updatedMessage` is a Message object with all updates applied. As with sending and deleting, the promise may resolve after the updated message is received via the messages subscription. + +A `Message` that was updated will have `updatedAt` and `updatedBy` fields set, and `isUpdated()` will return `true`. + +Note that if you delete an updated message, it is no longer considered _updated_. Only the last operation takes effect. + +#### Handling updates in realtime + +Updated messages received from realtime have the `latestAction` parameter set to `ChatMessageActions.MessageUpdate`, and the event received has the `type` set to `MessageEvents.Updated`. Updated messages are full copies of the message, meaning that all that is needed to keep a state or UI up to date is to replace the old message with the received one. + +In rare occasions updates might arrive over realtime out of order. To keep a correct state, the `Message` interface provides methods to compare two instances of the same base message to determine which one is newer: `actionBefore()`, `actionAfter()`, and `actionEqual()`. + +The same out-of-order situation can happen between updates received over realtime and HTTP responses. In the situation where two concurrent edits happen, both might be received via realtime before the HTTP response of the first one arrives. Always use `actionAfter()`, `actionBefore()`, or `actionEqual()` to determine which instance of a `Message` is newer. + +Example for handling updates: +```typescript +const messages : Message[] = []; // assuming this is where state is kept + +room.messages.subscribe(event => { + switch (event.type) { + case MessageEvents.Updated: { + const serial = event.message.serial; + const index = messages.findIndex((m) => m.serial === serial); + if (index !== -1 && messages[index].actionBefore(event.message)) { + messages[index] = event.message; + } + break; + } + // other event types (ie. created and updated) omitted + } +}); +``` + ### Deleting messages To delete a message, call `delete` on the `room.messages` property, with the original message you want to delete. @@ -298,6 +352,8 @@ This is a _soft delete_ and the message will still be available in the history, const deletedMessage = await room.messages.delete(message, { description: 'This message was deleted for ...' }); ``` +Note that you can update deleted messages, which will effectively undo the delete. Only the last operation on a message takes effect. + ### Subscribing to incoming messages To subscribe to incoming messages, call `subscribe` with your listener. diff --git a/demo/src/components/MessageComponent/MessageComponent.tsx b/demo/src/components/MessageComponent/MessageComponent.tsx index 096014cd..d1b9888d 100644 --- a/demo/src/components/MessageComponent/MessageComponent.tsx +++ b/demo/src/components/MessageComponent/MessageComponent.tsx @@ -1,71 +1,61 @@ import { Message } from '@ably/chat'; -import React, { useCallback, useState } from 'react'; +import React, { useCallback } from 'react'; import clsx from 'clsx'; -import { FaTrash } from 'react-icons/fa6'; - -function twoDigits(input: number): string { - if (input === 0) { - return '00'; - } - if (input < 10) { - return '0' + input; - } - return '' + input; -} +import { FaPencil, FaTrash } from 'react-icons/fa6'; interface MessageProps { - id: string; self?: boolean; message: Message; - onMessageClick?(id: string): void; + onMessageUpdate?(message: Message): void; onMessageDelete?(msg: Message): void; } +const shortDateTimeFormatter = new Intl.DateTimeFormat('default', { + hour: '2-digit', + minute: '2-digit', +}); + +const shortDateFullFormatter = new Intl.DateTimeFormat('default', { + day: '2-digit', + month: '2-digit', + year: 'numeric', + hour: '2-digit', + minute: '2-digit', +}); + +function shortDate(date: Date): string { + if (Date.now() - date.getTime() < 1000 * 60 * 60 * 24) { + return shortDateTimeFormatter.format(date); + } + return shortDateFullFormatter.format(date); +} + export const MessageComponent: React.FC = ({ - id, self = false, message, - onMessageClick, + onMessageUpdate, 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 - displayCreatedAt = twoDigits(message.createdAt.getHours()) + ':' + twoDigits(message.createdAt.getMinutes()); - } else { - // older, show full date - displayCreatedAt = - message.createdAt.getDate() + - '/' + - message.createdAt.getMonth() + - '/' + - message.createdAt.getFullYear() + - ' ' + - twoDigits(message.createdAt.getHours()) + - ':' + - twoDigits(message.createdAt.getMinutes()); - } + const handleMessageUpdate = useCallback( + (e: React.UIEvent) => { + e.stopPropagation(); + onMessageUpdate?.(message); + }, + [message, onMessageUpdate], + ); - const handleDelete = useCallback(() => { - // Add your delete handling logic here - onMessageDelete?.(message); - }, [message, onMessageDelete]); + const handleMessageDelete = useCallback( + (e: React.UIEvent) => { + e.stopPropagation(); + onMessageDelete?.(message); + }, + [message, onMessageDelete], + ); return ( -
setHovered(true)} - onMouseLeave={() => setHovered(false)} - > +
= ({
{message.clientId} ·{' '} - {displayCreatedAt} + {shortDate(message.createdAt)} {message.createdAt.toLocaleString()} + {message.isUpdated && message.updatedAt ? ( + <> + {' '} + · Edited{' '} + + {shortDate(message.updatedAt)} + {message.updatedAt.toLocaleString()} + + {message.updatedBy ? by {message.updatedBy} : ''} + + ) : ( + '' + )}
= ({ })} > {message.text} - {hovered && ( - { - e.stopPropagation(); - handleDelete(); - }} - /> - )} +
+
+ +
diff --git a/demo/src/containers/Chat/Chat.tsx b/demo/src/containers/Chat/Chat.tsx index 8dba2d31..689dd6ea 100644 --- a/demo/src/containers/Chat/Chat.tsx +++ b/demo/src/containers/Chat/Chat.tsx @@ -1,4 +1,4 @@ -import { useEffect, useRef, useState } from 'react'; +import { useCallback, useEffect, useRef, useState } from 'react'; import { MessageComponent } from '../../components/MessageComponent'; import { MessageInput } from '../../components/MessageInput'; import { useChatClient, useChatConnection, useMessages, useRoomReactions, useTyping } from '@ably/chat/react'; @@ -30,25 +30,78 @@ export const Chat = (props: { roomId: string; setRoomId: (roomId: string) => voi } }; + const handleUpdatedMessage = (message: Message) => { + setMessages((prevMessages) => { + const index = prevMessages.findIndex((m) => m.serial === message.serial); + if (index === -1) { + return prevMessages; + } + + // skip update if the received action is not newer + if (!prevMessages[index].actionBefore(message)) { + return prevMessages; + } + + const updatedArray = [...prevMessages]; + updatedArray[index] = message; + return updatedArray; + }); + }; + const { send: sendMessage, getPreviousMessages, deleteMessage, + update, } = useMessages({ listener: (message: MessageEventPayload) => { switch (message.type) { - case MessageEvents.Created: - setMessages((prevMessage) => [...prevMessage, message.message]); + case MessageEvents.Created: { + setMessages((prevMessages) => { + // if already exists do nothing + const index = prevMessages.findIndex((m) => m.serial === message.message.serial); + if (index !== -1) { + return prevMessages; + } + + // if the message is not in the list, add it + const newArray = [...prevMessages, message.message]; + + // and put it at the right place + for (let i = newArray.length - 1; i > 1; i--) { + if (newArray[i].before(newArray[i - 1])) { + const temp = newArray[i]; + newArray[i] = newArray[i - 1]; + newArray[i - 1] = temp; + } + } + + return newArray; + }); break; - case MessageEvents.Deleted: + } + case MessageEvents.Deleted: { setMessages((prevMessage) => { - return prevMessage.filter((m) => { + const updatedArray = prevMessage.filter((m) => { return m.serial !== message.message.serial; }); + + // don't change state if deleted message is not in the current list + if (prevMessage.length === updatedArray.length) { + return prevMessage; + } + + return updatedArray; }); break; - default: + } + case MessageEvents.Updated: { + handleUpdatedMessage(message.message); + break; + } + default: { console.error('Unknown message', message); + } } }, onDiscontinuity: (discontinuity) => { @@ -153,6 +206,38 @@ export const Chat = (props: { roomId: string; setRoomId: (roomId: string) => voi } }, [messages, loading]); + const onUpdateMessage = useCallback( + (message: Message) => { + const newText = prompt('Enter new text'); + if (!newText) { + return; + } + update(message, { + text: newText, + metadata: message.metadata, + headers: message.headers, + }) + .then((updatedMessage: Message) => { + handleUpdatedMessage(updatedMessage); + }) + .catch((error: unknown) => { + console.warn('failed to update message', error); + }); + }, + [update], + ); + + const onDeleteMessage = useCallback( + (message: Message) => { + deleteMessage(message, { description: 'deleted by user' }).then((deletedMessage: Message) => { + setMessages((prevMessages) => { + return prevMessages.filter((m) => m.serial !== deletedMessage.serial); + }); + }); + }, + [deleteMessage], + ); + return (
@@ -192,19 +277,11 @@ export const Chat = (props: { roomId: string; setRoomId: (roomId: string) => voi > {messages.map((msg) => ( { - deleteMessage(msg, { description: 'deleted by user' }).then((deletedMessage: Message) => { - setMessages((prevMessages) => { - return prevMessages.filter((m) => { - return m.serial !== deletedMessage.serial; - }); - }); - }); - }} + onMessageDelete={onDeleteMessage} + onMessageUpdate={onUpdateMessage} > ))}
diff --git a/demo/src/index.css b/demo/src/index.css index b4cf9411..8a617279 100644 --- a/demo/src/index.css +++ b/demo/src/index.css @@ -55,3 +55,11 @@ body { .sent-at-time:hover > .short { display: none; } + +.chat-message .buttons { + display: none; +} + +.chat-message:hover .buttons { + display: block; +} diff --git a/src/core/chat-api.ts b/src/core/chat-api.ts index 62850bc6..9bc59582 100644 --- a/src/core/chat-api.ts +++ b/src/core/chat-api.ts @@ -31,21 +31,53 @@ interface SendMessageParams { headers?: MessageHeaders; } +export interface DeleteMessageResponse { + /** + * The serial of the deletion action. + */ + serial: string; + + /** + * The timestamp of the deletion action. + */ + deletedAt: number; +} + +interface UpdateMessageParams { + /** + * Message data to update. All fields are updated and if omitted they are + * set to empty. + */ + message: { + text: string; + metadata?: MessageMetadata; + headers?: MessageHeaders; + }; + + /** Description of the update action */ + description?: string; + + /** Metadata of the update action */ + metadata?: MessageActionMetadata; +} interface DeleteMessageParams { + /** Description of the delete action */ description?: string; + + /** Metadata of the delete action */ metadata?: MessageActionMetadata; } -export interface DeleteMessageResponse { +interface UpdateMessageResponse { /** - * The serial of the deletion action. + * The serial of the update action. */ serial: string; /** - * The timestamp of the deletion action. + * The timestamp of when the update occurred. */ - deletedAt: number; + updatedAt: number; } /** @@ -62,6 +94,7 @@ export class ChatApi { } async getMessages(roomId: string, params: GetMessagesQueryParams): Promise> { + roomId = encodeURIComponent(roomId); return this._makeAuthorizedPaginatedRequest(`/chat/v2/rooms/${roomId}/messages`, params).then((data) => { data.items = data.items.map((message) => { const metadata = message.metadata as MessageMetadata | undefined; @@ -71,7 +104,6 @@ export class ChatApi { message.clientId, message.roomId, message.text, - new Date(message.createdAt), metadata ?? {}, headers ?? {}, message.latestAction, @@ -90,6 +122,8 @@ export class ChatApi { description: params?.description, metadata: params?.metadata, }; + serial = encodeURIComponent(serial); + roomId = encodeURIComponent(roomId); return this._makeAuthorizedRequest( `/chat/v2/rooms/${roomId}/messages/${serial}/delete`, 'POST', @@ -110,10 +144,22 @@ export class ChatApi { if (params.headers) { body.headers = params.headers; } + roomId = encodeURIComponent(roomId); return this._makeAuthorizedRequest(`/chat/v2/rooms/${roomId}/messages`, 'POST', body); } + async updateMessage(roomId: string, serial: string, params: UpdateMessageParams): Promise { + const encodedSerial = encodeURIComponent(serial); + roomId = encodeURIComponent(roomId); + return this._makeAuthorizedRequest( + `/chat/v2/rooms/${roomId}/messages/${encodedSerial}`, + 'PUT', + params, + ); + } + async getOccupancy(roomId: string): Promise { + roomId = encodeURIComponent(roomId); return this._makeAuthorizedRequest(`/chat/v1/rooms/${roomId}/occupancy`, 'GET'); } diff --git a/src/core/index.ts b/src/core/index.ts index 5d5ef9ff..48167fa9 100644 --- a/src/core/index.ts +++ b/src/core/index.ts @@ -35,6 +35,7 @@ export type { MessageMetadata, } from './message.js'; export type { + ActionDetails, DeleteMessageParams, MessageEventPayload, MessageListener, @@ -42,6 +43,7 @@ export type { MessageSubscriptionResponse, QueryOptions, SendMessageParams, + UpdateMessageParams, } from './messages.js'; export type { Metadata } from './metadata.js'; export type { Occupancy, OccupancyEvent, OccupancyListener, OccupancySubscriptionResponse } from './occupancy.js'; diff --git a/src/core/message-parser.ts b/src/core/message-parser.ts index 1424a84c..d673c0c1 100644 --- a/src/core/message-parser.ts +++ b/src/core/message-parser.ts @@ -26,7 +26,6 @@ interface ChatMessageFields { clientId: string; roomId: string; text: string; - createdAt: Date; metadata: MessageMetadata; headers: MessageHeaders; latestAction: ChatMessageActions; @@ -50,10 +49,6 @@ export function parseMessage(roomId: string | undefined, inboundMessage: Ably.In throw new Ably.ErrorInfo(`received incoming message without clientId`, 50000, 500); } - if (!message.timestamp) { - throw new Ably.ErrorInfo(`received incoming message without timestamp`, 50000, 500); - } - if (message.data.text === undefined) { throw new Ably.ErrorInfo(`received incoming message without text`, 50000, 500); } @@ -71,7 +66,6 @@ export function parseMessage(roomId: string | undefined, inboundMessage: Ably.In clientId: message.clientId, roomId, text: message.data.text, - createdAt: new Date(message.timestamp), metadata: message.data.metadata ?? {}, headers: message.extras.headers ?? {}, latestAction: message.action as ChatMessageActions, @@ -104,7 +98,6 @@ export function parseMessage(roomId: string | undefined, inboundMessage: Ably.In newMessage.clientId, newMessage.roomId, newMessage.text, - newMessage.createdAt, newMessage.metadata, newMessage.headers, newMessage.latestAction, diff --git a/src/core/message.ts b/src/core/message.ts index 17276d7d..ff0d9584 100644 --- a/src/core/message.ts +++ b/src/core/message.ts @@ -203,13 +203,13 @@ export interface Message { export class DefaultMessage implements Message { private readonly _calculatedOriginSerial: Serial; private readonly _calculatedActionSerial: Serial; + public readonly createdAt: Date; constructor( public readonly serial: string, public readonly clientId: string, public readonly roomId: string, public readonly text: string, - public readonly createdAt: Date, public readonly metadata: MessageMetadata, public readonly headers: MessageHeaders, public readonly latestAction: ChatMessageActions, @@ -224,6 +224,7 @@ export class DefaultMessage implements Message { ) { this._calculatedOriginSerial = DefaultSerial.calculateSerial(serial); this._calculatedActionSerial = DefaultSerial.calculateSerial(latestActionSerial); + this.createdAt = new Date(this._calculatedOriginSerial.timestamp); // The object is frozen after constructing to enforce readonly at runtime too Object.freeze(this); diff --git a/src/core/messages.ts b/src/core/messages.ts index 1c0e7de2..366c792a 100644 --- a/src/core/messages.ts +++ b/src/core/messages.ts @@ -78,21 +78,27 @@ export interface QueryOptions { } /** - * The parameters supplied to delete a message. + * The parameters supplied to a message action like delete or update. */ -export interface DeleteMessageParams { +export interface ActionDetails { /** - * The optional description for deleting messages. + * Optional description for the message action. */ description?: string; /** - * The metadata that will be added to the deletion request. Defaults to empty. + * Optional metadata that will be added to the action. Defaults to empty. * */ metadata?: MessageActionMetadata; } +/** + * Parameters for deleting a message. + */ +// eslint-disable-next-line @typescript-eslint/no-empty-object-type +export interface DeleteMessageParams extends ActionDetails {} + /** * Params for sending a text message. Only `text` is mandatory. */ @@ -134,6 +140,17 @@ export interface SendMessageParams { headers?: MessageHeaders; } +/** + * Params for updating a message. It accepts all parameters that sending a + * message accepts. + * + * Note that updating a message replaces the whole previous message, so all + * metadata and headers that should be kept must be set in the update request, + * or they will be lost. + */ +// eslint-disable-next-line @typescript-eslint/no-empty-object-type +export interface UpdateMessageParams extends SendMessageParams {} + /** * Payload for a message event. */ @@ -232,10 +249,26 @@ export interface Messages extends EmitsDiscontinuities { * * @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. + * @param deleteMessageParams - Optional details to record about the delete action. * @return A promise that resolves to the deleted message. */ - delete(message: Message, deleteMessageParams?: DeleteMessageParams): Promise; + delete(message: Message, deleteMessageParams?: ActionDetails): Promise; + + /** + * Update a message in the chat room. + * + * Note that the Promise may resolve before OR after the updated message is + * received from the realtime channel. This means you may see the update that + * was just sent in a callback to `subscribe` before the returned promise + * resolves. + * + * @param message The message to update. + * @param update The new message content including headers and metadata. This + * fully replaces the old content. Everything that's not set will be removed. + * @param details Optional details to record about the update action. + * @returns A promise of the updated message. + */ + update(message: Message, update: UpdateMessageParams, details?: ActionDetails): Promise; /** * Get the underlying Ably realtime channel used for the messages in this chat room. @@ -478,7 +511,6 @@ export class DefaultMessages this._clientId, this._roomId, text, - new Date(response.createdAt), metadata ?? {}, headers ?? {}, ChatMessageActions.MessageCreate, @@ -486,18 +518,44 @@ export class DefaultMessages ); } + async update(message: Message, update: UpdateMessageParams, details?: ActionDetails): Promise { + this._logger.trace('Messages.update();', { message, update, details }); + + const response = await this._chatApi.updateMessage(this._roomId, message.serial, { + ...details, + message: update, + }); + + return new DefaultMessage( + message.serial, + message.clientId, + this._roomId, + update.text, + update.metadata ?? {}, + update.headers ?? {}, + ChatMessageActions.MessageUpdate, + response.serial, + undefined, + response.updatedAt ? new Date(response.updatedAt) : undefined, + { + clientId: this._clientId, + description: details?.description, + metadata: details?.metadata, + }, + ); + } + /** * @inheritdoc Messages */ - async delete(message: Message, params?: DeleteMessageParams): Promise { - this._logger.trace('Messages.delete();', { params }); - const response = await this._chatApi.deleteMessage(this._roomId, message.serial, params); + async delete(message: Message, details?: ActionDetails): Promise { + this._logger.trace('Messages.delete();', { details }); + const response = await this._chatApi.deleteMessage(this._roomId, message.serial, details); const deletedMessage: Message = new DefaultMessage( message.serial, message.clientId, message.roomId, message.text, - message.createdAt, message.metadata, message.headers, ChatMessageActions.MessageDelete, @@ -506,8 +564,8 @@ export class DefaultMessages message.updatedAt, { clientId: this._clientId, - description: params?.description, - metadata: params?.metadata, + description: details?.description, + metadata: details?.metadata, }, ); this._logger.debug('Messages.delete(); message deleted successfully', { deletedMessage }); diff --git a/src/react/README.md b/src/react/README.md index 11b9d695..8ec81aca 100644 --- a/src/react/README.md +++ b/src/react/README.md @@ -209,10 +209,10 @@ 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, Deleting And Getting Messages +### Sending, Updating, 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 message to the room, +that can be used to send a message to the room, an `update` method for updating messages, 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. @@ -221,7 +221,7 @@ import { useMessages } from '@ably/chat/react'; import { Message } from '@ably/chat'; const MyComponent = () => { - const { send, get, deleteMessage } = useMessages(); + const { send, get, update, deleteMessage } = useMessages(); const [message, setMessage] = useState(); const handleGetMessages = () => { // fetch the last 3 messages, oldest to newest @@ -236,11 +236,26 @@ const MyComponent = () => { deleteMessage(message, { description: 'deleted by user' }); }; + const handleUpdateMessage = (message: Message) => { + const newText = prompt('Enter new text'); + if (!newText) { + return; + } + update(message, { text: newText }, { description: 'updated by user' }); + }; + return (
- + + {messages.map((msg) => ( +
+ {msg.text} + + +
+ ))}
); }; diff --git a/src/react/hooks/use-messages.ts b/src/react/hooks/use-messages.ts index e7adb99f..c9e16de4 100644 --- a/src/react/hooks/use-messages.ts +++ b/src/react/hooks/use-messages.ts @@ -1,4 +1,5 @@ import { + ActionDetails, DeleteMessageParams, Message, MessageListener, @@ -6,6 +7,7 @@ import { MessageSubscriptionResponse, QueryOptions, SendMessageParams, + UpdateMessageParams, } from '@ably/chat'; import * as Ably from 'ably'; import { useCallback, useEffect, useState } from 'react'; @@ -30,6 +32,11 @@ export interface UseMessagesResponse extends ChatStatusResponse { */ readonly send: Messages['send']; + /** + * A shortcut to the {@link Messages.update} method. + */ + readonly update: Messages['update']; + /** * A shortcut to the {@link Messages.get} method. */ @@ -109,6 +116,11 @@ export const useMessages = (params?: UseMessagesParams): UseMessagesResponse => (options: QueryOptions) => context.room.then((room) => room.messages.get(options)), [context], ); + const update = useCallback( + (message: Message, update: UpdateMessageParams, details?: ActionDetails) => + context.room.then((room) => room.messages.update(message, update, details)), + [context], + ); const [getPreviousMessages, setGetPreviousMessages] = useState(); @@ -176,6 +188,7 @@ export const useMessages = (params?: UseMessagesParams): UseMessagesResponse => return { messages: useEventualRoomProperty((room) => room.messages), send, + update, get, deleteMessage, getPreviousMessages, diff --git a/test/core/helpers.test.ts b/test/core/helpers.test.ts index dd322a4f..63565653 100644 --- a/test/core/helpers.test.ts +++ b/test/core/helpers.test.ts @@ -43,7 +43,6 @@ describe('helpers', () => { 'user1', 'some-room', 'I have the high ground now', - new Date(1719948956834), {}, {}, ChatMessageActions.MessageCreate, diff --git a/test/core/message-parser.test.ts b/test/core/message-parser.test.ts index 63756aa5..8aed8f07 100644 --- a/test/core/message-parser.test.ts +++ b/test/core/message-parser.test.ts @@ -37,18 +37,6 @@ describe('parseMessage', () => { }, expectedError: 'received incoming message without clientId', }, - { - description: 'message.timestamp is undefined', - roomId: 'room1', - message: { - data: { text: 'hello' }, - clientId: 'client1', - extras: {}, - serial: 'cbfkKvEYgBhDaZ38195418@1728402074206-0:0', - action: ChatMessageActions.MessageCreate, - }, - expectedError: 'received incoming message without timestamp', - }, { description: 'message.data.text is undefined', roomId: 'room1', @@ -100,7 +88,7 @@ describe('parseMessage', () => { expectedError: 'received incoming message with unhandled action; unhandled.action', }, { - description: 'message.updateAt is undefined for update', + description: 'message.updatedAt is undefined for update', roomId: 'room1', message: { data: { text: 'hello' }, @@ -174,11 +162,11 @@ describe('parseMessage', () => { const message = { data: { text: 'hello', metadata: { key: 'value' } }, clientId: 'client1', - timestamp: 1234567890, + timestamp: 1728402074206, extras: { headers: { headerKey: 'headerValue' }, }, - updatedAt: 1234567890, + updatedAt: 1728402074206, updateSerial: 'cbfkKvEYgBhDaZ38195418@1728402074206-0:0', action: ChatMessageActions.MessageCreate, serial: 'cbfkKvEYgBhDaZ38195418@1728402074206-0:0', @@ -191,7 +179,7 @@ describe('parseMessage', () => { expect(result.clientId).toBe('client1'); expect(result.roomId).toBe('room1'); expect(result.text).toBe('hello'); - expect(result.createdAt).toEqual(new Date(1234567890)); + expect(result.createdAt).toEqual(new Date(1728402074206)); expect(result.metadata).toEqual({ key: 'value' }); expect(result.headers).toEqual({ headerKey: 'headerValue' }); @@ -218,7 +206,7 @@ describe('parseMessage', () => { }, action: ChatMessageActions.MessageUpdate, serial: 'cbfkKvEYgBhDaZ38195418@1728402074206-0:0', - updatedAt: 1234567890, + updatedAt: 1728402074206, updateSerial: 'cbfkKvEYgBhDaZ38195418@1728402074206-0:0', operation: { clientId: 'client2', description: 'update message', metadata: { 'custom-update': 'some flag' } }, } as Ably.InboundMessage; @@ -230,10 +218,10 @@ describe('parseMessage', () => { expect(result.clientId).toBe('client1'); expect(result.roomId).toBe('room1'); expect(result.text).toBe('hello'); - expect(result.createdAt).toEqual(new Date(1234567890)); + expect(result.createdAt).toEqual(new Date(1728402074206)); expect(result.metadata).toEqual({ key: 'value' }); expect(result.headers).toEqual({ headerKey: 'headerValue' }); - expect(result.updatedAt).toEqual(new Date(1234567890)); + expect(result.updatedAt).toEqual(new Date(1728402074206)); expect(result.updatedBy).toBe('client2'); expect(result.latestAction).toEqual(ChatMessageActions.MessageUpdate); expect(result.latestActionSerial).toEqual('cbfkKvEYgBhDaZ38195418@1728402074206-0:0'); @@ -259,7 +247,7 @@ describe('parseMessage', () => { }, action: ChatMessageActions.MessageDelete, serial: 'cbfkKvEYgBhDaZ38195418@1728402074206-0:0', - updatedAt: 1234567890, + updatedAt: 1728402074206, updateSerial: 'cbfkKvEYgBhDaZ38195418@1728402074206-0:0', operation: { clientId: 'client2', @@ -275,10 +263,10 @@ describe('parseMessage', () => { expect(result.clientId).toBe('client1'); expect(result.roomId).toBe('room1'); expect(result.text).toBe('hello'); - expect(result.createdAt).toEqual(new Date(1234567890)); + expect(result.createdAt).toEqual(new Date(1728402074206)); expect(result.metadata).toEqual({ key: 'value' }); expect(result.headers).toEqual({ headerKey: 'headerValue' }); - expect(result.deletedAt).toEqual(new Date(1234567890)); + expect(result.deletedAt).toEqual(new Date(1728402074206)); expect(result.deletedBy).toBe('client2'); expect(result.latestActionSerial).toEqual('cbfkKvEYgBhDaZ38195418@1728402074206-0:0'); expect(result.latestActionDetails).toEqual({ diff --git a/test/core/message.test.ts b/test/core/message.test.ts index db5c77ec..e5f91b7b 100644 --- a/test/core/message.test.ts +++ b/test/core/message.test.ts @@ -5,6 +5,23 @@ import { ChatMessageActions } from '../../src/core/events.ts'; import { DefaultMessage } from '../../src/core/message.ts'; describe('ChatMessage', () => { + it('should correctly parse createdAt from serial', () => { + const serial = 'abcdefghij@1672531200000-123'; + + const message = new DefaultMessage( + serial, + 'clientId', + 'roomId', + 'hello there', + {}, + {}, + ChatMessageActions.MessageCreate, + serial, + ); + + expect(message.createdAt).toEqual(new Date(1672531200000)); + }); + it('is the same as another message', () => { const firstSerial = 'abcdefghij@1672531200000-123'; const secondSerial = 'abcdefghij@1672531200000-123'; @@ -14,7 +31,6 @@ describe('ChatMessage', () => { 'clientId', 'roomId', 'hello there', - new Date(1672531200000), {}, {}, ChatMessageActions.MessageCreate, @@ -25,7 +41,6 @@ describe('ChatMessage', () => { 'clientId', 'roomId', 'hello there', - new Date(1672531200000), {}, {}, ChatMessageActions.MessageCreate, @@ -44,7 +59,6 @@ describe('ChatMessage', () => { 'clientId', 'roomId', 'hello there', - new Date(1672531200000), {}, {}, ChatMessageActions.MessageCreate, @@ -55,7 +69,6 @@ describe('ChatMessage', () => { 'clientId', 'roomId', 'hello there', - new Date(1672531200000), {}, {}, ChatMessageActions.MessageCreate, @@ -74,7 +87,6 @@ describe('ChatMessage', () => { 'clientId', 'roomId', 'hello there', - new Date(1672531200000), {}, {}, ChatMessageActions.MessageCreate, @@ -85,7 +97,6 @@ describe('ChatMessage', () => { 'clientId', 'roomId', 'hello there', - new Date(1672531200000), {}, {}, ChatMessageActions.MessageCreate, @@ -103,7 +114,6 @@ describe('ChatMessage', () => { 'clientId', 'roomId', 'hello there', - new Date(1672531200000), {}, {}, ChatMessageActions.MessageCreate, @@ -114,7 +124,6 @@ describe('ChatMessage', () => { 'clientId', 'roomId', 'hello there', - new Date(1672531200000), {}, {}, ChatMessageActions.MessageCreate, @@ -131,7 +140,6 @@ describe('ChatMessage', () => { 'clientId', 'roomId', 'hello there', - new Date(1672531200000), {}, {}, ChatMessageActions.MessageCreate, @@ -151,7 +159,6 @@ describe('ChatMessage', () => { 'clientId', 'roomId', 'hello there', - new Date(1672531200000), {}, {}, ChatMessageActions.MessageDelete, @@ -173,7 +180,6 @@ describe('ChatMessage', () => { 'clientId', 'roomId', 'hello there', - new Date(1672531200000), {}, {}, ChatMessageActions.MessageUpdate, @@ -198,7 +204,6 @@ describe('ChatMessage', () => { 'clientId', 'roomId', 'hello there', - new Date(1672531200000), {}, {}, ChatMessageActions.MessageUpdate, @@ -209,7 +214,6 @@ describe('ChatMessage', () => { 'clientId', 'roomId', 'hello there', - new Date(1672531200000), {}, {}, ChatMessageActions.MessageUpdate, @@ -275,7 +279,6 @@ describe('ChatMessage', () => { 'clientId', 'roomId', 'hello there', - new Date(1672531200000), {}, {}, ChatMessageActions.MessageUpdate, @@ -286,7 +289,6 @@ describe('ChatMessage', () => { 'clientId', 'roomId', 'hello there', - new Date(1672531200000), {}, {}, ChatMessageActions.MessageUpdate, diff --git a/test/core/messages.integration.test.ts b/test/core/messages.integration.test.ts index dcb953cd..9e09710f 100644 --- a/test/core/messages.integration.test.ts +++ b/test/core/messages.integration.test.ts @@ -141,6 +141,71 @@ describe('messages integration', () => { ]); }); + it('should be able to update and receive update messages', async (context) => { + const { chat } = context; + + const room = await getRandomRoom(chat); + + // Attach the room + await room.attach(); + + // Subscribe to messages and filter them when they arrive + const messages: Message[] = []; + const updates: Message[] = []; + room.messages.subscribe((messageEvent) => { + switch (messageEvent.type) { + case MessageEvents.Created: { + messages.push(messageEvent.message); + break; + } + case MessageEvents.Updated: { + updates.push(messageEvent.message); + break; + } + default: { + throw new Error('Unexpected message event type'); + } + } + }); + + // send a message, and then update it + const message1 = await room.messages.send({ text: 'Hello there!' }); + const updated1 = await room.messages.update(message1, { text: 'bananas' }); + + expect(updated1.text).toBe('bananas'); + expect(updated1.serial).toBe(message1.serial); + expect(updated1.createdAt.getTime()).toBe(message1.createdAt.getTime()); + expect(updated1.updatedAt).toBeDefined(); + expect(updated1.updatedBy).toBe(chat.clientId); + + // Wait up to 5 seconds for the promises to resolve + await waitForMessages(messages, 1); + await waitForMessages(updates, 1); + + // Check that the message was received + expect(messages).toEqual([ + expect.objectContaining({ + text: 'Hello there!', + clientId: chat.clientId, + serial: message1.serial, + }), + ]); + + // Check that the update was received + expect(updates).toEqual([ + expect.objectContaining({ + text: 'bananas', + clientId: chat.clientId, + serial: message1.serial, + updatedAt: updated1.updatedAt, + updatedBy: chat.clientId, + latestAction: ChatMessageActions.MessageUpdate, + latestActionSerial: updated1.latestActionSerial, + createdAt: message1.createdAt, + }), + ]); + }); + it('should be able to retrieve chat history', async (context) => { const { chat } = context; @@ -205,6 +270,41 @@ describe('messages integration', () => { expect(history.hasNext()).toBe(false); }); + // At the moment, the history API does not materialize updated messages in the history. + it.skip('should be able to retrieve chat updated message in history', async (context) => { + const { chat } = context; + + const room = await getRandomRoom(chat); + + // Publish 1 messages + const message1 = await room.messages.send({ text: 'Hello there!' }); + + // Update the message + const updatedMessage1 = await room.messages.update( + message1, + { text: 'Hello test!' }, + { description: 'updated message' }, + ); + + // Do a history request to get the update message + const history = await room.messages.get({ limit: 3, direction: 'forwards' }); + + expect(history.items).toEqual([ + expect.objectContaining({ + text: 'Hello test!', + clientId: chat.clientId, + serial: updatedMessage1.serial, + updatedAt: updatedMessage1.updatedAt, + updatedBy: chat.clientId, + createdAt: message1.createdAt, + createdBy: message1.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 976e3aad..702f750f 100644 --- a/test/core/messages.test.ts +++ b/test/core/messages.test.ts @@ -65,8 +65,9 @@ describe('Messages', () => { it('should be able to send message and get it back from response', async (context) => { const { chatApi } = context; const timestamp = Date.now(); + const serial = 'abcdefghij@' + String(timestamp) + '-123'; vi.spyOn(chatApi, 'sendMessage').mockResolvedValue({ - serial: 'abcdefghij@1672531200000-123', + serial: serial, createdAt: timestamp, }); @@ -76,7 +77,7 @@ describe('Messages', () => { expect(message).toEqual( expect.objectContaining({ - serial: 'abcdefghij@1672531200000-123', + serial: serial, text: 'hello there', clientId: 'clientId', createdAt: new Date(timestamp), @@ -88,8 +89,9 @@ 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(); + const sendSerial = 'abcdefghij@' + String(sendTimestamp) + '-123'; vi.spyOn(chatApi, 'sendMessage').mockResolvedValue({ - serial: 'abcdefghij@1672531200000-123', + serial: sendSerial, createdAt: sendTimestamp, }); @@ -104,11 +106,12 @@ describe('Messages', () => { expect(deleteMessage1).toEqual( expect.objectContaining({ - serial: 'abcdefghij@1672531200000-123', + serial: sendSerial, text: 'hello there', clientId: 'clientId', deletedAt: new Date(deleteTimestamp), deletedBy: 'clientId', + createdAt: new Date(sendTimestamp), roomId: context.room.roomId, }), ); @@ -119,8 +122,9 @@ describe('Messages', () => { it('should be able to send message with headers and metadata and get it back from response', async (context) => { const { chatApi, realtime } = context; const timestamp = Date.now(); + const serial = 'abcdefghij@' + String(timestamp) + '-123'; vi.spyOn(chatApi, 'sendMessage').mockResolvedValue({ - serial: 'abcdefghij@1672531200000-123', + serial: serial, createdAt: timestamp, }); @@ -135,7 +139,7 @@ describe('Messages', () => { expect(message).toEqual( expect.objectContaining({ - serial: 'abcdefghij@1672531200000-123', + serial: serial, text: 'hello there', clientId: 'clientId', createdAt: new Date(timestamp), @@ -609,19 +613,6 @@ describe('Messages', () => { timestamp: Date.now(), }, ], - [ - 'no timestamp', - { - clientId: 'yoda2', - name: 'chat.message', - data: { - text: 'may the fourth be with you', - }, - extras: {}, - serial: 'abcdefghij@1672531200000-123', - action: ChatMessageActions.MessageCreate, - }, - ], ])('invalid incoming messages', (name: string, inboundMessage: unknown) => { it('should handle invalid inbound messages: ' + name, (context) => { const room = context.room; diff --git a/test/react/hooks/use-messages.integration.test.tsx b/test/react/hooks/use-messages.integration.test.tsx index 3bd6da4c..e4fe5d37 100644 --- a/test/react/hooks/use-messages.integration.test.tsx +++ b/test/react/hooks/use-messages.integration.test.tsx @@ -1,4 +1,12 @@ -import { ChatClient, Message, MessageEvents, MessageListener, RoomOptionsDefaults, RoomStatus } from '@ably/chat'; +import { + ChatClient, + ChatMessageActions, + 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'; @@ -127,6 +135,75 @@ describe('useMessages', () => { expect(deletionsRoomTwo[0]?.deletedBy).toBe(chatClientOne.clientId); }, 10000); + it('should update 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 updates + const roomId = randomRoomId(); + const roomTwo = await chatClientTwo.rooms.get(roomId, RoomOptionsDefaults); + await roomTwo.attach(); + + // start listening for updates + const updatesRoomTwo: Message[] = []; + roomTwo.messages.subscribe((message) => { + if (message.type === MessageEvents.Updated) { + updatesRoomTwo.push(message.message); + } + }); + + const TestComponent = () => { + const { send, update, roomStatus } = useMessages(); + + useEffect(() => { + if (roomStatus === RoomStatus.Attached) { + void send({ text: 'hello world' }).then((message) => { + void update( + message, + { + text: 'hello universe', + metadata: { icon: 'universe' }, + headers: { awesome: 'yes' }, + }, + { + description: 'make it better', + metadata: { something: 'else' }, + }, + ); + }); + } + }, [roomStatus]); + + return null; + }; + + const TestProvider = () => ( + + + + + + ); + + render(); + + // expect a message to be received by the second room + await waitForMessages(updatesRoomTwo, 1); + expect(updatesRoomTwo.length).toBe(1); + const update = updatesRoomTwo[0]; + expect(update?.isUpdated).toBe(true); + expect(update?.updatedBy).toBe(chatClientOne.clientId); + expect(update?.text).toBe('hello universe'); + expect(update?.metadata).toEqual({ icon: 'universe' }); + expect(update?.latestAction).toBe(ChatMessageActions.MessageUpdate); + expect(update?.latestActionDetails?.description).toBe('make it better'); + expect(update?.latestActionDetails?.metadata).toEqual({ something: 'else' }); + }, 10000); + it('should receive messages on a subscribed listener', async () => { // create new clients const chatClientOne = newChatClient(); @@ -171,7 +248,7 @@ describe('useMessages', () => { () => { expect(currentRoomStatus).toBe(RoomStatus.Attached); }, - { timeout: 3000 }, + { timeout: 5000 }, ); // send a message from the second room diff --git a/test/react/hooks/use-messages.test.tsx b/test/react/hooks/use-messages.test.tsx index 2653956b..67fa83df 100644 --- a/test/react/hooks/use-messages.test.tsx +++ b/test/react/hooks/use-messages.test.tsx @@ -175,7 +175,6 @@ describe('useMessages', () => { 'client-1', 'some-room', 'I have the high ground now', - new Date(1719948956834), {}, {}, ChatMessageActions.MessageCreate, diff --git a/test/react/hooks/use-room-reactions.integration.test.tsx b/test/react/hooks/use-room-reactions.integration.test.tsx index 48ea3f83..4a42597a 100644 --- a/test/react/hooks/use-room-reactions.integration.test.tsx +++ b/test/react/hooks/use-room-reactions.integration.test.tsx @@ -124,7 +124,7 @@ describe('useRoomReactions', () => { () => { expect(currentRoomStatus).toBe(RoomStatus.Attached); }, - { timeout: 3000 }, + { timeout: 5000 }, ); // send a reaction from the second room