diff --git a/README.md b/README.md index 24123841..efe8364d 100644 --- a/README.md +++ b/README.md @@ -275,6 +275,61 @@ 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 `isEdited()` 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. @@ -292,6 +347,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 0aecac87..36dbe550 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'; @@ -15,25 +15,81 @@ export const Chat = () => { const isConnected: boolean = currentStatus === ConnectionStatus.Connected; + const handleUpdatedMessage = (message: Message) => { + setMessages((prevMessages) => { + const index = prevMessages.findIndex((m) => m.timeserial === message.timeserial); + if (index === -1) { + return prevMessages; + + // todo: if we receive an update before the original message, and it is within + // the visible range, then we should add it to the list at the correct place + } + + // 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) => { @@ -134,6 +190,38 @@ export const Chat = () => { } }, [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 (
@@ -159,19 +247,11 @@ export const Chat = () => { > {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..cc286116 100644 --- a/src/core/chat-api.ts +++ b/src/core/chat-api.ts @@ -1,5 +1,6 @@ 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 { OccupancyEvent } from './occupancy.js'; @@ -48,6 +49,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 +93,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 +103,6 @@ export class ChatApi { message.clientId, message.roomId, message.text, - new Date(message.createdAt), metadata ?? {}, headers ?? {}, message.latestAction, @@ -94,8 +125,10 @@ export class ChatApi { 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 +147,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..bc80516c 100644 --- a/src/core/index.ts +++ b/src/core/index.ts @@ -42,6 +42,8 @@ export type { MessageSubscriptionResponse, QueryOptions, SendMessageParams, + UpdateMessageDetails, + 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 05bb55ab..723d6ab6 100644 --- a/src/core/messages.ts +++ b/src/core/messages.ts @@ -134,6 +134,32 @@ 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 {} + +/** + * Allows setting optional details about the update operation itself. + */ +export interface UpdateMessageDetails { + /** + * An optional description for the update operation. + */ + description?: string; + + /** + * Optional metadata for the update operation. + */ + metadata?: Record; +} + /** * Payload for a message event. */ @@ -237,6 +263,22 @@ export interface Messages extends EmitsDiscontinuities { */ delete(message: Message, deleteMessageParams?: DeleteMessageParams): 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 Details to record about the update operation. + * @returns A promise of the updated message. + */ + update(message: Message, update: UpdateMessageParams, details?: UpdateMessageDetails): Promise; + /** * Get the underlying Ably realtime channel used for the messages in this chat room. * @@ -496,7 +538,6 @@ export class DefaultMessages this._clientId, this._roomId, text, - new Date(response.createdAt), metadata ?? {}, headers ?? {}, ChatMessageActions.MessageCreate, @@ -504,6 +545,33 @@ export class DefaultMessages ); } + async update(message: Message, update: UpdateMessageParams, details?: UpdateMessageDetails): 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 */ @@ -515,7 +583,6 @@ export class DefaultMessages message.clientId, message.roomId, message.text, - message.createdAt, message.metadata, message.headers, ChatMessageActions.MessageDelete, diff --git a/src/react/hooks/use-messages.ts b/src/react/hooks/use-messages.ts index 14ae6458..f90ae4ad 100644 --- a/src/react/hooks/use-messages.ts +++ b/src/react/hooks/use-messages.ts @@ -10,6 +10,7 @@ import { } from '@ably/chat'; import { useCallback, useEffect, useState } from 'react'; +import { UpdateMessageDetails, UpdateMessageParams } from '../../../dist/chat/messages.js'; import { useEventListenerRef } from '../helper/use-event-listener-ref.js'; import { ChatStatusResponse } from '../types/chat-status-response.js'; import { Listenable } from '../types/listenable.js'; @@ -27,6 +28,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,6 +107,11 @@ export const useMessages = (params?: UseMessagesParams): UseMessagesResponse => [room], ); const get = useCallback((options: QueryOptions) => room.messages.get(options), [room]); + const update = useCallback( + (message: Message, update: UpdateMessageParams, details?: UpdateMessageDetails) => + room.messages.update(message, update, details), + [room], + ); const [getPreviousMessages, setGetPreviousMessages] = useState(); @@ -152,6 +163,7 @@ export const useMessages = (params?: UseMessagesParams): UseMessagesResponse => return { send, + update, get, deleteMessage, messages: room.messages, 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..81de0267 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', @@ -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 52f8ab76..9212ddbf 100644 --- a/test/core/messages.integration.test.ts +++ b/test/core/messages.integration.test.ts @@ -141,6 +141,72 @@ describe('messages integration', () => { ]); }); + it('should be able to update and receive update 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 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 delete it + const message1 = await room.messages.send({ text: 'Hello there!' }); + await new Promise((resolve) => setTimeout(resolve, 100)); + 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; diff --git a/test/core/messages.test.ts b/test/core/messages.test.ts index 9ae29679..613c0588 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 c5dbf470..0c3c7d0e 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 deletions + const roomId = randomRoomId(); + const roomTwo = chatClientTwo.rooms.get(roomId, RoomOptionsDefaults); + await roomTwo.attach(); + + // start listening for deletions + 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() as unknown as ChatClient; @@ -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 3ee2e414..d52929a4 100644 --- a/test/react/hooks/use-messages.test.tsx +++ b/test/react/hooks/use-messages.test.tsx @@ -168,7 +168,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 480fb56b..18a9d303 100644 --- a/test/react/hooks/use-room-reactions.integration.test.tsx +++ b/test/react/hooks/use-room-reactions.integration.test.tsx @@ -123,7 +123,7 @@ describe('useRoomReactions', () => { () => { expect(currentRoomStatus).toBe(RoomStatus.Attached); }, - { timeout: 3000 }, + { timeout: 5000 }, ); // send a reaction from the second room