@jupyter/chat 0.20.0-alpha.0 → 0.20.0-alpha.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -17,6 +17,9 @@ export function MessageFooterComponent(props) {
17
17
  return null;
18
18
  }
19
19
  const footer = messageFooterRegistry.getFooter();
20
+ if (!footer.left && !footer.center && !footer.right) {
21
+ return null;
22
+ }
20
23
  return (React.createElement(Box, { sx: { display: 'flex', justifyContent: 'space-between' } },
21
24
  ((_a = footer.left) === null || _a === void 0 ? void 0 : _a.component) ? (React.createElement(footer.left.component, { message: message, model: model })) : (React.createElement("div", null)),
22
25
  ((_b = footer.center) === null || _b === void 0 ? void 0 : _b.component) ? (React.createElement(footer.center.component, { message: message, model: model })) : (React.createElement("div", null)),
@@ -4,7 +4,7 @@
4
4
  */
5
5
  import { MessageLoop } from '@lumino/messaging';
6
6
  import { Widget } from '@lumino/widgets';
7
- import React, { useState, useEffect, useRef } from 'react';
7
+ import React, { useState, useEffect } from 'react';
8
8
  import { createPortal } from 'react-dom';
9
9
  import { MessageToolbar } from './toolbar';
10
10
  import { CodeToolbar } from '../code-blocks/code-toolbar';
@@ -18,17 +18,13 @@ const DEFAULT_MIME_TYPE = 'text/markdown';
18
18
  function MessageRendererBase(props) {
19
19
  const { message } = props;
20
20
  const { model, rmRegistry } = useChatContext();
21
- const containerRef = useRef(null);
21
+ // The rendered content, return by the mime renderer.
22
+ const [renderedContent, setRenderedContent] = useState(null);
22
23
  // Allow edition only on text messages.
23
24
  const [canEdit, setCanEdit] = useState(false);
24
25
  // Each element is a two-tuple with the structure [codeToolbarRoot, codeToolbarProps].
25
26
  const [codeToolbarDefns, setCodeToolbarDefns] = useState([]);
26
27
  useEffect(() => {
27
- let node = null;
28
- const container = containerRef.current;
29
- if (!container) {
30
- return;
31
- }
32
28
  const renderContent = async () => {
33
29
  var _a, _b;
34
30
  let isMarkdownRenderer = true;
@@ -106,21 +102,15 @@ function MessageRendererBase(props) {
106
102
  });
107
103
  setCodeToolbarDefns(newCodeToolbarDefns);
108
104
  }
109
- // Add the rendered node to the DOM.
110
- node = renderer.node;
111
- container.insertBefore(node, container.firstChild);
105
+ // Update the content.
106
+ setRenderedContent(renderer.node);
112
107
  // Resolve the rendered promise.
113
108
  props.rendered.resolve();
114
109
  };
115
110
  renderContent();
116
- return () => {
117
- if (node && container.contains(node)) {
118
- container.removeChild(node);
119
- }
120
- node = null;
121
- };
122
111
  }, [message.body, message.mentions, rmRegistry]);
123
- return (React.createElement("div", { className: RENDERED_CLASS, ref: containerRef },
112
+ return (React.createElement(React.Fragment, null,
113
+ renderedContent && (React.createElement("div", { className: RENDERED_CLASS, ref: node => node && node.replaceChildren(renderedContent) })),
124
114
  React.createElement(MessageToolbar, { edit: canEdit ? props.edit : undefined, delete: props.delete }),
125
115
  // Render a `CodeToolbar` element underneath each code block.
126
116
  // We use ReactDOM.createPortal() so each `CodeToolbar` element is able
@@ -9,6 +9,7 @@ import { ChatInput } from '../input';
9
9
  import { useChatContext } from '../../context';
10
10
  import { InputModel } from '../../input-model';
11
11
  import { replaceSpanToMention } from '../../utils';
12
+ const MESSAGE_CONTAINER_CLASS = 'jp-chat-message-container';
12
13
  /**
13
14
  * The message component body.
14
15
  */
@@ -56,7 +57,7 @@ export const ChatMessage = forwardRef((props, ref) => {
56
57
  }, [props.message]);
57
58
  // Create an input model only if the message is edited.
58
59
  const startEdition = () => {
59
- var _a;
60
+ var _a, _b;
60
61
  if (!canEdit || !(typeof message.body === 'string')) {
61
62
  return;
62
63
  }
@@ -75,7 +76,7 @@ export const ChatMessage = forwardRef((props, ref) => {
75
76
  config: {
76
77
  sendWithShiftEnter: model.config.sendWithShiftEnter
77
78
  },
78
- attachments: message.attachments,
79
+ attachments: structuredClone((_b = message.attachments) !== null && _b !== void 0 ? _b : []),
79
80
  mentions: message.mentions
80
81
  });
81
82
  model.addEditionModel(message.id, inputModel);
@@ -110,7 +111,7 @@ export const ChatMessage = forwardRef((props, ref) => {
110
111
  model.deleteMessage(id);
111
112
  };
112
113
  // Empty if the message has been deleted.
113
- return deleted ? (React.createElement("div", { ref: ref, "data-index": props.index })) : (React.createElement("div", { ref: ref, "data-index": props.index },
114
+ return deleted ? (React.createElement("div", { ref: ref, "data-index": props.index })) : (React.createElement("div", { ref: ref, "data-index": props.index, className: MESSAGE_CONTAINER_CLASS },
114
115
  edit && canEdit && model.getEditionModel(message.id) ? (React.createElement(ChatInput, { onCancel: () => cancelEdition(), model: model.getEditionModel(message.id), edit: true })) : (React.createElement(MessageRenderer, { message: message, edit: canEdit ? startEdition : undefined, delete: canDelete ? () => deleteMessage(message.id) : undefined, rendered: props.renderedPromise })),
115
116
  message.attachments && !edit && (
116
117
  // Display the attachments only if message is not edited, otherwise the
package/lib/model.d.ts CHANGED
@@ -341,6 +341,7 @@ export declare abstract class AbstractChatModel implements IChatModel {
341
341
  private _notify;
342
342
  private _messages;
343
343
  private _unreadMessages;
344
+ private _lastRead;
344
345
  private _messagesInViewport;
345
346
  private _id;
346
347
  private _name;
package/lib/model.js CHANGED
@@ -21,6 +21,7 @@ export class AbstractChatModel {
21
21
  var _a, _b, _c, _d;
22
22
  this._messages = [];
23
23
  this._unreadMessages = [];
24
+ this._lastRead = 0;
24
25
  this._messagesInViewport = [];
25
26
  this._name = '';
26
27
  this._readyDelegate = new PromiseDelegate();
@@ -71,6 +72,12 @@ export class AbstractChatModel {
71
72
  }
72
73
  set id(value) {
73
74
  this._id = value;
75
+ // Update the last read message.
76
+ const storage = JSON.parse(localStorage.getItem(`@jupyter/chat:${this._id}`) || '{}');
77
+ if (typeof storage.lastRead === 'number' &&
78
+ storage.lastRead > this._lastRead) {
79
+ this._lastRead = storage.lastRead;
80
+ }
74
81
  }
75
82
  /**
76
83
  * The chat model name.
@@ -124,17 +131,14 @@ export class AbstractChatModel {
124
131
  * Timestamp of the last read message in local storage.
125
132
  */
126
133
  get lastRead() {
127
- var _a;
128
- if (this._id === undefined) {
129
- return 0;
130
- }
131
- const storage = JSON.parse(localStorage.getItem(`@jupyter/chat:${this._id}`) || '{}');
132
- return (_a = storage.lastRead) !== null && _a !== void 0 ? _a : 0;
134
+ return this._lastRead;
133
135
  }
134
136
  set lastRead(value) {
137
+ this._lastRead = value;
135
138
  if (this._id === undefined) {
136
139
  return;
137
140
  }
141
+ // Save the last read message to the local storage, for persistence across reload.
138
142
  const storage = JSON.parse(localStorage.getItem(`@jupyter/chat:${this._id}`) || '{}');
139
143
  storage.lastRead = value;
140
144
  localStorage.setItem(`@jupyter/chat:${this._id}`, JSON.stringify(storage));
@@ -192,17 +196,16 @@ export class AbstractChatModel {
192
196
  return this._unreadMessages;
193
197
  }
194
198
  set unreadMessages(unread) {
195
- var _a;
196
199
  const recentlyRead = this._unreadMessages.filter(elem => !unread.includes(elem));
197
200
  const unreadCountDiff = unread.length - this._unreadMessages.length;
198
201
  this._unreadMessages = unread;
199
202
  this._unreadChanged.emit(this._unreadMessages);
200
203
  // Notify the change.
201
204
  this._notify(unread.length, unreadCountDiff > 0);
202
- // Save the last read to the local storage.
203
- if (this._id !== undefined && recentlyRead.length) {
205
+ // Save the last read.
206
+ if (recentlyRead.length) {
204
207
  let lastReadChanged = false;
205
- let lastRead = (_a = this.lastRead) !== null && _a !== void 0 ? _a : this.messages[recentlyRead[0]].time;
208
+ let lastRead = this.lastRead;
206
209
  recentlyRead.forEach(index => {
207
210
  if (this.messages[index].time > lastRead) {
208
211
  lastRead = this.messages[index].time;
@@ -318,15 +321,13 @@ export class AbstractChatModel {
318
321
  * @param messages - the messages list.
319
322
  */
320
323
  messagesInserted(index, messages) {
321
- var _a;
322
324
  const formattedMessages = [];
323
325
  const unreadIndexes = [];
324
- const lastRead = (_a = this.lastRead) !== null && _a !== void 0 ? _a : 0;
325
326
  // Format the messages.
326
327
  messages.forEach((message, idx) => {
327
328
  const formattedMessage = this.formatChatMessage(message);
328
329
  formattedMessages.push(new Message(formattedMessage));
329
- if (message.time > lastRead) {
330
+ if (message.time > this.lastRead) {
330
331
  unreadIndexes.push(index + idx);
331
332
  }
332
333
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jupyter/chat",
3
- "version": "0.20.0-alpha.0",
3
+ "version": "0.20.0-alpha.1",
4
4
  "description": "A package that provides UI components that can be used to create a chat in a Jupyterlab extension.",
5
5
  "keywords": [
6
6
  "jupyter",
@@ -33,6 +33,10 @@ export function MessageFooterComponent(
33
33
  }
34
34
  const footer = messageFooterRegistry.getFooter();
35
35
 
36
+ if (!footer.left && !footer.center && !footer.right) {
37
+ return null;
38
+ }
39
+
36
40
  return (
37
41
  <Box sx={{ display: 'flex', justifyContent: 'space-between' }}>
38
42
  {footer.left?.component ? (
@@ -7,7 +7,7 @@ import { IRenderMime } from '@jupyterlab/rendermime';
7
7
  import { PromiseDelegate } from '@lumino/coreutils';
8
8
  import { MessageLoop } from '@lumino/messaging';
9
9
  import { Widget } from '@lumino/widgets';
10
- import React, { useState, useEffect, useRef } from 'react';
10
+ import React, { useState, useEffect } from 'react';
11
11
  import { createPortal } from 'react-dom';
12
12
 
13
13
  import { MessageToolbar } from './toolbar';
@@ -48,7 +48,10 @@ function MessageRendererBase(props: MessageRendererProps): JSX.Element {
48
48
  const { message } = props;
49
49
  const { model, rmRegistry } = useChatContext();
50
50
 
51
- const containerRef = useRef<HTMLDivElement>(null);
51
+ // The rendered content, return by the mime renderer.
52
+ const [renderedContent, setRenderedContent] = useState<HTMLElement | null>(
53
+ null
54
+ );
52
55
 
53
56
  // Allow edition only on text messages.
54
57
  const [canEdit, setCanEdit] = useState<boolean>(false);
@@ -59,12 +62,6 @@ function MessageRendererBase(props: MessageRendererProps): JSX.Element {
59
62
  >([]);
60
63
 
61
64
  useEffect(() => {
62
- let node: HTMLElement | null = null;
63
- const container = containerRef.current;
64
- if (!container) {
65
- return;
66
- }
67
-
68
65
  const renderContent = async () => {
69
66
  let isMarkdownRenderer = true;
70
67
  let renderer: IRenderMime.IRenderer;
@@ -153,26 +150,24 @@ function MessageRendererBase(props: MessageRendererProps): JSX.Element {
153
150
  setCodeToolbarDefns(newCodeToolbarDefns);
154
151
  }
155
152
 
156
- // Add the rendered node to the DOM.
157
- node = renderer.node;
158
- container.insertBefore(node, container.firstChild);
153
+ // Update the content.
154
+ setRenderedContent(renderer.node);
159
155
 
160
156
  // Resolve the rendered promise.
161
157
  props.rendered.resolve();
162
158
  };
163
159
 
164
160
  renderContent();
165
-
166
- return () => {
167
- if (node && container.contains(node)) {
168
- container.removeChild(node);
169
- }
170
- node = null;
171
- };
172
161
  }, [message.body, message.mentions, rmRegistry]);
173
162
 
174
163
  return (
175
- <div className={RENDERED_CLASS} ref={containerRef}>
164
+ <>
165
+ {renderedContent && (
166
+ <div
167
+ className={RENDERED_CLASS}
168
+ ref={node => node && node.replaceChildren(renderedContent)}
169
+ />
170
+ )}
176
171
  <MessageToolbar
177
172
  edit={canEdit ? props.edit : undefined}
178
173
  delete={props.delete}
@@ -189,7 +184,7 @@ function MessageRendererBase(props: MessageRendererProps): JSX.Element {
189
184
  );
190
185
  })
191
186
  }
192
- </div>
187
+ </>
193
188
  );
194
189
  }
195
190
 
@@ -14,6 +14,8 @@ import { IInputModel, InputModel } from '../../input-model';
14
14
  import { IMessageContent, IMessage } from '../../types';
15
15
  import { replaceSpanToMention } from '../../utils';
16
16
 
17
+ const MESSAGE_CONTAINER_CLASS = 'jp-chat-message-container';
18
+
17
19
  /**
18
20
  * The message component props.
19
21
  */
@@ -104,7 +106,7 @@ export const ChatMessage = forwardRef<HTMLDivElement, ChatMessageProps>(
104
106
  config: {
105
107
  sendWithShiftEnter: model.config.sendWithShiftEnter
106
108
  },
107
- attachments: message.attachments,
109
+ attachments: structuredClone(message.attachments ?? []),
108
110
  mentions: message.mentions
109
111
  });
110
112
  model.addEditionModel(message.id, inputModel);
@@ -148,7 +150,11 @@ export const ChatMessage = forwardRef<HTMLDivElement, ChatMessageProps>(
148
150
  return deleted ? (
149
151
  <div ref={ref} data-index={props.index}></div>
150
152
  ) : (
151
- <div ref={ref} data-index={props.index}>
153
+ <div
154
+ ref={ref}
155
+ data-index={props.index}
156
+ className={MESSAGE_CONTAINER_CLASS}
157
+ >
152
158
  {edit && canEdit && model.getEditionModel(message.id) ? (
153
159
  <ChatInput
154
160
  onCancel={() => cancelEdition()}
package/src/model.ts CHANGED
@@ -268,6 +268,17 @@ export abstract class AbstractChatModel implements IChatModel {
268
268
  }
269
269
  set id(value: string | undefined) {
270
270
  this._id = value;
271
+
272
+ // Update the last read message.
273
+ const storage = JSON.parse(
274
+ localStorage.getItem(`@jupyter/chat:${this._id}`) || '{}'
275
+ );
276
+ if (
277
+ typeof storage.lastRead === 'number' &&
278
+ storage.lastRead > this._lastRead
279
+ ) {
280
+ this._lastRead = storage.lastRead;
281
+ }
271
282
  }
272
283
 
273
284
  /**
@@ -330,18 +341,15 @@ export abstract class AbstractChatModel implements IChatModel {
330
341
  * Timestamp of the last read message in local storage.
331
342
  */
332
343
  get lastRead(): number {
333
- if (this._id === undefined) {
334
- return 0;
335
- }
336
- const storage = JSON.parse(
337
- localStorage.getItem(`@jupyter/chat:${this._id}`) || '{}'
338
- );
339
- return storage.lastRead ?? 0;
344
+ return this._lastRead;
340
345
  }
341
346
  set lastRead(value: number) {
347
+ this._lastRead = value;
348
+
342
349
  if (this._id === undefined) {
343
350
  return;
344
351
  }
352
+ // Save the last read message to the local storage, for persistence across reload.
345
353
  const storage = JSON.parse(
346
354
  localStorage.getItem(`@jupyter/chat:${this._id}`) || '{}'
347
355
  );
@@ -422,10 +430,10 @@ export abstract class AbstractChatModel implements IChatModel {
422
430
  // Notify the change.
423
431
  this._notify(unread.length, unreadCountDiff > 0);
424
432
 
425
- // Save the last read to the local storage.
426
- if (this._id !== undefined && recentlyRead.length) {
433
+ // Save the last read.
434
+ if (recentlyRead.length) {
427
435
  let lastReadChanged = false;
428
- let lastRead = this.lastRead ?? this.messages[recentlyRead[0]].time;
436
+ let lastRead = this.lastRead;
429
437
  recentlyRead.forEach(index => {
430
438
  if (this.messages[index].time > lastRead) {
431
439
  lastRead = this.messages[index].time;
@@ -569,13 +577,11 @@ export abstract class AbstractChatModel implements IChatModel {
569
577
  const formattedMessages: IMessage[] = [];
570
578
  const unreadIndexes: number[] = [];
571
579
 
572
- const lastRead = this.lastRead ?? 0;
573
-
574
580
  // Format the messages.
575
581
  messages.forEach((message, idx) => {
576
582
  const formattedMessage = this.formatChatMessage(message);
577
583
  formattedMessages.push(new Message(formattedMessage));
578
- if (message.time > lastRead) {
584
+ if (message.time > this.lastRead) {
579
585
  unreadIndexes.push(index + idx);
580
586
  }
581
587
  });
@@ -714,6 +720,7 @@ export abstract class AbstractChatModel implements IChatModel {
714
720
 
715
721
  private _messages: IMessage[] = [];
716
722
  private _unreadMessages: number[] = [];
723
+ private _lastRead: number = 0;
717
724
  private _messagesInViewport: number[] = [];
718
725
  private _id: string | undefined;
719
726
  private _name: string = '';
package/style/chat.css CHANGED
@@ -3,7 +3,7 @@
3
3
  * Distributed under the terms of the Modified BSD License.
4
4
  */
5
5
 
6
- .jp-chat-rendered-message {
6
+ .jp-chat-message-container {
7
7
  position: relative;
8
8
  }
9
9
 
@@ -58,8 +58,8 @@
58
58
  .jp-chat-toolbar {
59
59
  visibility: hidden;
60
60
  position: absolute;
61
- right: 2px;
62
- top: 2px;
61
+ right: 0;
62
+ top: 0;
63
63
  font-size: var(--jp-ui-font-size0);
64
64
  color: var(--jp-ui-font-color3);
65
65
  }
@@ -69,7 +69,7 @@
69
69
  color: var(--jp-ui-font-color2);
70
70
  }
71
71
 
72
- .jp-chat-rendered-message:hover .jp-chat-toolbar {
72
+ .jp-chat-message-container:hover .jp-chat-toolbar {
73
73
  visibility: visible;
74
74
  }
75
75