diff --git a/docs/jupyter-chat-example/src/index.ts b/docs/jupyter-chat-example/src/index.ts index a4696a86..0c2d6054 100644 --- a/docs/jupyter-chat-example/src/index.ts +++ b/docs/jupyter-chat-example/src/index.ts @@ -9,9 +9,9 @@ import { ActiveCellManager, AttachmentOpenerRegistry, IAttachment, - IChatMessage, IChatContext, IConfig, + IMessageContent, INewMessage, MultiChatPanel, SelectionWatcher, @@ -58,7 +58,7 @@ class MyChatModel extends AbstractChatModel { sendMessage( newMessage: INewMessage ): Promise | boolean | void { - const message: IChatMessage = { + const message: IMessageContent = { body: newMessage.body, id: newMessage.id ?? UUID.uuid4(), type: 'msg', diff --git a/packages/jupyter-chat/src/__tests__/model.spec.ts b/packages/jupyter-chat/src/__tests__/model.spec.ts index 60695bb5..78a4fd6f 100644 --- a/packages/jupyter-chat/src/__tests__/model.spec.ts +++ b/packages/jupyter-chat/src/__tests__/model.spec.ts @@ -8,7 +8,7 @@ */ import { AbstractChatModel, IChatContext, IChatModel } from '../model'; -import { IChatMessage, INewMessage } from '../types'; +import { IMessage, IMessageContent, INewMessage } from '../types'; import { MockChatModel, MockChatContext } from './mocks'; describe('test chat model', () => { @@ -27,7 +27,7 @@ describe('test chat model', () => { describe('incoming message', () => { class TestChat extends AbstractChatModel implements IChatModel { - protected formatChatMessage(message: IChatMessage): IChatMessage { + protected formatChatMessage(message: IMessageContent): IMessageContent { message.body = 'formatted msg'; return message; } @@ -43,14 +43,14 @@ describe('test chat model', () => { } let model: IChatModel; - let messages: IChatMessage[]; + let messages: IMessage[]; const msg = { type: 'msg', id: 'message1', time: Date.now() / 1000, body: 'message test', sender: { username: 'user' } - } as IChatMessage; + } as IMessageContent; beforeEach(() => { messages = []; @@ -64,7 +64,7 @@ describe('test chat model', () => { }); model.messageAdded(msg); expect(messages).toHaveLength(1); - expect(messages[0]).toBe(msg); + expect(messages[0].content).toBe(msg); }); it('should format message', () => { @@ -75,8 +75,8 @@ describe('test chat model', () => { }); model.messageAdded({ ...msg }); expect(messages).toHaveLength(1); - expect(messages[0]).not.toBe(msg); - expect((messages[0] as IChatMessage).body).toBe('formatted msg'); + expect(messages[0].content).not.toBe(msg); + expect(messages[0].body).toBe('formatted msg'); }); }); diff --git a/packages/jupyter-chat/src/components/messages/header.tsx b/packages/jupyter-chat/src/components/messages/header.tsx index a1f26d31..f31b9441 100644 --- a/packages/jupyter-chat/src/components/messages/header.tsx +++ b/packages/jupyter-chat/src/components/messages/header.tsx @@ -7,7 +7,7 @@ import { Box, Typography } from '@mui/material'; import React, { useEffect, useState } from 'react'; import { Avatar } from '../avatar'; -import { IChatMessage } from '../../types'; +import { IMessageContent, IMessage } from '../../types'; const MESSAGE_HEADER_CLASS = 'jp-chat-message-header'; const MESSAGE_TIME_CLASS = 'jp-chat-message-time'; @@ -19,7 +19,7 @@ type ChatMessageHeaderProps = { /** * The chat message. */ - message: IChatMessage; + message: IMessage; /** * Whether this message is from the current user. */ @@ -35,7 +35,9 @@ export function ChatMessageHeader(props: ChatMessageHeaderProps): JSX.Element { return <>; } const [datetime, setDatetime] = useState>({}); - const message = props.message; + const [message, setMessage] = useState( + props.message.content + ); const sender = message.sender; /** * Effect: update cached datetime strings upon receiving a new message. @@ -74,6 +76,17 @@ export function ChatMessageHeader(props: ChatMessageHeaderProps): JSX.Element { } }); + // Listen for changes in the current message. + useEffect(() => { + function messageChanged() { + setMessage(props.message.content); + } + props.message.changed.connect(messageChanged); + return () => { + props.message.changed.disconnect(messageChanged); + }; + }, [props.message]); + const avatar = message.stacked ? null : Avatar({ user: sender }); const name = diff --git a/packages/jupyter-chat/src/components/messages/message.tsx b/packages/jupyter-chat/src/components/messages/message.tsx index ad4c3470..bdb15d9f 100644 --- a/packages/jupyter-chat/src/components/messages/message.tsx +++ b/packages/jupyter-chat/src/components/messages/message.tsx @@ -11,7 +11,7 @@ import { BaseMessageProps } from './messages'; import { AttachmentPreviewList } from '../attachments'; import { ChatInput } from '../input'; import { IInputModel, InputModel } from '../../input-model'; -import { IChatMessage } from '../../types'; +import { IMessageContent, IMessage } from '../../types'; import { replaceSpanToMention } from '../../utils'; /** @@ -21,7 +21,7 @@ type ChatMessageProps = BaseMessageProps & { /** * The message to display. */ - message: IChatMessage; + message: IMessage; /** * The index of the message in the list. */ @@ -37,7 +37,10 @@ type ChatMessageProps = BaseMessageProps & { */ export const ChatMessage = forwardRef( (props, ref): JSX.Element => { - const { message, model, rmRegistry } = props; + const { model, rmRegistry } = props; + const [message, setMessage] = useState( + props.message.content + ); const [edit, setEdit] = useState(false); const [deleted, setDeleted] = useState(false); const [canEdit, setCanEdit] = useState(false); @@ -62,6 +65,24 @@ export const ChatMessage = forwardRef( } }, [model, message]); + // Listen for changes in the current message. + useEffect(() => { + function messageChanged() { + setMessage(props.message.content); + } + + props.message.changed.connect(messageChanged); + + // Initialize the message when the message is re-rendered. + // FIX ? This seems to be required for outofband change, to get the new value, + // even if when an outofband change occurs, all the messages are deleted and + // recreated. + setMessage(props.message.content); + return () => { + props.message.changed.disconnect(messageChanged); + }; + }, [props.message]); + // Create an input model only if the message is edited. const startEdition = (): void => { if (!canEdit) { diff --git a/packages/jupyter-chat/src/components/messages/messages.tsx b/packages/jupyter-chat/src/components/messages/messages.tsx index ef182f4e..a9b38599 100644 --- a/packages/jupyter-chat/src/components/messages/messages.tsx +++ b/packages/jupyter-chat/src/components/messages/messages.tsx @@ -16,9 +16,10 @@ import { Navigation } from './navigation'; import { WelcomeMessage } from './welcome'; import { IInputToolbarRegistry } from '../input'; import { ScrollContainer } from '../scroll-container'; -import { IChatCommandRegistry, IMessageFooterRegistry } from '../../registers'; +import { Message } from '../../message'; import { IChatModel } from '../../model'; -import { ChatArea, IChatMessage } from '../../types'; +import { IChatCommandRegistry, IMessageFooterRegistry } from '../../registers'; +import { ChatArea, IMessage } from '../../types'; export const MESSAGE_CLASS = 'jp-chat-message'; const MESSAGES_BOX_CLASS = 'jp-chat-messages-container'; @@ -63,7 +64,7 @@ export type BaseMessageProps = { */ export function ChatMessages(props: BaseMessageProps): JSX.Element { const { model } = props; - const [messages, setMessages] = useState(model.messages); + const [messages, setMessages] = useState(model.messages); const refMsgBox = useRef(null); const [allRendered, setAllRendered] = useState(false); @@ -81,7 +82,11 @@ export function ChatMessages(props: BaseMessageProps): JSX.Element { } model .getHistory() - .then(history => setMessages(history.messages)) + .then(history => + setMessages( + history.messages.map(message => new Message({ ...message })) + ) + ) .catch(e => console.error(e)); } @@ -198,7 +203,7 @@ export function ChatMessages(props: BaseMessageProps): JSX.Element { return ( // extra div needed to ensure each bubble is on a new line { + return this._changed; + } + + /** + * Update one or several fields of the message. + */ + update(updated: Partial) { + this._content = { ...this._content, ...updated }; + this._changed.emit(); + } + + private _content: IMessageContent; + private _changed = new Signal(this); +} diff --git a/packages/jupyter-chat/src/model.ts b/packages/jupyter-chat/src/model.ts index 40f80634..06734522 100644 --- a/packages/jupyter-chat/src/model.ts +++ b/packages/jupyter-chat/src/model.ts @@ -6,21 +6,23 @@ import { IDocumentManager } from '@jupyterlab/docmanager'; import { ArrayExt } from '@lumino/algorithm'; import { CommandRegistry } from '@lumino/commands'; +import { PromiseDelegate } from '@lumino/coreutils'; import { IDisposable } from '@lumino/disposable'; import { ISignal, Signal } from '@lumino/signaling'; import { IActiveCellManager } from './active-cell-manager'; import { IInputModel, InputModel } from './input-model'; +import { Message } from './message'; import { ISelectionWatcher } from './selection-watcher'; import { IChatHistory, - INewMessage, - IChatMessage, IConfig, + IMessage, + IMessageContent, + INewMessage, IUser } from './types'; import { replaceMentionToSpan } from './utils'; -import { PromiseDelegate } from '@lumino/coreutils'; /** * The chat model interface. @@ -59,7 +61,7 @@ export interface IChatModel extends IDisposable { /** * The chat messages list. */ - readonly messages: IChatMessage[]; + readonly messages: IMessage[]; /** * The input model. @@ -138,7 +140,7 @@ export interface IChatModel extends IDisposable { */ updateMessage?( id: string, - message: IChatMessage + message: IMessageContent ): Promise | boolean | void; /** @@ -168,7 +170,7 @@ export interface IChatModel extends IDisposable { * * @param message - the message with user information and body. */ - messageAdded(message: IChatMessage): void; + messageAdded(message: IMessageContent): void; /** * Function called when messages are inserted. @@ -176,7 +178,7 @@ export interface IChatModel extends IDisposable { * @param index - the index of the first message of the list. * @param messages - the messages list. */ - messagesInserted(index: number, messages: IChatMessage[]): void; + messagesInserted(index: number, messages: IMessageContent[]): void; /** * Function called when messages are deleted. @@ -286,7 +288,7 @@ export abstract class AbstractChatModel implements IChatModel { /** * The chat messages list. */ - get messages(): IChatMessage[] { + get messages(): IMessage[] { return this._messages; } @@ -387,11 +389,11 @@ export abstract class AbstractChatModel implements IChatModel { if (this._config.stackMessages) { this._messages.slice(1).forEach((message, idx) => { const previousUser = this._messages[idx].sender.username; - message.stacked = previousUser === message.sender.username; + message.update({ stacked: previousUser === message.sender.username }); }); } else { this._messages.forEach(message => { - delete message.stacked; + message.update({ stacked: undefined }); }); } this._messagesUpdated.emit(); @@ -450,7 +452,7 @@ export abstract class AbstractChatModel implements IChatModel { } /** - * A signal emitting when the messages list is updated. + * A signal emitting when the message list is updated. */ get messagesUpdated(): ISignal { return this._messagesUpdated; @@ -532,7 +534,7 @@ export abstract class AbstractChatModel implements IChatModel { * A function called before transferring the message to the panel(s). * Can be useful if some actions are required on the message. */ - protected formatChatMessage(message: IChatMessage): IChatMessage { + protected formatChatMessage(message: IMessageContent): IMessageContent { message.mentions?.forEach(user => { message.body = replaceMentionToSpan(message.body, user); }); @@ -544,7 +546,7 @@ export abstract class AbstractChatModel implements IChatModel { * * @param message - the message with user information and body. */ - messageAdded(message: IChatMessage): void { + messageAdded(message: IMessageContent): void { const messageIndex = this._messages.findIndex(msg => msg.id === message.id); if (messageIndex > -1) { // The message is an update of an existing one. @@ -567,15 +569,16 @@ export abstract class AbstractChatModel implements IChatModel { * @param index - the index of the first message of the list. * @param messages - the messages list. */ - messagesInserted(index: number, messages: IChatMessage[]): void { - const formattedMessages: IChatMessage[] = []; + messagesInserted(index: number, messages: IMessageContent[]): void { + const formattedMessages: IMessage[] = []; const unreadIndexes: number[] = []; const lastRead = this.lastRead ?? 0; // Format the messages. messages.forEach((message, idx) => { - formattedMessages.push(this.formatChatMessage(message)); + const formattedMessage = this.formatChatMessage(message); + formattedMessages.push(new Message(formattedMessage)); if (message.time > lastRead) { unreadIndexes.push(index + idx); } @@ -593,7 +596,7 @@ export abstract class AbstractChatModel implements IChatModel { for (let idx = start; idx <= end; idx++) { const message = this._messages[idx]; const previousUser = this._messages[idx - 1].sender.username; - message.stacked = previousUser === message.sender.username; + message.update({ stacked: previousUser === message.sender.username }); } } @@ -713,7 +716,7 @@ export abstract class AbstractChatModel implements IChatModel { } } - private _messages: IChatMessage[] = []; + private _messages: IMessage[] = []; private _unreadMessages: number[] = []; private _messagesInViewport: number[] = []; private _id: string | undefined; @@ -821,9 +824,9 @@ export interface IChatContext { */ readonly name: string; /** - * A copy of the messages. + * A copy of the messages content. */ - readonly messages: IChatMessage[]; + readonly messages: IMessageContent[]; /** * A list of all users who have connected to this chat. */ @@ -847,8 +850,8 @@ export abstract class AbstractChatContext implements IChatContext { return this._model.name; } - get messages(): IChatMessage[] { - return [...this._model.messages]; + get messages(): IMessageContent[] { + return this._model.messages.map(message => ({ ...message.content })); } get user(): IUser | undefined { diff --git a/packages/jupyter-chat/src/registers/footers.ts b/packages/jupyter-chat/src/registers/footers.ts index 12dcf888..664bb348 100644 --- a/packages/jupyter-chat/src/registers/footers.ts +++ b/packages/jupyter-chat/src/registers/footers.ts @@ -5,7 +5,7 @@ import { Token } from '@lumino/coreutils'; import { IChatModel } from '../model'; -import { IChatMessage } from '../types'; +import { IMessageContent } from '../types'; /** * The token providing the chat footer registry. @@ -35,7 +35,7 @@ export interface IMessageFooterRegistry { */ export type MessageFooterSectionProps = { model: IChatModel; - message: IChatMessage; + message: IMessageContent; }; /** diff --git a/packages/jupyter-chat/src/types.ts b/packages/jupyter-chat/src/types.ts index 808b4a51..d45ba132 100644 --- a/packages/jupyter-chat/src/types.ts +++ b/packages/jupyter-chat/src/types.ts @@ -3,6 +3,8 @@ * Distributed under the terms of the Modified BSD License. */ +import { ISignal } from '@lumino/signaling'; + /** * The user description. */ @@ -57,8 +59,8 @@ export interface IConfig { /** * The chat message description. */ -export interface IChatMessage { - type: 'msg'; +export type IMessageContent = { + type: string; body: string; id: string; time: number; @@ -69,13 +71,31 @@ export interface IChatMessage { deleted?: boolean; edited?: boolean; stacked?: boolean; +}; + +/** + * + */ +export interface IMessage extends IMessageContent { + /** + * Update one or several fields of the message. + */ + update(updated: Partial): void; + /** + * The message content. + */ + content: IMessageContent; + /** + * A signal emitting when the message has been updated. + */ + changed: ISignal; } /** * The chat history interface. */ export interface IChatHistory { - messages: IChatMessage[]; + messages: IMessageContent[]; } /** diff --git a/packages/jupyterlab-chat/src/model.ts b/packages/jupyterlab-chat/src/model.ts index 2ab14afb..bf8563b3 100644 --- a/packages/jupyterlab-chat/src/model.ts +++ b/packages/jupyterlab-chat/src/model.ts @@ -8,9 +8,9 @@ import { AbstractChatContext, IAttachment, IChatContext, - IChatMessage, IChatModel, IInputModel, + IMessageContent, INewMessage, IUser } from '@jupyter/chat'; @@ -189,7 +189,7 @@ export class LabChatModel async messagesInserted( index: number, - messages: IChatMessage[] + messages: IMessageContent[] ): Promise { // Ensure the chat has an ID before inserting the messages, to properly catch the // unread messages (the last read message is saved using the chat ID). @@ -246,7 +246,7 @@ export class LabChatModel updateMessage( id: string, - updatedMessage: IChatMessage + updatedMessage: IMessageContent ): Promise | boolean | void { const index = this.sharedModel.getMessageIndex(id); let message = this.sharedModel.getMessage(index); @@ -398,8 +398,8 @@ export class LabChatModel } private _onchange = async (_: YChat, changes: IChatChanges) => { - if (changes.messageChanges) { - const msgDelta = changes.messageChanges; + if (changes.messageListChanges) { + const msgDelta = changes.messageListChanges; let index = 0; for (const delta of msgDelta) { if (delta.retain) { @@ -411,10 +411,10 @@ export class LabChatModel attachments: attachmentIds, mentions: mentionsIds, ...baseMessage - } = ymessage; + } = ymessage.toJSON() as IYmessage; // Build the base message with sender. - const msg: IChatMessage = { + const msg: IMessageContent = { ...baseMessage, sender: this.sharedModel.getUser(sender) || { username: 'User undefined', @@ -457,6 +457,54 @@ export class LabChatModel } } + if (changes.messageChanges) { + // Update change in the message. + changes.messageChanges.forEach(change => { + const message = this.messages[change.index]; + if (change.type === 'remove') { + delete message[change.key as keyof IMessageContent]; + } else if (change.newValue !== undefined) { + const key = change.key; + const value = change.newValue; + if (key === 'attachments') { + const attachments: IAttachment[] = []; + (value as string[]).forEach(attachmentId => { + const attachment = this.sharedModel.getAttachment(attachmentId); + if (attachment) { + attachments.push(attachment); + } + }); + if (attachments.length) { + message.update({ attachments }); + } else { + message.update({ attachments: undefined }); + } + } else if (key === 'mentions') { + const mentions: IUser[] = (value as string[]).map( + user => + this.sharedModel.getUser(user) || { + username: 'User undefined', + mention_name: 'User-undefined' + } + ); + if (mentions?.length) { + message.update({ mentions }); + } + } else if ( + ['body', 'time', 'raw_time', 'deleted', 'edited'].includes(key) + ) { + const update: Partial = {}; + update[key as keyof IMessageContent] = value; + message.update(update); + } else { + console.error( + `The attribute '${key}' of message cannot be updated` + ); + } + } + }); + } + if (changes.metadataChanges) { changes.metadataChanges.forEach(change => { // no need to search for update or add, if the new value contains ID, let's diff --git a/packages/jupyterlab-chat/src/ychat.ts b/packages/jupyterlab-chat/src/ychat.ts index 0251fff0..2c988f7d 100644 --- a/packages/jupyterlab-chat/src/ychat.ts +++ b/packages/jupyterlab-chat/src/ychat.ts @@ -3,7 +3,7 @@ * Distributed under the terms of the Modified BSD License. */ -import { IAttachment, IChatMessage, IUser } from '@jupyter/chat'; +import { IAttachment, IMessageContent, IUser } from '@jupyter/chat'; import { Delta, DocumentChange, IMapChange, YDocument } from '@jupyter/ydoc'; import { JSONExt, JSONObject, PartialJSONValue, UUID } from '@lumino/coreutils'; import * as Y from 'yjs'; @@ -11,7 +11,7 @@ import * as Y from 'yjs'; /** * The type for a YMessage. */ -export type IYmessage = IChatMessage; +export type IYmessage = IMessageContent; /** * The type for a YMessage. @@ -25,7 +25,11 @@ export interface IChatChanges extends DocumentChange { /** * Changes in messages. */ - messageChanges?: MessageChange; + messageListChanges?: MessageListChange; + /** + * Changes of one message. + */ + messageChanges?: MessageChange[]; /** * Changes in users. */ @@ -40,10 +44,15 @@ export interface IChatChanges extends DocumentChange { metadataChanges?: MetadataChange[]; } +/** + * The message list change type. + */ +export type MessageListChange = Delta[]>; + /** * The message change type. */ -export type MessageChange = Delta; +export type MessageChange = IMapChange & { index: number }; /** * The user change type. @@ -72,8 +81,8 @@ export class YChat extends YDocument { this._users = this.ydoc.getMap('users'); this._users.observe(this._usersObserver); - this._messages = this.ydoc.getArray('messages'); - this._messages.observe(this._messagesObserver); + this._messages = this.ydoc.getArray>('messages'); + this._messages.observeDeep(this._messagesObserver); this._attachments = this.ydoc.getMap('attachments'); this._attachments.observe(this._attachmentsObserver); @@ -132,9 +141,15 @@ export class YChat extends YDocument { this.transact(() => { const messages = (value['messages'] as unknown as Array) ?? []; + const ymessages: Y.Map[] = []; messages.forEach(message => { - this._messages.push([message]); + const ymessage = new Y.Map(); + for (const [key, value] of Object.entries(message)) { + ymessage.set(key, value); + } + ymessages.push(ymessage); }); + this._messages.push(ymessages); const users = value['users'] ?? {}; Object.entries(users).forEach(([key, val]) => @@ -167,24 +182,32 @@ export class YChat extends YDocument { } getMessage(index: number): IYmessage | undefined { - return this._messages.get(index); + return this._messages.get(index).toJSON() as IYmessage; } - addMessage(value: IYmessage): void { + addMessage(msg: IYmessage): void { this.transact(() => { - this._messages.push([value]); + const ymessage = new Y.Map(); + for (const [key, value] of Object.entries(msg)) { + ymessage.set(key, value); + } + this._messages.push([ymessage]); }); } - updateMessage(index: number, value: IYmessage): void { + updateMessage(index: number, msg: IYmessage): void { this.transact(() => { - this._messages.delete(index); - this._messages.insert(index, [value]); + const original = this._messages.get(index); + for (const [key, value] of Object.entries(msg)) { + if (original.get(key) !== value) { + original.set(key, value); + } + } }); } getMessageIndex(id: string): number { - return this._messages.toArray().findIndex(msg => msg.id === id); + return this._messages.toArray().findIndex(msg => msg.get('id') === id); } deleteMessage(index: number): void { @@ -258,14 +281,60 @@ export class YChat extends YDocument { } }); - this._changed.emit({ userChanges } as Partial); + this._changed.emit({ userChanges }); }; - private _messagesObserver = (event: Y.YArrayEvent): void => { - const messageChanges = event.delta; - this._changed.emit({ - messageChanges: messageChanges - } as Partial); + private _messagesObserver = ( + events: (Y.YArrayEvent> | Y.YMapEvent)[] + ): void => { + events.forEach(event => { + if (event instanceof Y.YArrayEvent) { + // Change on the message list. + const messageListChanges = event.delta; + this._changed.emit({ + messageListChanges + } as Partial); + } else if (event instanceof Y.YMapEvent) { + // Change on existing message(s), let's update the whole message. + const messageChanges = new Array(); + event.keysChanged.forEach(key => { + const change = event.changes.keys.get(key); + if (change) { + const index = this.getMessageIndex(event.target.get('id')); + switch (change.action) { + case 'add': + messageChanges.push({ + index, + key, + newValue: event.target.get(key), + type: 'add' + }); + break; + case 'delete': + messageChanges.push({ + index, + key, + oldValue: change.oldValue, + type: 'remove' + }); + break; + case 'update': + messageChanges.push({ + index, + key: key, + oldValue: change.oldValue, + newValue: event.target.get(key), + type: 'change' + }); + break; + } + } + }); + this._changed.emit({ + messageChanges + }); + } + }); }; private _attachmentsObserver = (event: Y.YMapEvent): void => { @@ -300,7 +369,7 @@ export class YChat extends YDocument { } }); - this._changed.emit({ attachmentChanges } as Partial); + this._changed.emit({ attachmentChanges }); }; private _metadataObserver = (event: Y.YMapEvent): void => { @@ -332,11 +401,11 @@ export class YChat extends YDocument { } }); - this._changed.emit({ metadataChanges } as Partial); + this._changed.emit({ metadataChanges }); }; private _users: Y.Map; - private _messages: Y.Array; + private _messages: Y.Array>; private _attachments: Y.Map; private _metadata: Y.Map; } diff --git a/python/jupyterlab-chat/jupyterlab_chat/ychat.py b/python/jupyterlab-chat/jupyterlab_chat/ychat.py index a89e4fe0..288a0fcc 100644 --- a/python/jupyterlab-chat/jupyterlab_chat/ychat.py +++ b/python/jupyterlab-chat/jupyterlab_chat/ychat.py @@ -137,7 +137,7 @@ def add_message(self, new_message: NewMessage) -> str: ) # find all mentioned users and add them as message mentions - mention_pattern = re.compile("@([\w-]+):?") + mention_pattern = re.compile(r"@([\w-]+):?") mentioned_names: Set[str] = set(re.findall(mention_pattern, message.body)) users = self.get_users() mentioned_usernames = [] @@ -150,23 +150,34 @@ def add_message(self, new_message: NewMessage) -> str: index = len(self._ymessages) - next((i for i, v in enumerate(self._get_messages()[::-1]) if v["time"] < timestamp), len(self._ymessages)) self._ymessages.insert( index, - asdict(message, dict_factory=message_asdict_factory) + Map(asdict(message, dict_factory=message_asdict_factory)) ) return uid - def update_message(self, message: Message, append: bool = False): + def update_message(self, update: Message, append: bool = False): """ Update a message of the document. If append is True, the content will be append to the previous content. """ with self._ydoc.transaction(): - index = self._indexes_by_id[message.id] - initial_message = self._ymessages[index] - message.time = initial_message["time"] # type:ignore[index] - if append: - message.body = initial_message["body"] + message.body # type:ignore[index] - self._ymessages[index] = asdict(message, dict_factory=message_asdict_factory) + try: + index = self._indexes_by_id[update.id] + message = self._ymessages[index] + except (KeyError, IndexError) as e: + print(f"Error while updating the message:\n{e}") + return + update_dict = asdict(update) + if (update.body and append): + update_dict["body"] = message.get("body") + update.body + + # Only update the changed values. + for key in update_dict: + if key in message: + if message[key] != update_dict[key]: + message.update({ key: update_dict[key] }) + elif update_dict[key] is not None: + message.update({ key: update_dict[key] }) def get_attachments(self) -> dict[str, Union[FileAttachment, NotebookAttachment]]: """ @@ -279,7 +290,7 @@ def set(self, value: str) -> None: self._yattachments.update({k: v}) if "messages" in contents.keys(): - self._ymessages.extend(contents["messages"]) + self._ymessages.extend([Map(message) for message in contents["messages"]]) if "metadata" in contents.keys(): for k, v in contents["metadata"].items(): @@ -291,7 +302,7 @@ def observe(self, callback: Callable[[str, Any], None]) -> None: self._subscriptions[self._ymetadata] = self._ymetadata.observe( partial(callback, "metadata") ) - self._subscriptions[self._ymessages] = self._ymessages.observe( + self._subscriptions[self._ymessages] = self._ymessages.observe_deep( partial(callback, "messages") ) self._subscriptions[self._yusers] = self._yusers.observe(partial(callback, "users")) @@ -357,19 +368,17 @@ async def _set_timestamp(self, msg_idx: int, timestamp: float): with self._ydoc.transaction(): # Remove the message from the list and modify the timestamp try: - message_dict = self._ymessages[msg_idx] + message = self._ymessages[msg_idx] except IndexError: return - message_dict["time"] = timestamp # type:ignore[index] - message_dict["raw_time"] = False # type:ignore[index] - self._ymessages[msg_idx] = message_dict + message.update({"time": timestamp, "raw_time": False}) # type:ignore[index] # Move the message at the correct position in the list, looking first at the end, since the message # should be the last one. # The next() function below return the index of the first message with a timestamp inferior of the # current one, starting from the end of the list. - new_idx = len(self._ymessages) - next((i for i, v in enumerate(self._get_messages()[::-1]) if v["time"] < timestamp), len(self._ymessages)) + new_idx = len(self._ymessages) - next((i for i, v in enumerate(self._get_messages()[::-1]) if v.get("time", 0) < timestamp), len(self._ymessages)) if msg_idx != new_idx: - message_dict = self._ymessages.pop(msg_idx) - self._ymessages.insert(new_idx, message_dict) + message = self._ymessages.pop(msg_idx) + self._ymessages.insert(new_idx, message)