@jupyter/chat 0.13.0 → 0.14.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.
Files changed (90) hide show
  1. package/lib/active-cell-manager.d.ts +2 -0
  2. package/lib/active-cell-manager.js +7 -2
  3. package/lib/components/avatar.d.ts +20 -0
  4. package/lib/components/avatar.js +29 -0
  5. package/lib/components/chat.d.ts +1 -3
  6. package/lib/components/chat.js +2 -3
  7. package/lib/components/index.d.ts +2 -3
  8. package/lib/components/index.js +2 -3
  9. package/lib/components/input/buttons/send-button.js +15 -5
  10. package/lib/components/{chat-input.d.ts → input/chat-input.d.ts} +3 -3
  11. package/lib/components/{chat-input.js → input/chat-input.js} +7 -4
  12. package/lib/components/input/index.d.ts +1 -0
  13. package/lib/components/input/index.js +1 -0
  14. package/lib/components/input/toolbar-registry.d.ts +6 -0
  15. package/lib/components/input/use-chat-commands.d.ts +1 -1
  16. package/lib/components/input/use-chat-commands.js +23 -12
  17. package/lib/components/messages/footer.d.ts +2 -2
  18. package/lib/components/messages/footer.js +1 -1
  19. package/lib/components/messages/header.d.ts +16 -0
  20. package/lib/components/messages/header.js +85 -0
  21. package/lib/components/messages/index.d.ts +9 -0
  22. package/lib/components/messages/index.js +13 -0
  23. package/lib/components/messages/message-renderer.js +1 -1
  24. package/lib/components/messages/message.d.ts +21 -0
  25. package/lib/components/messages/message.js +102 -0
  26. package/lib/components/messages/messages.d.ts +38 -0
  27. package/lib/components/messages/messages.js +139 -0
  28. package/lib/components/messages/navigation.d.ts +20 -0
  29. package/lib/components/messages/navigation.js +98 -0
  30. package/lib/components/messages/writers.d.ts +16 -0
  31. package/lib/components/messages/writers.js +39 -0
  32. package/lib/context.d.ts +1 -1
  33. package/lib/index.d.ts +2 -6
  34. package/lib/index.js +2 -6
  35. package/lib/{registry.d.ts → registers/attachment-openers.d.ts} +1 -1
  36. package/lib/registers/chat-commands.d.ts +108 -0
  37. package/lib/{chat-commands/registry.js → registers/chat-commands.js} +8 -8
  38. package/lib/{footers/registry.d.ts → registers/footers.d.ts} +30 -5
  39. package/lib/registers/index.d.ts +3 -0
  40. package/lib/{footers → registers}/index.js +3 -2
  41. package/lib/selection-watcher.d.ts +11 -1
  42. package/lib/selection-watcher.js +10 -4
  43. package/lib/widgets/index.d.ts +3 -0
  44. package/lib/{chat-commands → widgets}/index.js +3 -2
  45. package/package.json +3 -1
  46. package/src/active-cell-manager.ts +10 -1
  47. package/src/components/avatar.tsx +68 -0
  48. package/src/components/chat.tsx +11 -6
  49. package/src/components/index.ts +2 -3
  50. package/src/components/input/buttons/send-button.tsx +17 -5
  51. package/src/components/{chat-input.tsx → input/chat-input.tsx} +12 -7
  52. package/src/components/input/index.ts +1 -0
  53. package/src/components/input/toolbar-registry.tsx +6 -0
  54. package/src/components/input/use-chat-commands.tsx +30 -15
  55. package/src/components/messages/footer.tsx +5 -2
  56. package/src/components/messages/header.tsx +133 -0
  57. package/src/components/messages/index.ts +14 -0
  58. package/src/components/messages/message-renderer.tsx +1 -1
  59. package/src/components/messages/message.tsx +156 -0
  60. package/src/components/messages/messages.tsx +218 -0
  61. package/src/components/messages/navigation.tsx +167 -0
  62. package/src/components/messages/welcome.tsx +1 -0
  63. package/src/components/messages/writers.tsx +81 -0
  64. package/src/context.ts +1 -1
  65. package/src/index.ts +2 -6
  66. package/src/{registry.ts → registers/attachment-openers.ts} +2 -1
  67. package/src/registers/chat-commands.ts +142 -0
  68. package/src/{footers/registry.ts → registers/footers.ts} +35 -8
  69. package/src/{footers → registers}/index.ts +3 -2
  70. package/src/selection-watcher.ts +28 -5
  71. package/src/{chat-commands → widgets}/index.ts +3 -2
  72. package/style/chat.css +82 -0
  73. package/lib/chat-commands/index.d.ts +0 -2
  74. package/lib/chat-commands/registry.d.ts +0 -28
  75. package/lib/chat-commands/types.d.ts +0 -52
  76. package/lib/chat-commands/types.js +0 -5
  77. package/lib/components/chat-messages.d.ts +0 -119
  78. package/lib/components/chat-messages.js +0 -446
  79. package/lib/footers/index.d.ts +0 -2
  80. package/lib/footers/types.d.ts +0 -26
  81. package/lib/footers/types.js +0 -5
  82. package/src/chat-commands/registry.ts +0 -60
  83. package/src/chat-commands/types.ts +0 -67
  84. package/src/components/chat-messages.tsx +0 -739
  85. package/src/footers/types.ts +0 -33
  86. package/lib/components/{toolbar.d.ts → messages/toolbar.d.ts} +0 -0
  87. package/lib/components/{toolbar.js → messages/toolbar.js} +0 -0
  88. package/lib/{registry.js → registers/attachment-openers.js} +0 -0
  89. package/lib/{footers/registry.js → registers/footers.js} +4 -4
  90. /package/src/components/{toolbar.tsx → messages/toolbar.tsx} +0 -0
@@ -0,0 +1,133 @@
1
+ /*
2
+ * Copyright (c) Jupyter Development Team.
3
+ * Distributed under the terms of the Modified BSD License.
4
+ */
5
+
6
+ import { Box, Typography } from '@mui/material';
7
+ import React, { useEffect, useState } from 'react';
8
+
9
+ import { Avatar } from '../avatar';
10
+ import { IChatMessage } from '../../types';
11
+
12
+ const MESSAGE_HEADER_CLASS = 'jp-chat-message-header';
13
+ const MESSAGE_TIME_CLASS = 'jp-chat-message-time';
14
+
15
+ /**
16
+ * The message header props.
17
+ */
18
+ type ChatMessageHeaderProps = {
19
+ /**
20
+ * The chat message.
21
+ */
22
+ message: IChatMessage;
23
+ };
24
+
25
+ /**
26
+ * The message header component.
27
+ */
28
+ export function ChatMessageHeader(props: ChatMessageHeaderProps): JSX.Element {
29
+ const [datetime, setDatetime] = useState<Record<number, string>>({});
30
+ const message = props.message;
31
+ const sender = message.sender;
32
+ /**
33
+ * Effect: update cached datetime strings upon receiving a new message.
34
+ */
35
+ useEffect(() => {
36
+ if (!datetime[message.time]) {
37
+ const newDatetime: Record<number, string> = {};
38
+ let datetime: string;
39
+ const currentDate = new Date();
40
+ const sameDay = (date: Date) =>
41
+ date.getFullYear() === currentDate.getFullYear() &&
42
+ date.getMonth() === currentDate.getMonth() &&
43
+ date.getDate() === currentDate.getDate();
44
+
45
+ const msgDate = new Date(message.time * 1000); // Convert message time to milliseconds
46
+
47
+ // Display only the time if the day of the message is the current one.
48
+ if (sameDay(msgDate)) {
49
+ // Use the browser's default locale
50
+ datetime = msgDate.toLocaleTimeString([], {
51
+ hour: 'numeric',
52
+ minute: '2-digit'
53
+ });
54
+ } else {
55
+ // Use the browser's default locale
56
+ datetime = msgDate.toLocaleString([], {
57
+ day: 'numeric',
58
+ month: 'numeric',
59
+ year: 'numeric',
60
+ hour: 'numeric',
61
+ minute: '2-digit'
62
+ });
63
+ }
64
+ newDatetime[message.time] = datetime;
65
+ setDatetime(newDatetime);
66
+ }
67
+ });
68
+
69
+ const avatar = message.stacked ? null : Avatar({ user: sender });
70
+
71
+ const name =
72
+ sender.display_name ?? sender.name ?? (sender.username || 'User undefined');
73
+
74
+ return (
75
+ <Box
76
+ className={MESSAGE_HEADER_CLASS}
77
+ sx={{
78
+ display: 'flex',
79
+ alignItems: 'center',
80
+ '& > :not(:last-child)': {
81
+ marginRight: 3
82
+ },
83
+ marginBottom: message.stacked ? '0px' : '12px'
84
+ }}
85
+ >
86
+ {avatar}
87
+ <Box
88
+ sx={{
89
+ display: 'flex',
90
+ flexGrow: 1,
91
+ flexWrap: 'wrap',
92
+ justifyContent: 'space-between',
93
+ alignItems: 'center'
94
+ }}
95
+ >
96
+ <Box sx={{ display: 'flex', alignItems: 'center' }}>
97
+ {!message.stacked && (
98
+ <Typography
99
+ sx={{
100
+ fontWeight: 700,
101
+ color: 'var(--jp-ui-font-color1)',
102
+ paddingRight: '0.5em'
103
+ }}
104
+ >
105
+ {name}
106
+ </Typography>
107
+ )}
108
+ {(message.deleted || message.edited) && (
109
+ <Typography
110
+ sx={{
111
+ fontStyle: 'italic',
112
+ fontSize: 'var(--jp-content-font-size0)'
113
+ }}
114
+ >
115
+ {message.deleted ? '(message deleted)' : '(edited)'}
116
+ </Typography>
117
+ )}
118
+ </Box>
119
+ <Typography
120
+ className={MESSAGE_TIME_CLASS}
121
+ sx={{
122
+ fontSize: '0.8em',
123
+ color: 'var(--jp-ui-font-color2)',
124
+ fontWeight: 300
125
+ }}
126
+ title={message.raw_time ? 'Unverified time' : ''}
127
+ >
128
+ {`${datetime[message.time]}${message.raw_time ? '*' : ''}`}
129
+ </Typography>
130
+ </Box>
131
+ </Box>
132
+ );
133
+ }
@@ -0,0 +1,14 @@
1
+ /*
2
+ * Copyright (c) Jupyter Development Team.
3
+ * Distributed under the terms of the Modified BSD License.
4
+ */
5
+
6
+ export * from './footer';
7
+ export * from './header';
8
+ export * from './message';
9
+ export * from './message-renderer';
10
+ export * from './messages';
11
+ export * from './navigation';
12
+ export * from './toolbar';
13
+ export * from './welcome';
14
+ export * from './writers';
@@ -9,7 +9,7 @@ import React, { useState, useEffect } from 'react';
9
9
  import { createPortal } from 'react-dom';
10
10
 
11
11
  import { CodeToolbar, CodeToolbarProps } from '../code-blocks/code-toolbar';
12
- import { MessageToolbar } from '../toolbar';
12
+ import { MessageToolbar } from './toolbar';
13
13
  import { MarkdownRenderer, MD_RENDERED_CLASS } from '../../markdown-renderer';
14
14
  import { IChatModel } from '../../model';
15
15
 
@@ -0,0 +1,156 @@
1
+ /*
2
+ * Copyright (c) Jupyter Development Team.
3
+ * Distributed under the terms of the Modified BSD License.
4
+ */
5
+
6
+ import { PromiseDelegate } from '@lumino/coreutils';
7
+ import React, { forwardRef, useEffect, useState } from 'react';
8
+
9
+ import { MessageRenderer } from './message-renderer';
10
+ import { BaseMessageProps } from './messages';
11
+ import { AttachmentPreviewList } from '../attachments';
12
+ import { ChatInput } from '../input';
13
+ import { IInputModel, InputModel } from '../../input-model';
14
+ import { IChatMessage } from '../../types';
15
+ import { replaceSpanToMention } from '../../utils';
16
+
17
+ /**
18
+ * The message component props.
19
+ */
20
+ type ChatMessageProps = BaseMessageProps & {
21
+ /**
22
+ * The message to display.
23
+ */
24
+ message: IChatMessage;
25
+ /**
26
+ * The index of the message in the list.
27
+ */
28
+ index: number;
29
+ /**
30
+ * The promise to resolve when the message is rendered.
31
+ */
32
+ renderedPromise: PromiseDelegate<void>;
33
+ };
34
+
35
+ /**
36
+ * The message component body.
37
+ */
38
+ export const ChatMessage = forwardRef<HTMLDivElement, ChatMessageProps>(
39
+ (props, ref): JSX.Element => {
40
+ const { message, model, rmRegistry } = props;
41
+ const [edit, setEdit] = useState<boolean>(false);
42
+ const [deleted, setDeleted] = useState<boolean>(false);
43
+ const [canEdit, setCanEdit] = useState<boolean>(false);
44
+ const [canDelete, setCanDelete] = useState<boolean>(false);
45
+
46
+ // Look if the message can be deleted or edited.
47
+ useEffect(() => {
48
+ // Init canDelete and canEdit state.
49
+ setDeleted(message.deleted ?? false);
50
+ if (model.user !== undefined && !message.deleted) {
51
+ if (model.user.username === message.sender.username) {
52
+ setCanEdit(model.updateMessage !== undefined);
53
+ setCanDelete(model.deleteMessage !== undefined);
54
+ return;
55
+ }
56
+ if (message.sender.bot) {
57
+ setCanDelete(model.deleteMessage !== undefined);
58
+ }
59
+ } else {
60
+ setCanEdit(false);
61
+ setCanDelete(false);
62
+ }
63
+ }, [model, message]);
64
+
65
+ // Create an input model only if the message is edited.
66
+ const startEdition = (): void => {
67
+ if (!canEdit) {
68
+ return;
69
+ }
70
+ let body = message.body;
71
+ message.mentions?.forEach(user => {
72
+ body = replaceSpanToMention(body, user);
73
+ });
74
+ const inputModel = new InputModel({
75
+ chatContext: model.createChatContext(),
76
+ onSend: (input: string, model?: IInputModel) =>
77
+ updateMessage(message.id, input, model),
78
+ onCancel: () => cancelEdition(),
79
+ value: body,
80
+ activeCellManager: model.activeCellManager,
81
+ selectionWatcher: model.selectionWatcher,
82
+ documentManager: model.documentManager,
83
+ config: {
84
+ sendWithShiftEnter: model.config.sendWithShiftEnter
85
+ },
86
+ attachments: message.attachments,
87
+ mentions: message.mentions
88
+ });
89
+ model.addEditionModel(message.id, inputModel);
90
+ setEdit(true);
91
+ };
92
+
93
+ // Cancel the current edition of the message.
94
+ const cancelEdition = (): void => {
95
+ model.getEditionModel(message.id)?.dispose();
96
+ setEdit(false);
97
+ };
98
+
99
+ // Update the content of the message.
100
+ const updateMessage = (
101
+ id: string,
102
+ input: string,
103
+ inputModel?: IInputModel
104
+ ): void => {
105
+ if (!canEdit || !inputModel) {
106
+ return;
107
+ }
108
+ // Update the message
109
+ const updatedMessage = { ...message };
110
+ updatedMessage.body = input;
111
+ updatedMessage.attachments = inputModel.attachments;
112
+ updatedMessage.mentions = inputModel.mentions;
113
+ model.updateMessage!(id, updatedMessage);
114
+ model.getEditionModel(message.id)?.dispose();
115
+ setEdit(false);
116
+ };
117
+
118
+ // Delete the message.
119
+ const deleteMessage = (id: string): void => {
120
+ if (!canDelete) {
121
+ return;
122
+ }
123
+ model.deleteMessage!(id);
124
+ };
125
+
126
+ // Empty if the message has been deleted.
127
+ return deleted ? (
128
+ <div ref={ref} data-index={props.index}></div>
129
+ ) : (
130
+ <div ref={ref} data-index={props.index}>
131
+ {edit && canEdit && model.getEditionModel(message.id) ? (
132
+ <ChatInput
133
+ onCancel={() => cancelEdition()}
134
+ model={model.getEditionModel(message.id)!}
135
+ chatCommandRegistry={props.chatCommandRegistry}
136
+ toolbarRegistry={props.inputToolbarRegistry}
137
+ />
138
+ ) : (
139
+ <MessageRenderer
140
+ rmRegistry={rmRegistry}
141
+ markdownStr={message.body}
142
+ model={model}
143
+ edit={canEdit ? startEdition : undefined}
144
+ delete={canDelete ? () => deleteMessage(message.id) : undefined}
145
+ rendered={props.renderedPromise}
146
+ />
147
+ )}
148
+ {message.attachments && !edit && (
149
+ // Display the attachments only if message is not edited, otherwise the
150
+ // input component display them.
151
+ <AttachmentPreviewList attachments={message.attachments} />
152
+ )}
153
+ </div>
154
+ );
155
+ }
156
+ );
@@ -0,0 +1,218 @@
1
+ /*
2
+ * Copyright (c) Jupyter Development Team.
3
+ * Distributed under the terms of the Modified BSD License.
4
+ */
5
+
6
+ import { IRenderMimeRegistry } from '@jupyterlab/rendermime';
7
+ import { PromiseDelegate } from '@lumino/coreutils';
8
+ import { Box } from '@mui/material';
9
+ import clsx from 'clsx';
10
+ import React, { useEffect, useState, useRef } from 'react';
11
+
12
+ import { MessageFooterComponent } from './footer';
13
+ import { ChatMessageHeader } from './header';
14
+ import { ChatMessage } from './message';
15
+ import { Navigation } from './navigation';
16
+ import { WelcomeMessage } from './welcome';
17
+ import { WritingUsersList } from './writers';
18
+ import { IInputToolbarRegistry } from '../input';
19
+ import { ScrollContainer } from '../scroll-container';
20
+ import { IChatCommandRegistry, IMessageFooterRegistry } from '../../registers';
21
+ import { IChatModel } from '../../model';
22
+ import { IChatMessage, IUser } from '../../types';
23
+
24
+ const MESSAGES_BOX_CLASS = 'jp-chat-messages-container';
25
+ const MESSAGE_CLASS = 'jp-chat-message';
26
+ const MESSAGE_STACKED_CLASS = 'jp-chat-message-stacked';
27
+
28
+ /**
29
+ * The base components props.
30
+ */
31
+ export type BaseMessageProps = {
32
+ /**
33
+ * The mime renderer registry.
34
+ */
35
+ rmRegistry: IRenderMimeRegistry;
36
+ /**
37
+ * The chat model.
38
+ */
39
+ model: IChatModel;
40
+ /**
41
+ * The chat commands registry.
42
+ */
43
+ chatCommandRegistry?: IChatCommandRegistry;
44
+ /**
45
+ * The input toolbar registry.
46
+ */
47
+ inputToolbarRegistry: IInputToolbarRegistry;
48
+ /**
49
+ * The footer registry.
50
+ */
51
+ messageFooterRegistry?: IMessageFooterRegistry;
52
+ /**
53
+ * The welcome message.
54
+ */
55
+ welcomeMessage?: string;
56
+ };
57
+
58
+ /**
59
+ * The messages list component.
60
+ */
61
+ export function ChatMessages(props: BaseMessageProps): JSX.Element {
62
+ const { model } = props;
63
+ const [messages, setMessages] = useState<IChatMessage[]>(model.messages);
64
+ const refMsgBox = useRef<HTMLDivElement>(null);
65
+ const [currentWriters, setCurrentWriters] = useState<IUser[]>([]);
66
+ const [allRendered, setAllRendered] = useState<boolean>(false);
67
+
68
+ // The list of message DOM and their rendered promises.
69
+ const listRef = useRef<(HTMLDivElement | null)[]>([]);
70
+ const renderedPromise = useRef<PromiseDelegate<void>[]>([]);
71
+
72
+ /**
73
+ * Effect: fetch history and config on initial render
74
+ */
75
+ useEffect(() => {
76
+ async function fetchHistory() {
77
+ if (!model.getHistory) {
78
+ return;
79
+ }
80
+ model
81
+ .getHistory()
82
+ .then(history => setMessages(history.messages))
83
+ .catch(e => console.error(e));
84
+ }
85
+
86
+ fetchHistory();
87
+ setCurrentWriters([]);
88
+ }, [model]);
89
+
90
+ /**
91
+ * Effect: listen to chat messages.
92
+ */
93
+ useEffect(() => {
94
+ function handleChatEvents() {
95
+ setMessages([...model.messages]);
96
+ }
97
+
98
+ function handleWritersChange(_: IChatModel, writers: IChatModel.IWriter[]) {
99
+ setCurrentWriters(writers.map(writer => writer.user));
100
+ }
101
+
102
+ model.messagesUpdated.connect(handleChatEvents);
103
+ model.writersChanged?.connect(handleWritersChange);
104
+
105
+ return function cleanup() {
106
+ model.messagesUpdated.disconnect(handleChatEvents);
107
+ model.writersChanged?.disconnect(handleChatEvents);
108
+ };
109
+ }, [model]);
110
+
111
+ /**
112
+ * Observe the messages to update the current viewport and the unread messages.
113
+ */
114
+ useEffect(() => {
115
+ const observer = new IntersectionObserver(entries => {
116
+ // Used on first rendering, to ensure all the message as been rendered once.
117
+ if (!allRendered) {
118
+ Promise.all(renderedPromise.current.map(p => p.promise)).then(() => {
119
+ setAllRendered(true);
120
+ });
121
+ }
122
+
123
+ const unread = [...model.unreadMessages];
124
+ let unreadModified = false;
125
+ const inViewport = [...(model.messagesInViewport ?? [])];
126
+ entries.forEach(entry => {
127
+ const index = parseInt(entry.target.getAttribute('data-index') ?? '');
128
+ if (!isNaN(index)) {
129
+ const viewportIdx = inViewport.indexOf(index);
130
+ if (!entry.isIntersecting && viewportIdx !== -1) {
131
+ inViewport.splice(viewportIdx, 1);
132
+ } else if (entry.isIntersecting && viewportIdx === -1) {
133
+ inViewport.push(index);
134
+ }
135
+ if (unread.length) {
136
+ const unreadIdx = unread.indexOf(index);
137
+ if (unreadIdx !== -1 && entry.isIntersecting) {
138
+ unread.splice(unreadIdx, 1);
139
+ unreadModified = true;
140
+ }
141
+ }
142
+ }
143
+ });
144
+
145
+ props.model.messagesInViewport = inViewport;
146
+
147
+ // Ensure that all messages are rendered before updating unread messages, otherwise
148
+ // it can lead to wrong assumption , because more message are in the viewport
149
+ // before they are rendered.
150
+ if (allRendered && unreadModified) {
151
+ model.unreadMessages = unread;
152
+ }
153
+ });
154
+
155
+ /**
156
+ * Observe the messages.
157
+ */
158
+ listRef.current.forEach(item => {
159
+ if (item) {
160
+ observer.observe(item);
161
+ }
162
+ });
163
+
164
+ return () => {
165
+ listRef.current.forEach(item => {
166
+ if (item) {
167
+ observer.unobserve(item);
168
+ }
169
+ });
170
+ };
171
+ }, [messages, allRendered]);
172
+
173
+ return (
174
+ <>
175
+ <ScrollContainer sx={{ flexGrow: 1 }}>
176
+ {props.welcomeMessage && (
177
+ <WelcomeMessage
178
+ rmRegistry={props.rmRegistry}
179
+ content={props.welcomeMessage}
180
+ />
181
+ )}
182
+ <Box ref={refMsgBox} className={clsx(MESSAGES_BOX_CLASS)}>
183
+ {messages.map((message, i) => {
184
+ renderedPromise.current[i] = new PromiseDelegate();
185
+ return (
186
+ // extra div needed to ensure each bubble is on a new line
187
+ <Box
188
+ key={i}
189
+ className={clsx(
190
+ MESSAGE_CLASS,
191
+ message.stacked ? MESSAGE_STACKED_CLASS : ''
192
+ )}
193
+ >
194
+ <ChatMessageHeader message={message} />
195
+ <ChatMessage
196
+ {...props}
197
+ message={message}
198
+ index={i}
199
+ renderedPromise={renderedPromise.current[i]}
200
+ ref={el => (listRef.current[i] = el)}
201
+ />
202
+ {props.messageFooterRegistry && (
203
+ <MessageFooterComponent
204
+ registry={props.messageFooterRegistry}
205
+ message={message}
206
+ model={model}
207
+ />
208
+ )}
209
+ </Box>
210
+ );
211
+ })}
212
+ </Box>
213
+ </ScrollContainer>
214
+ <WritingUsersList writers={currentWriters}></WritingUsersList>
215
+ <Navigation {...props} refMsgBox={refMsgBox} allRendered={allRendered} />
216
+ </>
217
+ );
218
+ }
@@ -0,0 +1,167 @@
1
+ /*
2
+ * Copyright (c) Jupyter Development Team.
3
+ * Distributed under the terms of the Modified BSD License.
4
+ */
5
+
6
+ import { Button } from '@jupyter/react-components';
7
+ import {
8
+ LabIcon,
9
+ caretDownEmptyIcon,
10
+ classes
11
+ } from '@jupyterlab/ui-components';
12
+ import React, { useEffect, useState } from 'react';
13
+
14
+ import { BaseMessageProps } from './messages';
15
+ import { IChatModel } from '../../model';
16
+
17
+ const NAVIGATION_BUTTON_CLASS = 'jp-chat-navigation';
18
+ const NAVIGATION_UNREAD_CLASS = 'jp-chat-navigation-unread';
19
+ const NAVIGATION_TOP_CLASS = 'jp-chat-navigation-top';
20
+ const NAVIGATION_BOTTOM_CLASS = 'jp-chat-navigation-bottom';
21
+
22
+ /**
23
+ * The navigation component props.
24
+ */
25
+ type NavigationProps = BaseMessageProps & {
26
+ /**
27
+ * The reference to the messages container.
28
+ */
29
+ refMsgBox: React.RefObject<HTMLDivElement>;
30
+ /**
31
+ * Whether all the messages has been rendered once on first display.
32
+ */
33
+ allRendered: boolean;
34
+ };
35
+
36
+ /**
37
+ * The navigation component, to navigate to unread messages.
38
+ */
39
+ export function Navigation(props: NavigationProps): JSX.Element {
40
+ const { model } = props;
41
+ const [lastInViewport, setLastInViewport] = useState<boolean>(true);
42
+ const [unreadBefore, setUnreadBefore] = useState<number | null>(null);
43
+ const [unreadAfter, setUnreadAfter] = useState<number | null>(null);
44
+
45
+ const gotoMessage = (msgIdx: number, alignToTop: boolean = true) => {
46
+ props.refMsgBox.current?.children.item(msgIdx)?.scrollIntoView(alignToTop);
47
+ };
48
+
49
+ // Listen for change in unread messages, and find the first unread message before or
50
+ // after the current viewport, to display navigation buttons.
51
+ useEffect(() => {
52
+ // Do not attempt to display navigation until messages are rendered, it can lead to
53
+ // wrong assumption, because more messages are in the viewport before they are
54
+ // rendered.
55
+ if (!props.allRendered) {
56
+ return;
57
+ }
58
+
59
+ const unreadChanged = (model: IChatModel, unreadIndexes: number[]) => {
60
+ const viewport = model.messagesInViewport;
61
+ if (!viewport) {
62
+ return;
63
+ }
64
+
65
+ // Initialize the next values with the current values if there still relevant.
66
+ let before =
67
+ unreadBefore !== null &&
68
+ unreadIndexes.includes(unreadBefore) &&
69
+ unreadBefore < Math.min(...viewport)
70
+ ? unreadBefore
71
+ : null;
72
+
73
+ let after =
74
+ unreadAfter !== null &&
75
+ unreadIndexes.includes(unreadAfter) &&
76
+ unreadAfter > Math.max(...viewport)
77
+ ? unreadAfter
78
+ : null;
79
+
80
+ unreadIndexes.forEach(unread => {
81
+ if (viewport?.includes(unread)) {
82
+ return;
83
+ }
84
+ if (unread < (before ?? Math.min(...viewport))) {
85
+ before = unread;
86
+ } else if (
87
+ unread > Math.max(...viewport) &&
88
+ unread < (after ?? model.messages.length)
89
+ ) {
90
+ after = unread;
91
+ }
92
+ });
93
+
94
+ setUnreadBefore(before);
95
+ setUnreadAfter(after);
96
+ };
97
+
98
+ model.unreadChanged?.connect(unreadChanged);
99
+
100
+ unreadChanged(model, model.unreadMessages);
101
+
102
+ // Move to the last the message after all the messages have been first rendered.
103
+ gotoMessage(model.messages.length - 1, false);
104
+
105
+ return () => {
106
+ model.unreadChanged?.disconnect(unreadChanged);
107
+ };
108
+ }, [model, props.allRendered]);
109
+
110
+ // Listen for change in the viewport, to add a navigation button if the last is not
111
+ // in viewport.
112
+ useEffect(() => {
113
+ const viewportChanged = (model: IChatModel, viewport: number[]) => {
114
+ setLastInViewport(
115
+ model.messages.length === 0 ||
116
+ viewport.includes(model.messages.length - 1)
117
+ );
118
+ };
119
+
120
+ model.viewportChanged?.connect(viewportChanged);
121
+
122
+ viewportChanged(model, model.messagesInViewport ?? []);
123
+
124
+ return () => {
125
+ model.viewportChanged?.disconnect(viewportChanged);
126
+ };
127
+ }, [model]);
128
+
129
+ return (
130
+ <>
131
+ {unreadBefore !== null && (
132
+ <Button
133
+ className={`${NAVIGATION_BUTTON_CLASS} ${NAVIGATION_UNREAD_CLASS} ${NAVIGATION_TOP_CLASS}`}
134
+ onClick={() => gotoMessage!(unreadBefore)}
135
+ title={'Go to unread messages'}
136
+ >
137
+ <LabIcon.resolveReact
138
+ display={'flex'}
139
+ icon={caretDownEmptyIcon}
140
+ iconClass={classes('jp-Icon')}
141
+ />
142
+ </Button>
143
+ )}
144
+ {(unreadAfter !== null || !lastInViewport) && (
145
+ <Button
146
+ className={`${NAVIGATION_BUTTON_CLASS} ${unreadAfter !== null ? NAVIGATION_UNREAD_CLASS : ''} ${NAVIGATION_BOTTOM_CLASS}`}
147
+ onClick={
148
+ unreadAfter === null
149
+ ? () => gotoMessage(model.messages.length - 1, false)
150
+ : () => gotoMessage(unreadAfter)
151
+ }
152
+ title={
153
+ unreadAfter !== null
154
+ ? 'Go to unread messages'
155
+ : 'Go to last message'
156
+ }
157
+ >
158
+ <LabIcon.resolveReact
159
+ display={'flex'}
160
+ icon={caretDownEmptyIcon}
161
+ iconClass={classes('jp-Icon')}
162
+ />
163
+ </Button>
164
+ )}
165
+ </>
166
+ );
167
+ }
@@ -5,6 +5,7 @@
5
5
 
6
6
  import { classes } from '@jupyterlab/ui-components';
7
7
  import React, { useEffect, useRef } from 'react';
8
+
8
9
  import { MarkdownRenderer, MD_RENDERED_CLASS } from '../../markdown-renderer';
9
10
 
10
11
  const WELCOME_MESSAGE_CLASS = 'jp-chat-welcome-message';