@jupyter/chat 0.1.0 → 0.2.0

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.
@@ -3,55 +3,177 @@
3
3
  * Distributed under the terms of the Modified BSD License.
4
4
  */
5
5
 
6
+ import { Button } from '@jupyter/react-components';
6
7
  import { IRenderMimeRegistry } from '@jupyterlab/rendermime';
8
+ import {
9
+ LabIcon,
10
+ caretDownEmptyIcon,
11
+ classes
12
+ } from '@jupyterlab/ui-components';
7
13
  import { Avatar, Box, Typography } from '@mui/material';
8
14
  import type { SxProps, Theme } from '@mui/material';
9
15
  import clsx from 'clsx';
10
- import React, { useState, useEffect } from 'react';
16
+ import React, { useEffect, useState, useRef } from 'react';
11
17
 
12
18
  import { ChatInput } from './chat-input';
13
19
  import { RendermimeMarkdown } from './rendermime-markdown';
20
+ import { ScrollContainer } from './scroll-container';
14
21
  import { IChatModel } from '../model';
15
- import { IChatMessage, IUser } from '../types';
22
+ import { IChatMessage } from '../types';
16
23
 
17
24
  const MESSAGES_BOX_CLASS = 'jp-chat-messages-container';
18
25
  const MESSAGE_CLASS = 'jp-chat-message';
26
+ const MESSAGE_STACKED_CLASS = 'jp-chat-message-stacked';
19
27
  const MESSAGE_HEADER_CLASS = 'jp-chat-message-header';
20
28
  const MESSAGE_TIME_CLASS = 'jp-chat-message-time';
29
+ const NAVIGATION_BUTTON_CLASS = 'jp-chat-navigation';
30
+ const NAVIGATION_UNREAD_CLASS = 'jp-chat-navigation-unread';
31
+ const NAVIGATION_TOP_CLASS = 'jp-chat-navigation-top';
32
+ const NAVIGATION_BOTTOM_CLASS = 'jp-chat-navigation-bottom';
21
33
 
34
+ /**
35
+ * The base components props.
36
+ */
22
37
  type BaseMessageProps = {
23
38
  rmRegistry: IRenderMimeRegistry;
24
39
  model: IChatModel;
25
40
  };
26
41
 
27
- type ChatMessageProps = BaseMessageProps & {
28
- message: IChatMessage;
29
- };
42
+ /**
43
+ * The messages list component.
44
+ */
45
+ export function ChatMessages(props: BaseMessageProps): JSX.Element {
46
+ const { model } = props;
47
+ const [messages, setMessages] = useState<IChatMessage[]>(model.messages);
48
+ const refMsgBox = useRef<HTMLDivElement>(null);
49
+ const inViewport = useRef<number[]>([]);
30
50
 
31
- type ChatMessagesProps = BaseMessageProps & {
32
- messages: IChatMessage[];
33
- };
51
+ // The intersection observer that listen to all the message visibility.
52
+ const observerRef = useRef<IntersectionObserver>(
53
+ new IntersectionObserver(viewportChange)
54
+ );
55
+
56
+ /**
57
+ * Effect: fetch history and config on initial render
58
+ */
59
+ useEffect(() => {
60
+ async function fetchHistory() {
61
+ if (!model.getHistory) {
62
+ return;
63
+ }
64
+ model
65
+ .getHistory()
66
+ .then(history => setMessages(history.messages))
67
+ .catch(e => console.error(e));
68
+ }
69
+
70
+ fetchHistory();
71
+ }, [model]);
72
+
73
+ /**
74
+ * Effect: listen to chat messages.
75
+ */
76
+ useEffect(() => {
77
+ function handleChatEvents(_: IChatModel) {
78
+ setMessages([...model.messages]);
79
+ }
80
+
81
+ model.messagesUpdated.connect(handleChatEvents);
82
+ return function cleanup() {
83
+ model.messagesUpdated.disconnect(handleChatEvents);
84
+ };
85
+ }, [model]);
86
+
87
+ /**
88
+ * Function called when a message enter or leave the viewport.
89
+ */
90
+ function viewportChange(entries: IntersectionObserverEntry[]) {
91
+ const unread = [...model.unreadMessages];
92
+ let unreadModified = false;
93
+ entries.forEach(entry => {
94
+ const index = parseInt(entry.target.getAttribute('data-index') ?? '');
95
+ if (!isNaN(index)) {
96
+ if (unread.length) {
97
+ const unreadIdx = unread.indexOf(index);
98
+ if (unreadIdx !== -1 && entry.isIntersecting) {
99
+ unread.splice(unreadIdx, 1);
100
+ unreadModified = true;
101
+ }
102
+ }
103
+ const viewportIdx = inViewport.current.indexOf(index);
104
+ if (!entry.isIntersecting && viewportIdx !== -1) {
105
+ inViewport.current.splice(viewportIdx, 1);
106
+ } else if (entry.isIntersecting && viewportIdx === -1) {
107
+ inViewport.current.push(index);
108
+ }
109
+ }
110
+ });
111
+
112
+ props.model.messagesInViewport = inViewport.current;
113
+ if (unreadModified) {
114
+ props.model.unreadMessages = unread;
115
+ }
34
116
 
35
- export type ChatMessageHeaderProps = IUser & {
36
- timestamp: number;
37
- rawTime?: boolean;
38
- deleted?: boolean;
39
- edited?: boolean;
117
+ return () => {
118
+ observerRef.current?.disconnect();
119
+ };
120
+ }
121
+
122
+ return (
123
+ <>
124
+ <ScrollContainer sx={{ flexGrow: 1 }}>
125
+ <Box ref={refMsgBox} className={clsx(MESSAGES_BOX_CLASS)}>
126
+ {messages.map((message, i) => {
127
+ return (
128
+ // extra div needed to ensure each bubble is on a new line
129
+ <Box
130
+ key={i}
131
+ className={clsx(
132
+ MESSAGE_CLASS,
133
+ message.stacked ? MESSAGE_STACKED_CLASS : ''
134
+ )}
135
+ >
136
+ <ChatMessageHeader message={message} />
137
+ <ChatMessage
138
+ {...props}
139
+ message={message}
140
+ observer={observerRef.current}
141
+ index={i}
142
+ />
143
+ </Box>
144
+ );
145
+ })}
146
+ </Box>
147
+ </ScrollContainer>
148
+ <Navigation {...props} refMsgBox={refMsgBox} />
149
+ </>
150
+ );
151
+ }
152
+
153
+ /**
154
+ * The message header props.
155
+ */
156
+ type ChatMessageHeaderProps = {
157
+ message: IChatMessage;
40
158
  sx?: SxProps<Theme>;
41
159
  };
42
160
 
161
+ /**
162
+ * The message header component.
163
+ */
43
164
  export function ChatMessageHeader(props: ChatMessageHeaderProps): JSX.Element {
44
165
  const [datetime, setDatetime] = useState<Record<number, string>>({});
45
166
  const sharedStyles: SxProps<Theme> = {
46
167
  height: '24px',
47
168
  width: '24px'
48
169
  };
49
-
170
+ const message = props.message;
171
+ const sender = message.sender;
50
172
  /**
51
173
  * Effect: update cached datetime strings upon receiving a new message.
52
174
  */
53
175
  useEffect(() => {
54
- if (!datetime[props.timestamp]) {
176
+ if (!datetime[message.time]) {
55
177
  const newDatetime: Record<number, string> = {};
56
178
  let datetime: string;
57
179
  const currentDate = new Date();
@@ -60,7 +182,7 @@ export function ChatMessageHeader(props: ChatMessageHeaderProps): JSX.Element {
60
182
  date.getMonth() === currentDate.getMonth() &&
61
183
  date.getDate() === currentDate.getDate();
62
184
 
63
- const msgDate = new Date(props.timestamp * 1000); // Convert message time to milliseconds
185
+ const msgDate = new Date(message.time * 1000); // Convert message time to milliseconds
64
186
 
65
187
  // Display only the time if the day of the message is the current one.
66
188
  if (sameDay(msgDate)) {
@@ -79,21 +201,21 @@ export function ChatMessageHeader(props: ChatMessageHeaderProps): JSX.Element {
79
201
  minute: '2-digit'
80
202
  });
81
203
  }
82
- newDatetime[props.timestamp] = datetime;
204
+ newDatetime[message.time] = datetime;
83
205
  setDatetime(newDatetime);
84
206
  }
85
207
  });
86
208
 
87
- const bgcolor = props.color;
88
- const avatar = props.avatar_url ? (
209
+ const bgcolor = sender.color;
210
+ const avatar = message.stacked ? null : sender.avatar_url ? (
89
211
  <Avatar
90
212
  sx={{
91
213
  ...sharedStyles,
92
214
  ...(bgcolor && { bgcolor })
93
215
  }}
94
- src={props.avatar_url}
216
+ src={sender.avatar_url}
95
217
  ></Avatar>
96
- ) : props.initials ? (
218
+ ) : sender.initials ? (
97
219
  <Avatar
98
220
  sx={{
99
221
  ...sharedStyles,
@@ -106,13 +228,13 @@ export function ChatMessageHeader(props: ChatMessageHeaderProps): JSX.Element {
106
228
  color: 'var(--jp-ui-inverse-font-color1)'
107
229
  }}
108
230
  >
109
- {props.initials}
231
+ {sender.initials}
110
232
  </Typography>
111
233
  </Avatar>
112
234
  ) : null;
113
235
 
114
236
  const name =
115
- props.display_name ?? props.name ?? (props.username || 'User undefined');
237
+ sender.display_name ?? sender.name ?? (sender.username || 'User undefined');
116
238
 
117
239
  return (
118
240
  <Box
@@ -123,6 +245,7 @@ export function ChatMessageHeader(props: ChatMessageHeaderProps): JSX.Element {
123
245
  '& > :not(:last-child)': {
124
246
  marginRight: 3
125
247
  },
248
+ marginBottom: message.stacked ? '0px' : '12px',
126
249
  ...props.sx
127
250
  }}
128
251
  >
@@ -137,20 +260,25 @@ export function ChatMessageHeader(props: ChatMessageHeaderProps): JSX.Element {
137
260
  }}
138
261
  >
139
262
  <Box sx={{ display: 'flex', alignItems: 'center' }}>
140
- <Typography
141
- sx={{ fontWeight: 700, color: 'var(--jp-ui-font-color1)' }}
142
- >
143
- {name}
144
- </Typography>
145
- {(props.deleted || props.edited) && (
263
+ {!message.stacked && (
264
+ <Typography
265
+ sx={{
266
+ fontWeight: 700,
267
+ color: 'var(--jp-ui-font-color1)',
268
+ paddingRight: '0.5em'
269
+ }}
270
+ >
271
+ {name}
272
+ </Typography>
273
+ )}
274
+ {(message.deleted || message.edited) && (
146
275
  <Typography
147
276
  sx={{
148
277
  fontStyle: 'italic',
149
- fontSize: 'var(--jp-content-font-size0)',
150
- paddingLeft: '0.5em'
278
+ fontSize: 'var(--jp-content-font-size0)'
151
279
  }}
152
280
  >
153
- {props.deleted ? '(message deleted)' : '(edited)'}
281
+ {message.deleted ? '(message deleted)' : '(edited)'}
154
282
  </Typography>
155
283
  )}
156
284
  </Box>
@@ -161,9 +289,9 @@ export function ChatMessageHeader(props: ChatMessageHeaderProps): JSX.Element {
161
289
  color: 'var(--jp-ui-font-color2)',
162
290
  fontWeight: 300
163
291
  }}
164
- title={props.rawTime ? 'Unverified time' : ''}
292
+ title={message.raw_time ? 'Unverified time' : ''}
165
293
  >
166
- {`${datetime[props.timestamp]}${props.rawTime ? '*' : ''}`}
294
+ {`${datetime[message.time]}${message.raw_time ? '*' : ''}`}
167
295
  </Typography>
168
296
  </Box>
169
297
  </Box>
@@ -171,74 +299,70 @@ export function ChatMessageHeader(props: ChatMessageHeaderProps): JSX.Element {
171
299
  }
172
300
 
173
301
  /**
174
- * The messages list UI.
302
+ * The message component props.
175
303
  */
176
- export function ChatMessages(props: ChatMessagesProps): JSX.Element {
177
- return (
178
- <Box
179
- sx={{
180
- '& > :not(:last-child)': {
181
- borderBottom: '1px solid var(--jp-border-color2)'
182
- }
183
- }}
184
- className={clsx(MESSAGES_BOX_CLASS)}
185
- >
186
- {props.messages.map((message, i) => {
187
- let sender: IUser;
188
- if (typeof message.sender === 'string') {
189
- sender = { username: message.sender };
190
- } else {
191
- sender = message.sender;
192
- }
193
- return (
194
- // extra div needed to ensure each bubble is on a new line
195
- <Box
196
- key={i}
197
- sx={{ padding: '1em 1em 0 1em' }}
198
- className={clsx(MESSAGE_CLASS)}
199
- >
200
- <ChatMessageHeader
201
- {...sender}
202
- timestamp={message.time}
203
- rawTime={message.raw_time}
204
- deleted={message.deleted}
205
- edited={message.edited}
206
- sx={{ marginBottom: 3 }}
207
- />
208
- <ChatMessage {...props} message={message} />
209
- </Box>
210
- );
211
- })}
212
- </Box>
213
- );
214
- }
304
+ type ChatMessageProps = BaseMessageProps & {
305
+ /**
306
+ * The message to display.
307
+ */
308
+ message: IChatMessage;
309
+ /**
310
+ * The index of the message in the list.
311
+ */
312
+ index: number;
313
+ /**
314
+ * The intersection observer for all the messages.
315
+ */
316
+ observer: IntersectionObserver | null;
317
+ };
215
318
 
216
319
  /**
217
- * the message UI.
320
+ * The message component body.
218
321
  */
219
322
  export function ChatMessage(props: ChatMessageProps): JSX.Element {
220
323
  const { message, model, rmRegistry } = props;
221
- let canEdit = false;
222
- let canDelete = false;
223
- if (model.user !== undefined && !message.deleted) {
224
- const username =
225
- typeof message.sender === 'string'
226
- ? message.sender
227
- : message.sender.username;
228
-
229
- if (model.user.username === username && model.updateMessage !== undefined) {
230
- canEdit = true;
324
+ const elementRef = useRef<HTMLDivElement>(null);
325
+ const [edit, setEdit] = useState<boolean>(false);
326
+ const [deleted, setDeleted] = useState<boolean>(false);
327
+ const [canEdit, setCanEdit] = useState<boolean>(false);
328
+ const [canDelete, setCanDelete] = useState<boolean>(false);
329
+
330
+ // Add the current message to the observer, to actualize viewport and unread messages.
331
+ useEffect(() => {
332
+ if (elementRef.current === null) {
333
+ return;
231
334
  }
232
- if (model.user.username === username && model.deleteMessage !== undefined) {
233
- canDelete = true;
335
+
336
+ // If the observer is defined, let's observe the message.
337
+ props.observer?.observe(elementRef.current);
338
+
339
+ return () => {
340
+ if (elementRef.current !== null) {
341
+ props.observer?.unobserve(elementRef.current);
342
+ }
343
+ };
344
+ }, [model]);
345
+
346
+ // Look if the message can be deleted or edited.
347
+ useEffect(() => {
348
+ setDeleted(message.deleted ?? false);
349
+ if (model.user !== undefined && !message.deleted) {
350
+ if (model.user.username === message.sender.username) {
351
+ setCanEdit(model.updateMessage !== undefined);
352
+ setCanDelete(model.deleteMessage !== undefined);
353
+ }
354
+ } else {
355
+ setCanEdit(false);
356
+ setCanDelete(false);
234
357
  }
235
- }
236
- const [edit, setEdit] = useState<boolean>(false);
358
+ }, [model, message]);
237
359
 
360
+ // Cancel the current edition of the message.
238
361
  const cancelEdition = (): void => {
239
362
  setEdit(false);
240
363
  };
241
364
 
365
+ // Update the content of the message.
242
366
  const updateMessage = (id: string, input: string): void => {
243
367
  if (!canEdit) {
244
368
  return;
@@ -250,19 +374,19 @@ export function ChatMessage(props: ChatMessageProps): JSX.Element {
250
374
  setEdit(false);
251
375
  };
252
376
 
377
+ // Delete the message.
253
378
  const deleteMessage = (id: string): void => {
254
379
  if (!canDelete) {
255
380
  return;
256
381
  }
257
- // Delete the message
258
382
  model.deleteMessage!(id);
259
383
  };
260
384
 
261
- // Empty if the message has been deleted
262
- return message.deleted ? (
263
- <></>
385
+ // Empty if the message has been deleted.
386
+ return deleted ? (
387
+ <div ref={elementRef} data-index={props.index}></div>
264
388
  ) : (
265
- <div>
389
+ <div ref={elementRef} data-index={props.index}>
266
390
  {edit && canEdit ? (
267
391
  <ChatInput
268
392
  value={message.body}
@@ -281,3 +405,140 @@ export function ChatMessage(props: ChatMessageProps): JSX.Element {
281
405
  </div>
282
406
  );
283
407
  }
408
+
409
+ /**
410
+ * The navigation component props.
411
+ */
412
+ type NavigationProps = BaseMessageProps & {
413
+ /**
414
+ * The reference to the messages container.
415
+ */
416
+ refMsgBox: React.RefObject<HTMLDivElement>;
417
+ };
418
+
419
+ /**
420
+ * The navigation component, to navigate to unread messages.
421
+ */
422
+ export function Navigation(props: NavigationProps): JSX.Element {
423
+ const { model } = props;
424
+ const [lastInViewport, setLastInViewport] = useState<boolean>(true);
425
+ const [unreadBefore, setUnreadBefore] = useState<number | null>(null);
426
+ const [unreadAfter, setUnreadAfter] = useState<number | null>(null);
427
+
428
+ const gotoMessage = (msgIdx: number) => {
429
+ props.refMsgBox.current?.children.item(msgIdx)?.scrollIntoView();
430
+ };
431
+
432
+ // Listen for change in unread messages, and find the first unread message before or
433
+ // after the current viewport, to display navigation buttons.
434
+ useEffect(() => {
435
+ const unreadChanged = (model: IChatModel, unreadIndexes: number[]) => {
436
+ const viewport = model.messagesInViewport;
437
+ if (!viewport) {
438
+ return;
439
+ }
440
+
441
+ // Initialize the next values with the current values if there still relevant.
442
+ let before =
443
+ unreadBefore !== null &&
444
+ unreadIndexes.includes(unreadBefore) &&
445
+ unreadBefore < Math.min(...viewport)
446
+ ? unreadBefore
447
+ : null;
448
+
449
+ let after =
450
+ unreadAfter !== null &&
451
+ unreadIndexes.includes(unreadAfter) &&
452
+ unreadAfter > Math.max(...viewport)
453
+ ? unreadAfter
454
+ : null;
455
+
456
+ unreadIndexes.forEach(unread => {
457
+ if (viewport?.includes(unread)) {
458
+ return;
459
+ }
460
+ if (unread < (before ?? Math.min(...viewport))) {
461
+ before = unread;
462
+ } else if (
463
+ unread > Math.max(...viewport) &&
464
+ unread < (after ?? model.messages.length)
465
+ ) {
466
+ after = unread;
467
+ }
468
+ });
469
+
470
+ setUnreadBefore(before);
471
+ setUnreadAfter(after);
472
+ };
473
+
474
+ model.unreadChanged?.connect(unreadChanged);
475
+
476
+ unreadChanged(model, model.unreadMessages);
477
+
478
+ // Move to first the unread message or to last message on first rendering.
479
+ if (model.unreadMessages.length) {
480
+ gotoMessage(Math.min(...model.unreadMessages));
481
+ } else {
482
+ gotoMessage(model.messages.length - 1);
483
+ }
484
+
485
+ return () => {
486
+ model.unreadChanged?.disconnect(unreadChanged);
487
+ };
488
+ }, [model]);
489
+
490
+ // Listen for change in the viewport, to add a navigation button if the last is not
491
+ // in viewport.
492
+ useEffect(() => {
493
+ const viewportChanged = (model: IChatModel, viewport: number[]) => {
494
+ setLastInViewport(viewport.includes(model.messages.length - 1));
495
+ };
496
+
497
+ model.viewportChanged?.connect(viewportChanged);
498
+
499
+ viewportChanged(model, model.messagesInViewport ?? []);
500
+
501
+ return () => {
502
+ model.viewportChanged?.disconnect(viewportChanged);
503
+ };
504
+ }, [model]);
505
+
506
+ return (
507
+ <>
508
+ {unreadBefore !== null && (
509
+ <Button
510
+ className={`${NAVIGATION_BUTTON_CLASS} ${NAVIGATION_UNREAD_CLASS} ${NAVIGATION_TOP_CLASS}`}
511
+ onClick={() => gotoMessage!(unreadBefore)}
512
+ title={'Go to unread messages'}
513
+ >
514
+ <LabIcon.resolveReact
515
+ display={'flex'}
516
+ icon={caretDownEmptyIcon}
517
+ iconClass={classes('jp-Icon')}
518
+ />
519
+ </Button>
520
+ )}
521
+ {(unreadAfter !== null || !lastInViewport) && (
522
+ <Button
523
+ className={`${NAVIGATION_BUTTON_CLASS} ${unreadAfter !== null ? NAVIGATION_UNREAD_CLASS : ''} ${NAVIGATION_BOTTOM_CLASS}`}
524
+ onClick={() =>
525
+ gotoMessage!(
526
+ unreadAfter !== null ? unreadAfter : model.messages.length - 1
527
+ )
528
+ }
529
+ title={
530
+ unreadAfter !== null
531
+ ? 'Go to unread messages'
532
+ : 'Go to last message'
533
+ }
534
+ >
535
+ <LabIcon.resolveReact
536
+ display={'flex'}
537
+ icon={caretDownEmptyIcon}
538
+ iconClass={classes('jp-Icon')}
539
+ />
540
+ </Button>
541
+ )}
542
+ </>
543
+ );
544
+ }