Skip to content

Commit e268849

Browse files
authored
Display user writing notification when editing a message (#208)
* Send the message ID in the writer state when the user edit a message * Add test on typing notification when editing a message * Allow several editions in a chat * Dispose of the message edition input model when sending the message * Keep the writer list in the model and emit the signal only if the list changed * Update docstring
1 parent 12393ed commit e268849

File tree

7 files changed

+227
-49
lines changed

7 files changed

+227
-49
lines changed

packages/jupyter-chat/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,7 @@
5454
"@jupyterlab/notebook": "^4.2.0",
5555
"@jupyterlab/rendermime": "^4.2.0",
5656
"@jupyterlab/ui-components": "^4.2.0",
57+
"@lumino/algorithm": "^2.0.0",
5758
"@lumino/commands": "^2.0.0",
5859
"@lumino/coreutils": "^2.0.0",
5960
"@lumino/disposable": "^2.0.0",

packages/jupyter-chat/src/components/chat-messages.tsx

Lines changed: 33 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -111,8 +111,8 @@ export function ChatMessages(props: BaseMessageProps): JSX.Element {
111111
setMessages([...model.messages]);
112112
}
113113

114-
function handleWritersChange(_: IChatModel, writers: IUser[]) {
115-
setCurrentWriters(writers);
114+
function handleWritersChange(_: IChatModel, writers: IChatModel.IWriter[]) {
115+
setCurrentWriters(writers.map(writer => writer.user));
116116
}
117117

118118
model.messagesUpdated.connect(handleChatEvents);
@@ -381,10 +381,10 @@ export const ChatMessage = forwardRef<HTMLDivElement, ChatMessageProps>(
381381
const [deleted, setDeleted] = useState<boolean>(false);
382382
const [canEdit, setCanEdit] = useState<boolean>(false);
383383
const [canDelete, setCanDelete] = useState<boolean>(false);
384-
const [inputModel, setInputModel] = useState<IInputModel | null>(null);
385384

386385
// Look if the message can be deleted or edited.
387386
useEffect(() => {
387+
// Init canDelete and canEdit state.
388388
setDeleted(message.deleted ?? false);
389389
if (model.user !== undefined && !message.deleted) {
390390
if (model.user.username === message.sender.username) {
@@ -402,36 +402,36 @@ export const ChatMessage = forwardRef<HTMLDivElement, ChatMessageProps>(
402402
}, [model, message]);
403403

404404
// Create an input model only if the message is edited.
405-
useEffect(() => {
406-
if (edit && canEdit) {
407-
setInputModel(() => {
408-
let body = message.body;
409-
message.mentions?.forEach(user => {
410-
body = replaceSpanToMention(body, user);
411-
});
412-
return new InputModel({
413-
chatContext: model.createChatContext(),
414-
onSend: (input: string, model?: IInputModel) =>
415-
updateMessage(message.id, input, model),
416-
onCancel: () => cancelEdition(),
417-
value: body,
418-
activeCellManager: model.activeCellManager,
419-
selectionWatcher: model.selectionWatcher,
420-
documentManager: model.documentManager,
421-
config: {
422-
sendWithShiftEnter: model.config.sendWithShiftEnter
423-
},
424-
attachments: message.attachments,
425-
mentions: message.mentions
426-
});
427-
});
428-
} else {
429-
setInputModel(null);
405+
const startEdition = (): void => {
406+
if (!canEdit) {
407+
return;
430408
}
431-
}, [edit]);
409+
let body = message.body;
410+
message.mentions?.forEach(user => {
411+
body = replaceSpanToMention(body, user);
412+
});
413+
const inputModel = new InputModel({
414+
chatContext: model.createChatContext(),
415+
onSend: (input: string, model?: IInputModel) =>
416+
updateMessage(message.id, input, model),
417+
onCancel: () => cancelEdition(),
418+
value: body,
419+
activeCellManager: model.activeCellManager,
420+
selectionWatcher: model.selectionWatcher,
421+
documentManager: model.documentManager,
422+
config: {
423+
sendWithShiftEnter: model.config.sendWithShiftEnter
424+
},
425+
attachments: message.attachments,
426+
mentions: message.mentions
427+
});
428+
model.addEditionModel(message.id, inputModel);
429+
setEdit(true);
430+
};
432431

433432
// Cancel the current edition of the message.
434433
const cancelEdition = (): void => {
434+
model.getEditionModel(message.id)?.dispose();
435435
setEdit(false);
436436
};
437437

@@ -450,6 +450,7 @@ export const ChatMessage = forwardRef<HTMLDivElement, ChatMessageProps>(
450450
updatedMessage.attachments = inputModel.attachments;
451451
updatedMessage.mentions = inputModel.mentions;
452452
model.updateMessage!(id, updatedMessage);
453+
model.getEditionModel(message.id)?.dispose();
453454
setEdit(false);
454455
};
455456

@@ -466,10 +467,10 @@ export const ChatMessage = forwardRef<HTMLDivElement, ChatMessageProps>(
466467
<div ref={ref} data-index={props.index}></div>
467468
) : (
468469
<div ref={ref} data-index={props.index}>
469-
{edit && canEdit && inputModel ? (
470+
{edit && canEdit && model.getEditionModel(message.id) ? (
470471
<ChatInput
471472
onCancel={() => cancelEdition()}
472-
model={inputModel}
473+
model={model.getEditionModel(message.id)!}
473474
chatCommandRegistry={props.chatCommandRegistry}
474475
toolbarRegistry={props.inputToolbarRegistry}
475476
/>
@@ -478,7 +479,7 @@ export const ChatMessage = forwardRef<HTMLDivElement, ChatMessageProps>(
478479
rmRegistry={rmRegistry}
479480
markdownStr={message.body}
480481
model={model}
481-
edit={canEdit ? () => setEdit(true) : undefined}
482+
edit={canEdit ? startEdition : undefined}
482483
delete={canDelete ? () => deleteMessage(message.id) : undefined}
483484
rendered={props.renderedPromise}
484485
/>

packages/jupyter-chat/src/input-model.ts

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -147,6 +147,11 @@ export interface IInputModel extends IDisposable {
147147
* Clear mentions list.
148148
*/
149149
clearMentions(): void;
150+
151+
/**
152+
* A signal emitting when disposing of the model.
153+
*/
154+
readonly onDisposed: ISignal<InputModel, void>;
150155
}
151156

152157
/**
@@ -411,9 +416,17 @@ export class InputModel implements IInputModel {
411416
if (this.isDisposed) {
412417
return;
413418
}
419+
this._onDisposed.emit();
414420
this._isDisposed = true;
415421
}
416422

423+
/**
424+
* A signal emitting when disposing of the model.
425+
*/
426+
get onDisposed(): ISignal<InputModel, void> {
427+
return this._onDisposed;
428+
}
429+
417430
/**
418431
* Whether the input model is disposed.
419432
*/
@@ -438,6 +451,7 @@ export class InputModel implements IInputModel {
438451
private _configChanged = new Signal<IInputModel, InputModel.IConfig>(this);
439452
private _focusInputSignal = new Signal<InputModel, void>(this);
440453
private _attachmentsChanged = new Signal<InputModel, IAttachment[]>(this);
454+
private _onDisposed = new Signal<InputModel, void>(this);
441455
private _isDisposed = false;
442456
}
443457

packages/jupyter-chat/src/model.ts

Lines changed: 108 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
*/
55

66
import { IDocumentManager } from '@jupyterlab/docmanager';
7+
import { ArrayExt } from '@lumino/algorithm';
78
import { CommandRegistry } from '@lumino/commands';
89
import { IDisposable } from '@lumino/disposable';
910
import { ISignal, Signal } from '@lumino/signaling';
@@ -59,6 +60,11 @@ export interface IChatModel extends IDisposable {
5960
*/
6061
readonly input: IInputModel;
6162

63+
/**
64+
* The current writer list.
65+
*/
66+
readonly writers: IChatModel.IWriter[];
67+
6268
/**
6369
* Get the active cell manager.
6470
*/
@@ -82,7 +88,7 @@ export interface IChatModel extends IDisposable {
8288
/**
8389
* A signal emitting when the messages list is updated.
8490
*/
85-
get configChanged(): ISignal<IChatModel, IConfig>;
91+
readonly configChanged: ISignal<IChatModel, IConfig>;
8692

8793
/**
8894
* A signal emitting when unread messages change.
@@ -97,7 +103,12 @@ export interface IChatModel extends IDisposable {
97103
/**
98104
* A signal emitting when the writers change.
99105
*/
100-
readonly writersChanged?: ISignal<IChatModel, IUser[]>;
106+
readonly writersChanged?: ISignal<IChatModel, IChatModel.IWriter[]>;
107+
108+
/**
109+
* A signal emitting when the message edition input changed change.
110+
*/
111+
readonly messageEditionAdded: ISignal<IChatModel, IChatModel.IMessageEdition>;
101112

102113
/**
103114
* Send a message, to be defined depending on the chosen technology.
@@ -172,12 +183,22 @@ export interface IChatModel extends IDisposable {
172183
/**
173184
* Update the current writers list.
174185
*/
175-
updateWriters(writers: IUser[]): void;
186+
updateWriters(writers: IChatModel.IWriter[]): void;
176187

177188
/**
178189
* Create the chat context that will be passed to the input model.
179190
*/
180191
createChatContext(): IChatContext;
192+
193+
/**
194+
* Get the input model of the edited message, given its id.
195+
*/
196+
getEditionModel(messageID: string): IInputModel | undefined;
197+
198+
/**
199+
* Add an input model of the edited message.
200+
*/
201+
addEditionModel(messageID: string, inputModel: IInputModel): void;
181202
}
182203

183204
/**
@@ -256,6 +277,13 @@ export abstract class AbstractChatModel implements IChatModel {
256277
return this._inputModel;
257278
}
258279

280+
/**
281+
* The current writer list.
282+
*/
283+
get writers(): IChatModel.IWriter[] {
284+
return this._writers;
285+
}
286+
259287
/**
260288
* Get the active cell manager.
261289
*/
@@ -418,10 +446,17 @@ export abstract class AbstractChatModel implements IChatModel {
418446
/**
419447
* A signal emitting when the writers change.
420448
*/
421-
get writersChanged(): ISignal<IChatModel, IUser[]> {
449+
get writersChanged(): ISignal<IChatModel, IChatModel.IWriter[]> {
422450
return this._writersChanged;
423451
}
424452

453+
/**
454+
* A signal emitting when the message edition input changed change.
455+
*/
456+
get messageEditionAdded(): ISignal<IChatModel, IChatModel.IMessageEdition> {
457+
return this._messageEditionAdded;
458+
}
459+
425460
/**
426461
* Send a message, to be defined depending on the chosen technology.
427462
* Default to no-op.
@@ -546,15 +581,47 @@ export abstract class AbstractChatModel implements IChatModel {
546581
* Update the current writers list.
547582
* This implementation only propagate the list via a signal.
548583
*/
549-
updateWriters(writers: IUser[]): void {
550-
this._writersChanged.emit(writers);
584+
updateWriters(writers: IChatModel.IWriter[]): void {
585+
const compareWriters = (a: IChatModel.IWriter, b: IChatModel.IWriter) => {
586+
return (
587+
a.user.username === b.user.username &&
588+
a.user.display_name === b.user.display_name &&
589+
a.messageID === b.messageID
590+
);
591+
};
592+
if (!ArrayExt.shallowEqual(this._writers, writers, compareWriters)) {
593+
this._writers = writers;
594+
this._writersChanged.emit(writers);
595+
}
551596
}
552597

553598
/**
554599
* Create the chat context that will be passed to the input model.
555600
*/
556601
abstract createChatContext(): IChatContext;
557602

603+
/**
604+
* Get the input model of the edited message, given its id.
605+
*/
606+
getEditionModel(messageID: string): IInputModel | undefined {
607+
return this._messageEditions.get(messageID);
608+
}
609+
610+
/**
611+
* Add an input model of the edited message.
612+
*/
613+
addEditionModel(messageID: string, inputModel: IInputModel): void {
614+
// Dispose of an hypothetic previous model for this message.
615+
this.getEditionModel(messageID)?.dispose();
616+
617+
this._messageEditions.set(messageID, inputModel);
618+
this._messageEditionAdded.emit({ id: messageID, model: inputModel });
619+
620+
inputModel.onDisposed.connect(() => {
621+
this._messageEditions.delete(messageID);
622+
});
623+
}
624+
558625
/**
559626
* Add unread messages to the list.
560627
* @param indexes - list of new indexes.
@@ -617,11 +684,17 @@ export abstract class AbstractChatModel implements IChatModel {
617684
private _selectionWatcher: ISelectionWatcher | null;
618685
private _documentManager: IDocumentManager | null;
619686
private _notificationId: string | null = null;
687+
private _writers: IChatModel.IWriter[] = [];
688+
private _messageEditions = new Map<string, IInputModel>();
620689
private _messagesUpdated = new Signal<IChatModel, void>(this);
621690
private _configChanged = new Signal<IChatModel, IConfig>(this);
622691
private _unreadChanged = new Signal<IChatModel, number[]>(this);
623692
private _viewportChanged = new Signal<IChatModel, number[]>(this);
624-
private _writersChanged = new Signal<IChatModel, IUser[]>(this);
693+
private _writersChanged = new Signal<IChatModel, IChatModel.IWriter[]>(this);
694+
private _messageEditionAdded = new Signal<
695+
IChatModel,
696+
IChatModel.IMessageEdition
697+
>(this);
625698
}
626699

627700
/**
@@ -662,6 +735,34 @@ export namespace IChatModel {
662735
*/
663736
documentManager?: IDocumentManager | null;
664737
}
738+
739+
/**
740+
* Representation of a message edition.
741+
*/
742+
export interface IMessageEdition {
743+
/**
744+
* The id of the edited message.
745+
*/
746+
id: string;
747+
/**
748+
* The model of the input editing the message.
749+
*/
750+
model: IInputModel;
751+
}
752+
753+
/**
754+
* Writer interface, including the message ID if the writer is editing a message.
755+
*/
756+
export interface IWriter {
757+
/**
758+
* The user currently writing.
759+
*/
760+
user: IUser;
761+
/**
762+
* The message ID (optional)
763+
*/
764+
messageID?: string;
765+
}
665766
}
666767

667768
/**

0 commit comments

Comments
 (0)