diff --git a/ably-2.4.1.tgz b/ably-2.4.1.tgz new file mode 100644 index 00000000..f2a4c39c Binary files /dev/null and b/ably-2.4.1.tgz differ diff --git a/demo/ably-2.4.1.tgz b/demo/ably-2.4.1.tgz new file mode 100644 index 00000000..f2a4c39c Binary files /dev/null and b/demo/ably-2.4.1.tgz differ diff --git a/demo/package-lock.json b/demo/package-lock.json index 9810b270..b7e19749 100644 --- a/demo/package-lock.json +++ b/demo/package-lock.json @@ -9,7 +9,7 @@ "version": "0.0.0", "dependencies": { "@ably/chat": "file:..", - "ably": "^2.4.1", + "ably": "file:./ably-2.4.1.tgz", "clsx": "^2.1.1", "nanoid": "^5.0.7", "react": "^18.3.1", @@ -84,7 +84,7 @@ "@rollup/rollup-linux-x64-gnu": "^4.18" }, "peerDependencies": { - "ably": "^2.3.1" + "ably": "file:./ably-2.4.1.tgz" } }, "node_modules/@ably/chat": { @@ -1698,8 +1698,9 @@ }, "node_modules/ably": { "version": "2.4.1", - "resolved": "https://registry.npmjs.org/ably/-/ably-2.4.1.tgz", - "integrity": "sha512-0zGqjPBdDMcnMPG+f0/N0d6BN92XAIo7o/PtLTR2cY0y8Q1fPPtCfb74Eib6q2VCoe4HfutSAbdl+OQp4WDSTQ==", + "resolved": "file:ably-2.4.1.tgz", + "integrity": "sha512-tpGqtU36ThAU9sX7mTWnfgD68x/7PgFvzFAf5b6C3KvfPbzWUzyS0kjFgeGckRFkAEuTJzmAV8wqFZJqzq71xQ==", + "license": "Apache-2.0", "dependencies": { "@ably/msgpack-js": "^0.4.0", "fastestsmallesttextencoderdecoder": "^1.0.22", diff --git a/demo/package.json b/demo/package.json index 975557fe..34513bae 100644 --- a/demo/package.json +++ b/demo/package.json @@ -14,7 +14,7 @@ }, "dependencies": { "@ably/chat": "file:..", - "ably": "^2.4.1", + "ably": "file:./ably-2.4.1.tgz", "clsx": "^2.1.1", "nanoid": "^5.0.7", "react": "^18.3.1", diff --git a/package-lock.json b/package-lock.json index 2df4630c..054c565c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -56,7 +56,7 @@ "@rollup/rollup-linux-x64-gnu": "^4.18" }, "peerDependencies": { - "ably": "^2.3.1" + "ably": "file:./ably-2.4.1.tgz" } }, "node_modules/@aashutoshrathi/word-wrap": { @@ -2720,9 +2720,10 @@ "dev": true }, "node_modules/ably": { - "version": "2.3.1", - "resolved": "https://registry.npmjs.org/ably/-/ably-2.3.1.tgz", - "integrity": "sha512-OQ9PUwQe0qehOUPqvLargmUTlPQsxCAdStIpLZE7pT68yDd3JF28R1Ap7C50DluCIa6NqXrieRSa2tcLNN95PA==", + "version": "2.4.1", + "resolved": "file:ably-2.4.1.tgz", + "integrity": "sha512-tpGqtU36ThAU9sX7mTWnfgD68x/7PgFvzFAf5b6C3KvfPbzWUzyS0kjFgeGckRFkAEuTJzmAV8wqFZJqzq71xQ==", + "license": "Apache-2.0", "peer": true, "dependencies": { "@ably/msgpack-js": "^0.4.0", diff --git a/package.json b/package.json index f883add5..face8079 100644 --- a/package.json +++ b/package.json @@ -62,7 +62,7 @@ }, "homepage": "https://github.com/ably/ably-chat-js#readme", "peerDependencies": { - "ably": "^2.3.1" + "ably": "file:./ably-2.4.1.tgz" }, "devDependencies": { "@eslint/compat": "^1.2.0", diff --git a/src/core/chat-api.ts b/src/core/chat-api.ts index 92b7fedc..b4da1c18 100644 --- a/src/core/chat-api.ts +++ b/src/core/chat-api.ts @@ -45,7 +45,7 @@ export class ChatApi { } async getMessages(roomId: string, params: GetMessagesQueryParams): Promise> { - return this._makeAuthorizedPaginatedRequest(`/chat/v1/rooms/${roomId}/messages`, params).then((data) => { + return this._makeAuthorizedPaginatedRequest(`/chat/v2/rooms/${roomId}/messages`, params).then((data) => { data.items = data.items.map((message) => { const metadata = message.metadata as MessageMetadata | undefined; const headers = message.headers as MessageHeaders | undefined; @@ -57,6 +57,11 @@ export class ChatApi { new Date(message.createdAt), metadata ?? {}, headers ?? {}, + message.latestAction, + message.latestActionSerial, + message.deletedAt ? new Date(message.deletedAt) : undefined, + message.updatedAt ? new Date(message.updatedAt) : undefined, + message.latestActionDetails, ); }); return data; @@ -76,7 +81,7 @@ export class ChatApi { body.headers = params.headers; } - return this._makeAuthorizedRequest(`/chat/v1/rooms/${roomId}/messages`, 'POST', body); + return this._makeAuthorizedRequest(`/chat/v2/rooms/${roomId}/messages`, 'POST', body); } async getOccupancy(roomId: string): Promise { diff --git a/src/core/events.ts b/src/core/events.ts index 73a98fb1..75aab81d 100644 --- a/src/core/events.ts +++ b/src/core/events.ts @@ -4,6 +4,46 @@ export enum MessageEvents { /** Fires when a new chat message is received. */ Created = 'message.created', + + /** Fires when a chat message is updated. */ + Updated = 'message.updated', + + /** Fires when a chat message is deleted. */ + Deleted = 'message.deleted', +} + +/** + * Realtime chat message names. + */ +export enum RealtimeMessageNames { + /** Represents a regular chat message. */ + ChatMessage = 'chat.message', +} + +/** + * Chat Message Actions. + */ +export enum ChatMessageActions { + /** Represents a message with no action set. */ + MessageUnset = 'message.unset', + + /** Action applied to a new message. */ + MessageCreate = 'message.create', + + /** Action applied to an updated message. */ + MessageUpdate = 'message.update', + + /** Action applied to a deleted message. */ + MessageDelete = 'message.delete', + + /** Action applied to a new annotation. */ + MessageAnnotationCreate = 'annotation.create', + + /** Action applied to a deleted annotation. */ + MessageAnnotationUpdate = 'annotation.delete', + + /** Action applied to a meta occupancy message. */ + MessageMetaOccupancy = 'meta.occupancy', } /** diff --git a/src/core/helpers.ts b/src/core/helpers.ts index 7b7528a8..ad2bfabc 100644 --- a/src/core/helpers.ts +++ b/src/core/helpers.ts @@ -1,6 +1,6 @@ import * as Ably from 'ably'; -import { MessageEvents, RoomReactionEvents } from './events.js'; +import { RealtimeMessageNames, RoomReactionEvents } from './events.js'; import { Message } from './message.js'; import { parseMessage } from './message-parser.js'; import { Reaction } from './reaction.js'; @@ -89,7 +89,7 @@ export async function reactionFromEncoded(encoded: unknown): Promise { */ export function getEntityTypeFromAblyMessage(message: Ably.InboundMessage): ChatEntityType { switch (message.name) { - case MessageEvents.Created: { + case RealtimeMessageNames.ChatMessage: { return ChatEntityType.ChatMessage; } case RoomReactionEvents.Reaction: { diff --git a/src/core/index.ts b/src/core/index.ts index 25ba7dc4..483950a1 100644 --- a/src/core/index.ts +++ b/src/core/index.ts @@ -2,6 +2,7 @@ * @module chat-js */ +export type { ActionMetadata } from './action-metadata.js'; export { ChatClient } from './chat.js'; export type { ClientOptions } from './config.js'; export type { Connection } from './connection.js'; @@ -14,7 +15,7 @@ export type { export { ConnectionLifecycle } from './connection-status.js'; export type { DiscontinuityListener, OnDiscontinuitySubscriptionResponse } from './discontinuity.js'; export { ErrorCodes, errorInfoIs } from './errors.js'; -export { MessageEvents, PresenceEvents } from './events.js'; +export { ChatMessageActions, MessageEvents, PresenceEvents } from './events.js'; export type { Headers } from './headers.js'; export { ChatEntityType, @@ -27,7 +28,7 @@ export { } from './helpers.js'; export type { LogContext, Logger, LogHandler } from './logger.js'; export { LogLevel } from './logger.js'; -export type { Message, MessageHeaders, MessageMetadata } from './message.js'; +export type { ActionDetails, Message, MessageHeaders, MessageMetadata } from './message.js'; export type { MessageEventPayload, MessageListener, diff --git a/src/core/message-parser.ts b/src/core/message-parser.ts index 07c35b0b..c7de2af2 100644 --- a/src/core/message-parser.ts +++ b/src/core/message-parser.ts @@ -1,6 +1,7 @@ import * as Ably from 'ably'; -import { DefaultMessage, Message, MessageHeaders, MessageMetadata } from './message.js'; +import { ChatMessageActions } from './events.js'; +import { ActionDetails, DefaultMessage, Message, MessageHeaders, MessageMetadata } from './message.js'; interface MessagePayload { data?: { @@ -10,48 +11,106 @@ interface MessagePayload { clientId?: string; timestamp: number; extras?: { - timeserial?: string; headers?: MessageHeaders; }; + + serial: string; + updatedAt?: number; + updateSerial?: string; + action: Ably.MessageAction; + operation?: Ably.Operation; +} + +interface ChatMessageFields { + timeserial: string; + clientId: string; + roomId: string; + text: string; + createdAt: Date; + metadata: MessageMetadata; + headers: MessageHeaders; + latestAction: ChatMessageActions; + latestActionSerial: string; + updatedAt?: Date; + deletedAt?: Date; + operation?: ActionDetails; } -export function parseMessage(roomId: string | undefined, message: Ably.InboundMessage): Message { - const messageCreatedMessage = message as MessagePayload; +export function parseMessage(roomId: string | undefined, inboundMessage: Ably.InboundMessage): Message { + const message = inboundMessage as MessagePayload; if (!roomId) { throw new Ably.ErrorInfo(`received incoming message without roomId`, 50000, 500); } - if (!messageCreatedMessage.data) { + if (!message.data) { throw new Ably.ErrorInfo(`received incoming message without data`, 50000, 500); } - if (!messageCreatedMessage.clientId) { + if (!message.clientId) { throw new Ably.ErrorInfo(`received incoming message without clientId`, 50000, 500); } - if (!messageCreatedMessage.timestamp) { + if (!message.timestamp) { throw new Ably.ErrorInfo(`received incoming message without timestamp`, 50000, 500); } - if (messageCreatedMessage.data.text === undefined) { + if (message.data.text === undefined) { throw new Ably.ErrorInfo(`received incoming message without text`, 50000, 500); } - if (!messageCreatedMessage.extras) { + if (!message.extras) { throw new Ably.ErrorInfo(`received incoming message without extras`, 50000, 500); } - if (!messageCreatedMessage.extras.timeserial) { - throw new Ably.ErrorInfo(`received incoming message without timeserial`, 50000, 500); + if (!message.serial) { + throw new Ably.ErrorInfo(`received incoming message without serial`, 50000, 500); } - return new DefaultMessage( - messageCreatedMessage.extras.timeserial, - messageCreatedMessage.clientId, + const newMessage: ChatMessageFields = { + timeserial: message.serial, + clientId: message.clientId, roomId, - messageCreatedMessage.data.text, - new Date(messageCreatedMessage.timestamp), - messageCreatedMessage.data.metadata ?? {}, - messageCreatedMessage.extras.headers ?? {}, + text: message.data.text, + createdAt: new Date(message.timestamp), + metadata: message.data.metadata ?? {}, + headers: message.extras.headers ?? {}, + latestAction: message.action as ChatMessageActions, + latestActionSerial: message.updateSerial ?? message.serial, + updatedAt: message.updatedAt ? new Date(message.updatedAt) : undefined, + deletedAt: message.updatedAt ? new Date(message.updatedAt) : undefined, + operation: message.operation as ActionDetails, + }; + + switch (message.action) { + case ChatMessageActions.MessageCreate: { + break; + } + case ChatMessageActions.MessageUpdate: + case ChatMessageActions.MessageDelete: { + if (!message.updatedAt) { + throw new Ably.ErrorInfo(`received incoming ${message.action} without updatedAt`, 50000, 500); + } + if (!message.updateSerial) { + throw new Ably.ErrorInfo(`received incoming ${message.action} without updateSerial`, 50000, 500); + } + break; + } + default: { + throw new Ably.ErrorInfo(`received incoming message with unhandled action; ${message.action}`, 50000, 500); + } + } + return new DefaultMessage( + newMessage.timeserial, + newMessage.clientId, + newMessage.roomId, + newMessage.text, + newMessage.createdAt, + newMessage.metadata, + newMessage.headers, + newMessage.latestAction, + newMessage.latestActionSerial, + newMessage.latestAction === ChatMessageActions.MessageDelete ? newMessage.deletedAt : undefined, + newMessage.latestAction === ChatMessageActions.MessageUpdate ? newMessage.updatedAt : undefined, + newMessage.operation, ); } diff --git a/src/core/message.ts b/src/core/message.ts index ebcfc633..85751c84 100644 --- a/src/core/message.ts +++ b/src/core/message.ts @@ -1,3 +1,7 @@ +import { ErrorInfo } from 'ably'; + +import { ActionMetadata } from './action-metadata.js'; +import { ChatMessageActions } from './events.js'; import { Headers } from './headers.js'; import { Metadata } from './metadata.js'; import { DefaultTimeserial, Timeserial } from './timeserial.js'; @@ -12,6 +16,29 @@ export type MessageHeaders = Headers; */ export type MessageMetadata = Metadata; +/** + * {@link ActionMetadata} type for a chat message {@link ActionDetails}. + */ +export type MessageActionMetadata = ActionMetadata; + +/** + * Represents the detail of a message deletion or update. + */ +export interface ActionDetails { + /** + * The optional clientId of the user who performed the update or deletion. + */ + clientId?: string; + /** + * The optional description for the update or deletion. + */ + description?: string; + /** + * The optional {@link MessageActionMetadata} associated with the update or deletion. + */ + metadata?: MessageActionMetadata; +} + /** * Represents a single message in a chat room. */ @@ -70,6 +97,75 @@ export interface Message { */ readonly headers: MessageHeaders; + /** + * The latest action of the message. This can be used to determine if the message was created, updated, or deleted. + */ + readonly latestAction: ChatMessageActions; + + /** + * A unique identifier for the latest action that updated the message. This is only set for update and deletes. + */ + readonly latestActionSerial: string; + + /** + * The details of the latest action that updated the message. This is only set for update and delete actions. + */ + readonly latestActionDetails?: ActionDetails; + + /** + * Indicates if the message has been updated. + */ + readonly isUpdated: boolean; + + /** + * Indicates if the message has been deleted. + */ + readonly isDeleted: boolean; + + /** + * The clientId of the user who deleted the message. + */ + readonly deletedBy?: string; + + /** + * The clientId of the user who updated the message. + */ + readonly updatedBy?: string; + + /** + * The timestamp at which the message was deleted. + */ + readonly deletedAt?: Date; + + /** + * The timestamp at which the message was updated. + */ + readonly updatedAt?: Date; + + /** + * Determines if the action of this message is before the given message. + * @param message The message to compare against. + * @returns true if the action of this message is before the given message. + * @throws {@link ErrorInfo} if both message timeserials do not match, or if updateSerial of either is invalid. + */ + actionBefore(message: Message): boolean; + + /** + * Determines if the action of this message is after the given message. + * @param message The message to compare against. + * @returns true if the action of this message is after the given message. + * @throws {@link ErrorInfo} if both message timeserials do not match, or if updateSerial of either is invalid. + */ + actionAfter(message: Message): boolean; + + /** + * Determines if the action of this message is equal to the given message. + * @param message The message to compare against. + * @returns true if the action of this message is equal to the given message. + * @throws {@link ErrorInfo} if both message timeserials do not match, or if updateSerial of either is invalid. + */ + actionEqual(message: Message): boolean; + /** * Determines if this message was created before the given message. This comparison is based on * global order, so does not necessarily represent the order that messages are received in realtime @@ -105,7 +201,8 @@ export interface Message { * Allows for comparison of messages based on their timeserials. */ export class DefaultMessage implements Message { - private readonly _calculatedTimeserial: Timeserial; + private readonly _calculatedOriginTimeserial: Timeserial; + private readonly _calculatedActionSerial: Timeserial; constructor( public readonly timeserial: string, @@ -115,22 +212,73 @@ export class DefaultMessage implements Message { public readonly createdAt: Date, public readonly metadata: MessageMetadata, public readonly headers: MessageHeaders, + + public readonly latestAction: ChatMessageActions, + // the latestActionSerial will be set to the original timeserial for new messages, + // else it will be set to the serial corresponding to whatever action + // (update/delete) that was just performed. + public readonly latestActionSerial: string, + + public readonly deletedAt?: Date, + public readonly updatedAt?: Date, + public readonly latestActionDetails?: ActionDetails, ) { - this._calculatedTimeserial = DefaultTimeserial.calculateTimeserial(timeserial); + this._calculatedOriginTimeserial = DefaultTimeserial.calculateTimeserial(timeserial); + this._calculatedActionSerial = DefaultTimeserial.calculateTimeserial(latestActionSerial); // The object is frozen after constructing to enforce readonly at runtime too Object.freeze(this); } + get isUpdated(): boolean { + return this.updatedAt !== undefined; + } + + get isDeleted(): boolean { + return this.deletedAt !== undefined; + } + + get updatedBy(): string | undefined { + return this.latestAction === ChatMessageActions.MessageUpdate ? this.latestActionDetails?.clientId : undefined; + } + + get deletedBy(): string | undefined { + return this.latestAction === ChatMessageActions.MessageDelete ? this.latestActionDetails?.clientId : undefined; + } + + actionBefore(message: Message): boolean { + // Check to ensure the messages are the same before comparing operation order + if (!this.equal(message)) { + throw new ErrorInfo('actionBefore(): Cannot compare actions, message timeserials must be equal', 50000, 500); + } + return this._calculatedActionSerial.before(message.latestActionSerial); + } + + actionAfter(message: Message): boolean { + // Check to ensure the messages are the same before comparing operation order + if (!this.equal(message)) { + throw new ErrorInfo('actionAfter(): Cannot compare actions, message timeserials must be equal', 50000, 500); + } + return this._calculatedActionSerial.after(message.latestActionSerial); + } + + actionEqual(message: Message): boolean { + // Check to ensure the messages are the same before comparing operation order + if (!this.equal(message)) { + throw new ErrorInfo('actionEqual(): Cannot compare actions, message timeserials must be equal', 50000, 500); + } + return this._calculatedActionSerial.equal(message.latestActionSerial); + } + before(message: Message): boolean { - return this._calculatedTimeserial.before(message.timeserial); + return this._calculatedOriginTimeserial.before(message.timeserial); } after(message: Message): boolean { - return this._calculatedTimeserial.after(message.timeserial); + return this._calculatedOriginTimeserial.after(message.timeserial); } equal(message: Message): boolean { - return this._calculatedTimeserial.equal(message.timeserial); + return this._calculatedOriginTimeserial.equal(message.timeserial); } } diff --git a/src/core/messages.ts b/src/core/messages.ts index 442ab663..2cf29afe 100644 --- a/src/core/messages.ts +++ b/src/core/messages.ts @@ -11,7 +11,7 @@ import { OnDiscontinuitySubscriptionResponse, } from './discontinuity.js'; import { ErrorCodes } from './errors.js'; -import { MessageEvents } from './events.js'; +import { ChatMessageActions, MessageEvents, RealtimeMessageNames } from './events.js'; import { Logger } from './logger.js'; import { DefaultMessage, Message, MessageHeaders, MessageMetadata } from './message.js'; import { parseMessage } from './message-parser.js'; @@ -26,6 +26,8 @@ import EventEmitter from './utils/event-emitter.js'; */ interface MessageEventsMap { [MessageEvents.Created]: MessageEventPayload; + [MessageEvents.Updated]: MessageEventPayload; + [MessageEvents.Deleted]: MessageEventPayload; } /** @@ -257,7 +259,7 @@ export class DefaultMessages addListenerToChannelWithoutAttach({ listener: this._processEvent.bind(this), - events: [MessageEvents.Created], + events: [RealtimeMessageNames.ChatMessage], channel: channel, }); @@ -451,6 +453,8 @@ export class DefaultMessages new Date(response.createdAt), metadata ?? {}, headers ?? {}, + ChatMessageActions.MessageCreate, + response.timeserial, ); } @@ -459,7 +463,7 @@ export class DefaultMessages */ subscribe(listener: MessageListener): MessageSubscriptionResponse { this._logger.trace('Messages.subscribe();'); - super.on([MessageEvents.Created], listener); + super.on([MessageEvents.Created, MessageEvents.Updated, MessageEvents.Deleted], listener); // Set the subscription point to a promise that resolves when the channel attaches or with the latest message const resolvedSubscriptionStart = this._resolveSubscriptionStart(); @@ -494,27 +498,42 @@ export class DefaultMessages this._listenerSubscriptionPoints.clear(); } + // This function is used to convert the message action from Ably to the chat MessageEvents. + private _messageActionToMessageEvent(action: ChatMessageActions): MessageEvents | undefined { + switch (action) { + case ChatMessageActions.MessageCreate: { + return MessageEvents.Created; + } + case ChatMessageActions.MessageUpdate: { + return MessageEvents.Updated; + } + case ChatMessageActions.MessageDelete: { + return MessageEvents.Deleted; + } + default: { + this._logger.trace('Messages._messageActionToMessageEvent(); received unknown message action', { action }); + return undefined; + } + } + } + private _processEvent(channelEventMessage: Ably.InboundMessage) { this._logger.trace('Messages._processEvent();', { channelEventMessage, }); - const { name } = channelEventMessage; - + const { action } = channelEventMessage; + const event = this._messageActionToMessageEvent(action as ChatMessageActions); + if (!event) { + this._logger.debug('Messages._processEvent(); received unknown message action', { action }); + return; + } // Send the message to the listeners - switch (name) { - case MessageEvents.Created: { - const message = this._parseNewMessage(channelEventMessage); - if (!message) { - return; - } - - this.emit(MessageEvents.Created, { type: name, message: message }); - break; - } - default: { - this._logger.warn('Messages._processEvent(); received unknown event', { name }); - } + const message = this._parseNewMessage(channelEventMessage); + if (!message) { + return; } + + this.emit(event, { type: event, message: message }); } /** diff --git a/test/core/helpers.test.ts b/test/core/helpers.test.ts index 7b56b9d5..dd322a4f 100644 --- a/test/core/helpers.test.ts +++ b/test/core/helpers.test.ts @@ -1,7 +1,7 @@ import * as Ably from 'ably'; import { describe, expect, it } from 'vitest'; -import { MessageEvents } from '../../src/core/events.js'; +import { ChatMessageActions, RealtimeMessageNames } from '../../src/core/events.js'; import { RoomReactionEvents } from '../../src/core/events.ts'; import { chatMessageFromEncoded, getEntityTypeFromEncoded, reactionFromEncoded } from '../../src/core/helpers.js'; import { DefaultMessage } from '../../src/core/message.js'; @@ -12,12 +12,13 @@ const TEST_ENVELOPED_MESSAGE = { clientId: 'user1', timestamp: 1719948956834, encoding: 'json', + action: 1, + serial: '108TeGZDQBderu97202638@1719948956834-0', extras: { - timeserial: '108TeGZDQBderu97202638@1719948956834-0', headers: {}, }, data: '{"text":"I have the high ground now","metadata":{}}', - name: 'message.created', + name: 'chat.message', }; const TEST_ENVELOPED_ROOM_REACTION = { @@ -28,6 +29,8 @@ const TEST_ENVELOPED_ROOM_REACTION = { encoding: 'json', data: '{"type":"like"}', name: 'roomReaction', + serial: '108TeGZDQBderu97202638@1719948956834-0', + action: 1, }; describe('helpers', () => { @@ -43,6 +46,8 @@ describe('helpers', () => { new Date(1719948956834), {}, {}, + ChatMessageActions.MessageCreate, + '108TeGZDQBderu97202638@1719948956834-0', ), ); }); @@ -54,11 +59,12 @@ describe('helpers', () => { clientId: 'user1', timestamp: 1719948956834, encoding: 'json', + action: 1, + serial: '108TeGZDQBderu97202638@1719948956834-0', extras: { - timeserial: '108TeGZDQBderu97202638@1719948956834-0', headers: {}, }, - name: 'message.created', + name: 'chat.message', }); }).rejects.toBeErrorInfo({ code: 50000, @@ -81,7 +87,8 @@ describe('helpers', () => { connectionId: 'NtORcEMDdH', timestamp: 1719948877991, encoding: 'json', - name: 'message.created', + action: 1, + name: 'chat.message', }); }).rejects.toBeErrorInfo({ code: 50000, @@ -129,13 +136,13 @@ describe('helpers', () => { }); it('should return "chatMessage" for MessageEvents.created', () => { - const message = { name: MessageEvents.Created as string } as Ably.InboundMessage; + const message = { name: RealtimeMessageNames.ChatMessage } as Ably.InboundMessage; const result = getEntityTypeFromEncoded(message); expect(result).toBe('chatMessage'); }); it('should return "roomReaction" for RoomReactionEvents.reaction', () => { - const message = { name: RoomReactionEvents.Reaction as string } as Ably.InboundMessage; + const message = { name: RoomReactionEvents.Reaction } as Ably.InboundMessage; const result = getEntityTypeFromEncoded(message); expect(result).toBe('reaction'); }); diff --git a/test/core/message-parser.test.ts b/test/core/message-parser.test.ts index ee958f8a..f6dbc290 100644 --- a/test/core/message-parser.test.ts +++ b/test/core/message-parser.test.ts @@ -1,7 +1,8 @@ import * as Ably from 'ably'; import { describe, expect, it } from 'vitest'; -import { DefaultMessage } from '../../src/core/message.js'; +import { ChatMessageActions } from '../../src/core/events.ts'; +import { DefaultMessage } from '../../src/core/message.ts'; import { parseMessage } from '../../src/core/message-parser.js'; describe('parseMessage', () => { @@ -15,7 +16,13 @@ describe('parseMessage', () => { { description: 'message.data is undefined', roomId: 'room1', - message: { clientId: 'client1', timestamp: 1234567890, extras: { timeserial: 'abcdefghij@1672531200000-123' } }, + message: { + clientId: 'client1', + timestamp: 1234567890, + extras: {}, + serial: 'cbfkKvEYgBhDaZ38195418@1728402074206-0:0', + action: ChatMessageActions.MessageCreate, + }, expectedError: 'received incoming message without data', }, { @@ -24,7 +31,9 @@ describe('parseMessage', () => { message: { data: { text: 'hello' }, timestamp: 1234567890, - extras: { timeserial: 'abcdefghij@1672531200000-123' }, + extras: {}, + serial: 'cbfkKvEYgBhDaZ38195418@1728402074206-0:0', + action: ChatMessageActions.MessageCreate, }, expectedError: 'received incoming message without clientId', }, @@ -34,7 +43,9 @@ describe('parseMessage', () => { message: { data: { text: 'hello' }, clientId: 'client1', - extras: { timeserial: 'abcdefghij@1672531200000-123' }, + extras: {}, + serial: 'cbfkKvEYgBhDaZ38195418@1728402074206-0:0', + action: ChatMessageActions.MessageCreate, }, expectedError: 'received incoming message without timestamp', }, @@ -45,21 +56,108 @@ describe('parseMessage', () => { data: {}, clientId: 'client1', timestamp: 1234567890, - extras: { timeserial: 'abcdefghij@1672531200000-123' }, + extras: {}, + serial: 'cbfkKvEYgBhDaZ38195418@1728402074206-0:0', + action: ChatMessageActions.MessageCreate, }, expectedError: 'received incoming message without text', }, { description: 'message.extras is undefined', roomId: 'room1', - message: { data: { text: 'hello' }, clientId: 'client1', timestamp: 1234567890 }, + message: { + data: { text: 'hello' }, + clientId: 'client1', + timestamp: 1234567890, + serial: 'cbfkKvEYgBhDaZ38195418@1728402074206-0:0', + action: ChatMessageActions.MessageCreate, + }, expectedError: 'received incoming message without extras', }, { - description: 'message.extras.timeserial is undefined', + description: 'message.serial is undefined', + roomId: 'room1', + message: { + data: { text: 'hello' }, + clientId: 'client1', + timestamp: 1234567890, + extras: {}, + action: ChatMessageActions.MessageCreate, + }, + expectedError: 'received incoming message without serial', + }, + { + description: 'message.action is unhandled', + roomId: 'room1', + message: { + data: { text: 'hello' }, + clientId: 'client1', + timestamp: 1234567890, + extras: {}, + serial: 'cbfkKvEYgBhDaZ38195418@1728402074206-0:0', + action: 'unhandled.action', + }, + expectedError: 'received incoming message with unhandled action; unhandled.action', + }, + { + description: 'message.updateAt is undefined for update', roomId: 'room1', - message: { data: { text: 'hello' }, clientId: 'client1', timestamp: 1234567890, extras: {} }, - expectedError: 'received incoming message without timeserial', + message: { + data: { text: 'hello' }, + clientId: 'client1', + timestamp: 1234567890, + extras: {}, + serial: 'cbfkKvEYgBhDaZ38195418@1728402074206-0:0', + action: ChatMessageActions.MessageUpdate, + updatedAt: undefined, + updateSerial: 'cbfkKvEYgBhDaZ38195418@1728402074206-0:0', + }, + expectedError: 'received incoming message.update without updatedAt', + }, + { + description: 'message.updateSerial is undefined for update', + roomId: 'room1', + message: { + data: { text: 'hello' }, + clientId: 'client1', + timestamp: 1234567890, + extras: {}, + serial: 'cbfkKvEYgBhDaZ38195418@1728402074206-0:0', + action: ChatMessageActions.MessageUpdate, + updatedAt: 1234567890, + updateSerial: undefined, + }, + expectedError: 'received incoming message.update without updateSerial', + }, + { + description: 'message.updatedAt is undefined for deletion', + roomId: 'room1', + message: { + data: { text: 'hello' }, + clientId: 'client1', + timestamp: 1234567890, + extras: {}, + serial: 'cbfkKvEYgBhDaZ38195418@1728402074206-0:0', + updatedAt: undefined, + updateSerial: 'cbfkKvEYgBhDaZ38195418@1728402074206-0:0', + action: ChatMessageActions.MessageDelete, + }, + expectedError: 'received incoming message.delete without updatedAt', + }, + { + description: 'message.updateSerial is undefined for deletion', + roomId: 'room1', + message: { + data: { text: 'hello' }, + clientId: 'client1', + timestamp: 1234567890, + extras: {}, + serial: 'cbfkKvEYgBhDaZ38195418@1728402074206-0:0', + updatedAt: 1234567890, + updateSerial: undefined, + action: ChatMessageActions.MessageDelete, + }, + expectedError: 'received incoming message.delete without updateSerial', }, ])('should throw an error ', ({ description, roomId, message, expectedError }) => { it(`should throw an error if ${description}`, () => { @@ -72,23 +170,126 @@ describe('parseMessage', () => { }); }); - it('should return a DefaultMessage instance for a valid message', () => { + it('should return a DefaultMessage instance for a valid new message', () => { const message = { data: { text: 'hello', metadata: { key: 'value' } }, clientId: 'client1', timestamp: 1234567890, - extras: { timeserial: 'abcdefghij@1672531200000-123', headers: { headerKey: 'headerValue' } }, + extras: { + headers: { headerKey: 'headerValue' }, + }, + updatedAt: 1234567890, + updateSerial: 'cbfkKvEYgBhDaZ38195418@1728402074206-0:0', + action: ChatMessageActions.MessageCreate, + serial: 'cbfkKvEYgBhDaZ38195418@1728402074206-0:0', } as Ably.InboundMessage; const result = parseMessage('room1', message); expect(result).toBeInstanceOf(DefaultMessage); - expect(result.timeserial).toBe('abcdefghij@1672531200000-123'); + expect(result.timeserial).toBe('cbfkKvEYgBhDaZ38195418@1728402074206-0:0'); expect(result.clientId).toBe('client1'); expect(result.roomId).toBe('room1'); expect(result.text).toBe('hello'); expect(result.createdAt).toEqual(new Date(1234567890)); expect(result.metadata).toEqual({ key: 'value' }); expect(result.headers).toEqual({ headerKey: 'headerValue' }); + + // deletion related fields should be undefined + expect(result.deletedAt).toBeUndefined(); + expect(result.deletedBy).toBeUndefined(); + + // update related fields should be undefined + expect(result.updatedAt).toBeUndefined(); + expect(result.updatedBy).toBeUndefined(); + + expect(result.latestAction).toEqual(ChatMessageActions.MessageCreate); + expect(result.latestActionDetails).toBeUndefined(); + }); + + it('should return a DefaultMessage instance for a valid updated message', () => { + const message = { + id: 'message-id', + data: { text: 'hello', metadata: { key: 'value' } }, + clientId: 'client1', + timestamp: 1234567890, + extras: { + headers: { headerKey: 'headerValue' }, + }, + action: ChatMessageActions.MessageUpdate, + serial: 'cbfkKvEYgBhDaZ38195418@1728402074206-0:0', + updatedAt: 1234567890, + updateSerial: 'cbfkKvEYgBhDaZ38195418@1728402074206-0:0', + operation: { clientId: 'client2', description: 'update message', metadata: { 'custom-update': 'some flag' } }, + } as Ably.InboundMessage; + + const result = parseMessage('room1', message); + + expect(result).toBeInstanceOf(DefaultMessage); + expect(result.timeserial).toBe('cbfkKvEYgBhDaZ38195418@1728402074206-0:0'); + expect(result.clientId).toBe('client1'); + expect(result.roomId).toBe('room1'); + expect(result.text).toBe('hello'); + expect(result.createdAt).toEqual(new Date(1234567890)); + expect(result.metadata).toEqual({ key: 'value' }); + expect(result.headers).toEqual({ headerKey: 'headerValue' }); + expect(result.updatedAt).toEqual(new Date(1234567890)); + expect(result.updatedBy).toBe('client2'); + expect(result.latestAction).toEqual(ChatMessageActions.MessageUpdate); + expect(result.latestActionSerial).toEqual('cbfkKvEYgBhDaZ38195418@1728402074206-0:0'); + expect(result.latestActionDetails).toEqual({ + clientId: 'client2', + description: 'update message', + metadata: { 'custom-update': 'some flag' }, + }); + + // deletion related fields should be undefined + expect(result.deletedAt).toBeUndefined(); + expect(result.deletedBy).toBeUndefined(); + }); + + it('should return a DefaultMessage instance for a valid deleted message', () => { + const message = { + id: 'message-id', + data: { text: 'hello', metadata: { key: 'value' } }, + clientId: 'client1', + timestamp: 1234567890, + extras: { + headers: { headerKey: 'headerValue' }, + }, + action: ChatMessageActions.MessageDelete, + serial: 'cbfkKvEYgBhDaZ38195418@1728402074206-0:0', + updatedAt: 1234567890, + updateSerial: 'cbfkKvEYgBhDaZ38195418@1728402074206-0:0', + operation: { + clientId: 'client2', + description: 'delete message', + metadata: { 'custom-warning': 'this is a warning' }, + }, + } as Ably.InboundMessage; + + const result = parseMessage('room1', message); + + expect(result).toBeInstanceOf(DefaultMessage); + expect(result.timeserial).toBe('cbfkKvEYgBhDaZ38195418@1728402074206-0:0'); + expect(result.clientId).toBe('client1'); + expect(result.roomId).toBe('room1'); + expect(result.text).toBe('hello'); + expect(result.createdAt).toEqual(new Date(1234567890)); + expect(result.metadata).toEqual({ key: 'value' }); + expect(result.headers).toEqual({ headerKey: 'headerValue' }); + expect(result.deletedAt).toEqual(new Date(1234567890)); + expect(result.deletedBy).toBe('client2'); + expect(result.deletedAt).toEqual(new Date(1234567890)); + expect(result.latestActionSerial).toEqual('cbfkKvEYgBhDaZ38195418@1728402074206-0:0'); + expect(result.latestActionDetails).toEqual({ + clientId: 'client2', + description: 'delete message', + metadata: { 'custom-warning': 'this is a warning' }, + }); + + // update related fields should be undefined + expect(result.updatedAt).toBeUndefined(); + expect(result.updatedBy).toBeUndefined(); }); }); diff --git a/test/core/message.test.ts b/test/core/message.test.ts index d4f50077..10b10937 100644 --- a/test/core/message.test.ts +++ b/test/core/message.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it } from 'vitest'; +import { ChatMessageActions } from '../../src/core/events.ts'; import { DefaultMessage } from '../../src/core/message.ts'; describe('ChatMessage', () => { @@ -15,6 +16,8 @@ describe('ChatMessage', () => { new Date(1672531200000), {}, {}, + ChatMessageActions.MessageCreate, + firstTimeserial, ); const secondMessage = new DefaultMessage( secondTimeserial, @@ -24,6 +27,8 @@ describe('ChatMessage', () => { new Date(1672531200000), {}, {}, + ChatMessageActions.MessageCreate, + secondTimeserial, ); expect(firstMessage.equal(secondMessage)).toBe(true); @@ -41,6 +46,8 @@ describe('ChatMessage', () => { new Date(1672531200000), {}, {}, + ChatMessageActions.MessageCreate, + firstTimeserial, ); const secondMessage = new DefaultMessage( secondTimeserial, @@ -50,6 +57,8 @@ describe('ChatMessage', () => { new Date(1672531200000), {}, {}, + ChatMessageActions.MessageCreate, + secondTimeserial, ); expect(firstMessage.equal(secondMessage)).toBe(false); @@ -67,6 +76,8 @@ describe('ChatMessage', () => { new Date(1672531200000), {}, {}, + ChatMessageActions.MessageCreate, + firstTimeserial, ); const secondMessage = new DefaultMessage( secondTimeserial, @@ -76,6 +87,8 @@ describe('ChatMessage', () => { new Date(1672531200000), {}, {}, + ChatMessageActions.MessageCreate, + secondTimeserial, ); expect(firstMessage.before(secondMessage)).toBe(true); @@ -92,6 +105,8 @@ describe('ChatMessage', () => { new Date(1672531200000), {}, {}, + ChatMessageActions.MessageCreate, + firstTimeserial, ); const secondMessage = new DefaultMessage( secondTimeserial, @@ -101,11 +116,49 @@ describe('ChatMessage', () => { new Date(1672531200000), {}, {}, + ChatMessageActions.MessageCreate, + secondTimeserial, ); expect(firstMessage.after(secondMessage)).toBe(true); }); + it('is deleted', () => { + const firstTimeserial = 'abcdefghij@1672531200000-124:0'; + + const firstMessage = new DefaultMessage( + firstTimeserial, + 'clientId', + 'roomId', + 'hello there', + new Date(1672531200000), + {}, + {}, + ChatMessageActions.MessageDelete, + 'abcdefghij@1672531200000-123:0', + new Date(1672531300000), + ); + expect(firstMessage.isDeleted).toBe(true); + }); + + it('is updated', () => { + const firstTimeserial = 'abcdefghij@1672531200000-124'; + const firstMessage = new DefaultMessage( + firstTimeserial, + 'clientId', + 'roomId', + 'hello there', + new Date(1672531200000), + {}, + {}, + ChatMessageActions.MessageUpdate, + 'abcdefghij@1672531200000-123:0', + undefined, + new Date(1672531300000), + ); + expect(firstMessage.isUpdated).toBe(true); + }); + it('throws an error with an invalid timeserial', () => { expect(() => { new DefaultMessage( @@ -116,6 +169,8 @@ describe('ChatMessage', () => { new Date(1672531200000), {}, {}, + ChatMessageActions.MessageCreate, + 'not a valid timeserial', ); }).toThrowErrorInfo({ code: 50000, diff --git a/test/core/messages.test.ts b/test/core/messages.test.ts index c2cdf883..2df0fbda 100644 --- a/test/core/messages.test.ts +++ b/test/core/messages.test.ts @@ -3,6 +3,7 @@ import { RealtimeChannel } from 'ably'; import { beforeEach, describe, expect, it, vi } from 'vitest'; import { ChatApi, GetMessagesQueryParams } from '../../src/core/chat-api.ts'; +import { ChatMessageActions } from '../../src/core/events.ts'; import { Message } from '../../src/core/message.ts'; import { DefaultMessages, MessageEventPayload } from '../../src/core/messages.ts'; import { Room } from '../../src/core/room.ts'; @@ -25,10 +26,15 @@ interface TestContext { interface MockPaginatedResult { items: Message[]; + first(): Promise>; + next(): Promise | null>; + current(): Promise>; + hasNext(): boolean; + isLast(): boolean; } @@ -118,36 +124,55 @@ describe('Messages', () => { }); describe('subscribing to updates', () => { - it('subscribing to messages', (context) => + it('should subscribe to all message events', (context) => new Promise((done, reject) => { const publishTimestamp = Date.now(); - context.room.messages.subscribe((rawMsg) => { - const message = rawMsg.message; - try { - expect(message).toEqual( - expect.objectContaining({ - timeserial: 'abcdefghij@1672531200000-123', - text: 'may the fourth be with you', - clientId: 'yoda', - createdAt: new Date(publishTimestamp), - roomId: context.room.roomId, - }), - ); - } catch (error: unknown) { - reject(error as Error); + let eventCount = 0; + const timeout = setTimeout(() => { + reject(new Error('did not receive all message events')); + }, 300); + context.room.messages.subscribe(() => { + eventCount++; + if (eventCount === 3) { + clearTimeout(timeout); + done(); } - done(); }); - context.emulateBackendPublish({ clientId: 'yoda', - name: 'message.created', + name: 'chat.message', data: { - text: 'may the fourth be with you', + text: 'this message has been deleted', + }, + serial: 'abcdefghij@1672531200000-123', + action: ChatMessageActions.MessageDelete, + updatedAt: 1729091893, + updateSerial: 'abcdefghij@1672531200000-123', + extras: {}, + timestamp: publishTimestamp, + }); + context.emulateBackendPublish({ + clientId: 'yoda', + name: 'chat.message', + data: { + text: 'some updated text', }, - extras: { - timeserial: 'abcdefghij@1672531200000-123', + serial: 'abcdefghij@1672531200000-123', + action: ChatMessageActions.MessageUpdate, + updatedAt: 1729091893, + updateSerial: 'abcdefghij@1672531200000-123', + extras: {}, + timestamp: publishTimestamp, + }); + context.emulateBackendPublish({ + clientId: 'yoda', + name: 'chat.message', + data: { + text: 'may the fourth be with you', }, + serial: 'abcdefghij@1672531200000-123', + action: ChatMessageActions.MessageCreate, + extras: {}, timestamp: publishTimestamp, }); })); @@ -164,13 +189,13 @@ describe('Messages', () => { const { unsubscribe } = room.messages.subscribe(listener); context.emulateBackendPublish({ clientId: 'yoda', - name: 'message.created', + name: 'chat.message', data: { text: 'may the fourth be with you', }, - extras: { - timeserial: 'abcdefghij@1672531200000-123', - }, + serial: 'abcdefghij@1672531200000-123', + action: ChatMessageActions.MessageCreate, + extras: {}, timestamp: Date.now(), }); @@ -178,13 +203,13 @@ describe('Messages', () => { context.emulateBackendPublish({ clientId: 'yoda2', - name: 'message.created', + name: 'chat.message', data: { text: 'may the fourth be with you', }, - extras: { - timeserial: 'abcdefghij@1672531200000-123', - }, + serial: 'abcdefghij@1672531200000-123', + action: ChatMessageActions.MessageCreate, + extras: {}, timestamp: Date.now(), }); @@ -213,13 +238,13 @@ describe('Messages', () => { const { unsubscribe: unsubscribe2 } = room.messages.subscribe(listener2); context.emulateBackendPublish({ clientId: 'yoda', - name: 'message.created', + name: 'chat.message', data: { text: 'may the fourth be with you', }, - extras: { - timeserial: 'abcdefghij@1672531200000-123', - }, + serial: 'abcdefghij@1672531200000-123', + action: ChatMessageActions.MessageCreate, + extras: {}, timestamp: Date.now(), }); @@ -227,13 +252,13 @@ describe('Messages', () => { context.emulateBackendPublish({ clientId: 'yoda2', - name: 'message.created', + name: 'chat.message', data: { text: 'may the fourth be with you', }, - extras: { - timeserial: 'abcdefghij@1672531200000-123', - }, + serial: 'abcdefghij@1672531200000-123', + action: ChatMessageActions.MessageCreate, + extras: {}, timestamp: Date.now(), }); @@ -257,9 +282,9 @@ describe('Messages', () => { data: { text: 'may the fourth be with you', }, - extras: { - timeserial: 'abcdefghij@1672531200000-123', - }, + serial: 'abcdefghij@1672531200000-123', + action: ChatMessageActions.MessageCreate, + extras: {}, timestamp: Date.now(), }, ], @@ -267,10 +292,10 @@ describe('Messages', () => { 'no data', { clientId: 'yoda2', - name: 'message.created', - extras: { - timeserial: 'abcdefghij@1672531200000-123', - }, + name: 'chat.message', + serial: 'abcdefghij@1672531200000-123', + action: ChatMessageActions.MessageCreate, + extras: {}, timestamp: Date.now(), }, ], @@ -278,62 +303,65 @@ describe('Messages', () => { 'no text', { clientId: 'yoda2', - name: 'message.created', + name: 'chat.message', data: {}, - extras: { - timeserial: 'abcdefghij@1672531200000-123', - }, + serial: 'abcdefghij@1672531200000-123', + action: ChatMessageActions.MessageCreate, + extras: {}, timestamp: Date.now(), }, ], [ 'no client id', { - name: 'message.created', + name: 'chat.message', data: { text: 'may the fourth be with you', }, - extras: { - timeserial: 'abcdefghij@1672531200000-123', - }, + serial: 'abcdefghij@1672531200000-123', + action: ChatMessageActions.MessageCreate, + extras: {}, timestamp: Date.now(), }, ], [ 'no extras', { + name: 'chat.message', clientId: 'yoda2', - name: 'message.created', data: { text: 'may the fourth be with you', }, + serial: 'abcdefghij@1672531200000-123', + action: ChatMessageActions.MessageCreate, timestamp: Date.now(), }, ], [ - 'no extras.timeserial', + 'no timeserial', { clientId: 'yoda2', - name: 'message.created', + name: 'chat.message', data: { text: 'may the fourth be with you', }, extras: {}, + action: ChatMessageActions.MessageCreate, timestamp: Date.now(), }, ], [ - 'extras.timeserial invalid', + 'timeserial invalid', { + name: 'chat.message', clientId: 'yoda2', - name: 'message.created', data: { text: 'may the fourth be with you', }, - extras: { - timeserial: 'abc', - }, + extras: {}, + serial: 'abc', + action: ChatMessageActions.MessageCreate, timestamp: Date.now(), }, ], @@ -341,16 +369,16 @@ describe('Messages', () => { 'no timestamp', { clientId: 'yoda2', - name: 'message.created', + name: 'chat.message', data: { text: 'may the fourth be with you', }, - extras: { - timeserial: 'abcdefghij@1672531200000-123', - }, + extras: {}, + serial: 'abcdefghij@1672531200000-123', + action: ChatMessageActions.MessageCreate, }, ], - ])('invalid incoming messages', (name: string, inboundMessage: Partial) => { + ])('invalid incoming messages', (name: string, inboundMessage: unknown) => { it('should handle invalid inbound messages: ' + name, (context) => { const room = context.room; let listenerCalled = false; @@ -358,7 +386,7 @@ describe('Messages', () => { listenerCalled = true; }); - context.emulateBackendPublish(inboundMessage); + context.emulateBackendPublish(inboundMessage as Ably.InboundMessage); expect(listenerCalled).toBe(false); }); }); diff --git a/test/helper/channel.ts b/test/helper/channel.ts index 398a0b8c..01facc72 100644 --- a/test/helper/channel.ts +++ b/test/helper/channel.ts @@ -18,7 +18,6 @@ export const channelEventEmitter = ( if (!arg.name) { throw new Error('Event name is required'); } - channelWithEmitter.subscriptions.emit(arg.name, arg); }; }; diff --git a/test/react/hooks/use-messages.test.tsx b/test/react/hooks/use-messages.test.tsx index eebc68aa..be57085a 100644 --- a/test/react/hooks/use-messages.test.tsx +++ b/test/react/hooks/use-messages.test.tsx @@ -11,6 +11,7 @@ import { act, cleanup, renderHook, waitFor } from '@testing-library/react'; import * as Ably from 'ably'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { ChatMessageActions } from '../../../src/core/events.ts'; import { PaginatedResult } from '../../../src/core/query.ts'; import { useMessages } from '../../../src/react/hooks/use-messages.ts'; import { makeTestLogger } from '../../helper/logger.ts'; @@ -103,9 +104,17 @@ describe('useMessages', () => { timestamp: new Date(), text: 'test message', timeserial: '123', + action: ChatMessageActions.MessageCreate, clientId: '123', roomId: '123', createdAt: new Date(), + latestAction: ChatMessageActions.MessageCreate, + latestActionSerial: '122', + isUpdated: false, + isDeleted: false, + actionBefore: vi.fn(), + actionAfter: vi.fn(), + actionEqual: vi.fn(), before: vi.fn(), after: vi.fn(), equal: vi.fn(),