diff --git a/README.md b/README.md index 9a2c32fc..bbf1033d 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 `action` 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 timeserial = event.message.timeserial; + const index = messages.findIndex((m) => m.timeserial === timeserial); + 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 delete 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 cd31652f..27679d96 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.timeserial === message.timeserial); + 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.timeserial === message.message.timeserial); + 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.timeserial !== message.message.timeserial; }); + + // 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.timeserial !== deletedMessage.timeserial); + }); + }); + }, + [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.timeserial !== deletedMessage.timeserial; - }); - }); - }); - }} + 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 9c0aa604..ac6afdd0 100644 --- a/src/core/chat-api.ts +++ b/src/core/chat-api.ts @@ -1,7 +1,9 @@ import * as Ably from 'ably'; +import { ActionMetadata } from './action-metadata.js'; import { Logger } from './logger.js'; import { DefaultMessage, Message, MessageActionMetadata, MessageHeaders, MessageMetadata } from './message.js'; +import { ActionDetails } from './messages.js'; import { OccupancyEvent } from './occupancy.js'; import { PaginatedResult } from './query.js'; @@ -31,11 +33,6 @@ interface SendMessageParams { headers?: MessageHeaders; } -interface DeleteMessageParams { - description?: string; - metadata?: MessageActionMetadata; -} - export interface DeleteMessageResponse { /** * The serial of the deletion action. @@ -48,6 +45,36 @@ export interface DeleteMessageResponse { 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 operation */ + description?: string; + + /** Metadata of the update operation */ + metadata?: ActionMetadata; +} + +interface UpdateMessageResponse { + /** + * The serial of the update action. + */ + serial: string; + + /** + * The timestamp of when the update occurred. + */ + updatedAt: number; +} + /** * Chat SDK Backend */ @@ -62,6 +89,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 +99,6 @@ export class ChatApi { message.clientId, message.roomId, message.text, - new Date(message.createdAt), metadata ?? {}, headers ?? {}, message.latestAction, @@ -85,17 +112,15 @@ export class ChatApi { }); } - async deleteMessage( - roomId: string, - timeserial: string, - params?: DeleteMessageParams, - ): Promise { + async deleteMessage(roomId: string, timeserial: string, params?: ActionDetails): Promise { const body: { description?: string; metadata?: MessageActionMetadata } = { description: params?.description, metadata: params?.metadata, }; + const encodedSerial = encodeURIComponent(timeserial); + roomId = encodeURIComponent(roomId); return this._makeAuthorizedRequest( - `/chat/v2/rooms/${roomId}/messages/${timeserial}/delete`, + `/chat/v2/rooms/${roomId}/messages/${encodedSerial}/delete`, 'POST', body, {}, @@ -114,11 +139,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..aad2897a 100644 --- a/src/core/index.ts +++ b/src/core/index.ts @@ -35,13 +35,14 @@ export type { MessageMetadata, } from './message.js'; export type { - DeleteMessageParams, + ActionDetails, MessageEventPayload, MessageListener, Messages, 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 ef1c7b39..76d5676c 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 f5f9abd1..fbd8e153 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 _calculatedOriginTimeserial: Timeserial; private readonly _calculatedActionSerial: Timeserial; + public readonly createdAt: Date; constructor( public readonly timeserial: 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, @@ -225,6 +225,7 @@ export class DefaultMessage implements Message { ) { this._calculatedOriginTimeserial = DefaultTimeserial.calculateTimeserial(timeserial); this._calculatedActionSerial = DefaultTimeserial.calculateTimeserial(latestActionSerial); + this.createdAt = new Date(this._calculatedOriginTimeserial.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 a2ebcb23..5bd7d5cf 100644 --- a/src/core/messages.ts +++ b/src/core/messages.ts @@ -78,16 +78,16 @@ 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; @@ -134,6 +134,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 +243,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 message + * 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. @@ -480,7 +507,6 @@ export class DefaultMessages this._clientId, this._roomId, text, - new Date(response.createdAt), metadata ?? {}, headers ?? {}, ChatMessageActions.MessageCreate, @@ -488,18 +514,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.timeserial, { + ...details, + message: update, + }); + + return new DefaultMessage( + message.timeserial, + 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.timeserial, params); + async delete(message: Message, details?: ActionDetails): Promise { + this._logger.trace('Messages.delete();', { details }); + const response = await this._chatApi.deleteMessage(this._roomId, message.timeserial, details); const deletedMessage: Message = new DefaultMessage( message.timeserial, message.clientId, message.roomId, message.text, - message.createdAt, message.metadata, message.headers, ChatMessageActions.MessageDelete, @@ -508,8 +560,8 @@ export class DefaultMessages undefined, { 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/hooks/use-messages.ts b/src/react/hooks/use-messages.ts index e7adb99f..2ec5e8e5 100644 --- a/src/react/hooks/use-messages.ts +++ b/src/react/hooks/use-messages.ts @@ -1,15 +1,16 @@ import { - DeleteMessageParams, Message, MessageListener, Messages, MessageSubscriptionResponse, QueryOptions, SendMessageParams, + UpdateMessageParams, } from '@ably/chat'; import * as Ably from 'ably'; import { useCallback, useEffect, useState } from 'react'; +import { ActionDetails } from '../../../dist/chat/messages.js'; import { wrapRoomPromise } from '../helper/room-promise.js'; import { useEventListenerRef } from '../helper/use-event-listener-ref.js'; import { useEventualRoomProperty } from '../helper/use-eventual-room.js'; @@ -30,6 +31,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. */ @@ -101,7 +107,7 @@ export const useMessages = (params?: UseMessagesParams): UseMessagesResponse => [context], ); const deleteMessage = useCallback( - (message: Message, deleteMessageParams?: DeleteMessageParams) => + (message: Message, deleteMessageParams?: ActionDetails) => context.room.then((room) => room.messages.delete(message, deleteMessageParams)), [context], ); @@ -109,6 +115,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 +187,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 02c01927..b2c8be60 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 cf6ed69d..f80dc9a1 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 timeserial', () => { + const timeserial = 'abcdefghij@1672531200000-123'; + + const message = new DefaultMessage( + timeserial, + 'clientId', + 'roomId', + 'hello there', + {}, + {}, + ChatMessageActions.MessageCreate, + timeserial, + ); + + expect(message.createdAt).toEqual(new Date(1672531200000)); + }); + it('is the same as another message', () => { const firstTimeserial = 'abcdefghij@1672531200000-123'; const secondTimeserial = '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 f1458af3..91aac312 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.timeserial).toBe(message1.timeserial); + 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, + timeserial: message1.timeserial, + }), + ]); + + // Check that the update was received + expect(updates).toEqual([ + expect.objectContaining({ + text: 'bananas', + clientId: chat.clientId, + timeserial: message1.timeserial, + 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, + timeserial: updatedMessage1.timeserial, + 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 fa1f5594..d845b65d 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 timeserial = 'abcdefghij@' + String(timestamp) + '-123'; vi.spyOn(chatApi, 'sendMessage').mockResolvedValue({ - timeserial: 'abcdefghij@1672531200000-123', + timeserial: timeserial, createdAt: timestamp, }); @@ -76,7 +77,7 @@ describe('Messages', () => { expect(message).toEqual( expect.objectContaining({ - timeserial: 'abcdefghij@1672531200000-123', + timeserial: timeserial, 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 sendTimeserial = 'abcdefghij@' + String(sendTimestamp) + '-123'; vi.spyOn(chatApi, 'sendMessage').mockResolvedValue({ - timeserial: 'abcdefghij@1672531200000-123', + timeserial: sendTimeserial, createdAt: sendTimestamp, }); @@ -104,11 +106,12 @@ describe('Messages', () => { expect(deleteMessage1).toEqual( expect.objectContaining({ - timeserial: 'abcdefghij@1672531200000-123', + timeserial: sendTimeserial, 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 timeserial = 'abcdefghij@' + String(timestamp) + '-123'; vi.spyOn(chatApi, 'sendMessage').mockResolvedValue({ - timeserial: 'abcdefghij@1672531200000-123', + timeserial: timeserial, createdAt: timestamp, }); @@ -135,7 +139,7 @@ describe('Messages', () => { expect(message).toEqual( expect.objectContaining({ - timeserial: 'abcdefghij@1672531200000-123', + timeserial: timeserial, 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 1d0221fd..c094b813 100644 --- a/test/react/hooks/use-messages.test.tsx +++ b/test/react/hooks/use-messages.test.tsx @@ -176,7 +176,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