@jupyter/chat 0.1.0 → 0.3.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.
- package/lib/active-cell-manager.d.ts +151 -0
- package/lib/active-cell-manager.js +201 -0
- package/lib/components/chat-input.d.ts +14 -4
- package/lib/components/chat-input.js +118 -10
- package/lib/components/chat-messages.d.ts +45 -15
- package/lib/components/chat-messages.js +237 -55
- package/lib/components/chat.d.ts +21 -6
- package/lib/components/chat.js +15 -45
- package/lib/components/code-blocks/code-toolbar.d.ts +13 -0
- package/lib/components/code-blocks/code-toolbar.js +70 -0
- package/lib/components/{copy-button.d.ts → code-blocks/copy-button.d.ts} +1 -0
- package/lib/components/code-blocks/copy-button.js +43 -0
- package/lib/components/mui-extras/contrasting-tooltip.d.ts +6 -0
- package/lib/components/mui-extras/contrasting-tooltip.js +21 -0
- package/lib/components/mui-extras/tooltipped-icon-button.d.ts +35 -0
- package/lib/components/mui-extras/tooltipped-icon-button.js +36 -0
- package/lib/components/rendermime-markdown.d.ts +2 -0
- package/lib/components/rendermime-markdown.js +29 -15
- package/lib/components/scroll-container.js +1 -19
- package/lib/icons.d.ts +2 -0
- package/lib/icons.js +10 -0
- package/lib/index.d.ts +2 -0
- package/lib/index.js +2 -0
- package/lib/model.d.ts +98 -14
- package/lib/model.js +197 -6
- package/lib/registry.d.ts +78 -0
- package/lib/registry.js +83 -0
- package/lib/types.d.ts +60 -4
- package/lib/widgets/chat-sidebar.d.ts +3 -4
- package/lib/widgets/chat-sidebar.js +2 -2
- package/lib/widgets/chat-widget.d.ts +2 -8
- package/lib/widgets/chat-widget.js +6 -6
- package/package.json +204 -200
- package/src/active-cell-manager.ts +318 -0
- package/src/components/chat-input.tsx +196 -50
- package/src/components/chat-messages.tsx +357 -95
- package/src/components/chat.tsx +43 -69
- package/src/components/code-blocks/code-toolbar.tsx +143 -0
- package/src/components/code-blocks/copy-button.tsx +68 -0
- package/src/components/mui-extras/contrasting-tooltip.tsx +27 -0
- package/src/components/mui-extras/tooltipped-icon-button.tsx +84 -0
- package/src/components/rendermime-markdown.tsx +44 -20
- package/src/components/scroll-container.tsx +1 -25
- package/src/icons.ts +12 -0
- package/src/index.ts +2 -0
- package/src/model.ts +275 -21
- package/src/registry.ts +129 -0
- package/src/types.ts +62 -4
- package/src/widgets/chat-sidebar.tsx +3 -15
- package/src/widgets/chat-widget.tsx +8 -21
- package/style/chat.css +40 -0
- package/style/icons/read.svg +11 -0
- package/style/icons/replace-cell.svg +8 -0
- package/lib/components/copy-button.js +0 -35
- package/src/components/copy-button.tsx +0 -55
|
@@ -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,
|
|
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
|
|
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
|
-
|
|
28
|
-
|
|
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
|
-
|
|
32
|
-
|
|
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() {
|
|
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
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
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[
|
|
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(
|
|
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[
|
|
204
|
+
newDatetime[message.time] = datetime;
|
|
83
205
|
setDatetime(newDatetime);
|
|
84
206
|
}
|
|
85
207
|
});
|
|
86
208
|
|
|
87
|
-
const bgcolor =
|
|
88
|
-
const avatar =
|
|
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={
|
|
216
|
+
src={sender.avatar_url}
|
|
95
217
|
></Avatar>
|
|
96
|
-
) :
|
|
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
|
-
{
|
|
231
|
+
{sender.initials}
|
|
110
232
|
</Typography>
|
|
111
233
|
</Avatar>
|
|
112
234
|
) : null;
|
|
113
235
|
|
|
114
236
|
const name =
|
|
115
|
-
|
|
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
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
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
|
-
{
|
|
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={
|
|
292
|
+
title={message.raw_time ? 'Unverified time' : ''}
|
|
165
293
|
>
|
|
166
|
-
{`${datetime[
|
|
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
|
|
302
|
+
* The message component props.
|
|
175
303
|
*/
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
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
|
-
*
|
|
320
|
+
* The message component body.
|
|
218
321
|
*/
|
|
219
322
|
export function ChatMessage(props: ChatMessageProps): JSX.Element {
|
|
220
323
|
const { message, model, rmRegistry } = props;
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
if (
|
|
230
|
-
|
|
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
|
-
|
|
233
|
-
|
|
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,30 +374,31 @@ 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
|
|
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}
|
|
269
393
|
onSend={(input: string) => updateMessage(message.id, input)}
|
|
270
394
|
onCancel={() => cancelEdition()}
|
|
271
|
-
|
|
395
|
+
model={model}
|
|
272
396
|
/>
|
|
273
397
|
) : (
|
|
274
398
|
<RendermimeMarkdown
|
|
275
399
|
rmRegistry={rmRegistry}
|
|
276
400
|
markdownStr={message.body}
|
|
401
|
+
model={model}
|
|
277
402
|
edit={canEdit ? () => setEdit(true) : undefined}
|
|
278
403
|
delete={canDelete ? () => deleteMessage(message.id) : undefined}
|
|
279
404
|
/>
|
|
@@ -281,3 +406,140 @@ export function ChatMessage(props: ChatMessageProps): JSX.Element {
|
|
|
281
406
|
</div>
|
|
282
407
|
);
|
|
283
408
|
}
|
|
409
|
+
|
|
410
|
+
/**
|
|
411
|
+
* The navigation component props.
|
|
412
|
+
*/
|
|
413
|
+
type NavigationProps = BaseMessageProps & {
|
|
414
|
+
/**
|
|
415
|
+
* The reference to the messages container.
|
|
416
|
+
*/
|
|
417
|
+
refMsgBox: React.RefObject<HTMLDivElement>;
|
|
418
|
+
};
|
|
419
|
+
|
|
420
|
+
/**
|
|
421
|
+
* The navigation component, to navigate to unread messages.
|
|
422
|
+
*/
|
|
423
|
+
export function Navigation(props: NavigationProps): JSX.Element {
|
|
424
|
+
const { model } = props;
|
|
425
|
+
const [lastInViewport, setLastInViewport] = useState<boolean>(true);
|
|
426
|
+
const [unreadBefore, setUnreadBefore] = useState<number | null>(null);
|
|
427
|
+
const [unreadAfter, setUnreadAfter] = useState<number | null>(null);
|
|
428
|
+
|
|
429
|
+
const gotoMessage = (msgIdx: number) => {
|
|
430
|
+
props.refMsgBox.current?.children.item(msgIdx)?.scrollIntoView();
|
|
431
|
+
};
|
|
432
|
+
|
|
433
|
+
// Listen for change in unread messages, and find the first unread message before or
|
|
434
|
+
// after the current viewport, to display navigation buttons.
|
|
435
|
+
useEffect(() => {
|
|
436
|
+
const unreadChanged = (model: IChatModel, unreadIndexes: number[]) => {
|
|
437
|
+
const viewport = model.messagesInViewport;
|
|
438
|
+
if (!viewport) {
|
|
439
|
+
return;
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
// Initialize the next values with the current values if there still relevant.
|
|
443
|
+
let before =
|
|
444
|
+
unreadBefore !== null &&
|
|
445
|
+
unreadIndexes.includes(unreadBefore) &&
|
|
446
|
+
unreadBefore < Math.min(...viewport)
|
|
447
|
+
? unreadBefore
|
|
448
|
+
: null;
|
|
449
|
+
|
|
450
|
+
let after =
|
|
451
|
+
unreadAfter !== null &&
|
|
452
|
+
unreadIndexes.includes(unreadAfter) &&
|
|
453
|
+
unreadAfter > Math.max(...viewport)
|
|
454
|
+
? unreadAfter
|
|
455
|
+
: null;
|
|
456
|
+
|
|
457
|
+
unreadIndexes.forEach(unread => {
|
|
458
|
+
if (viewport?.includes(unread)) {
|
|
459
|
+
return;
|
|
460
|
+
}
|
|
461
|
+
if (unread < (before ?? Math.min(...viewport))) {
|
|
462
|
+
before = unread;
|
|
463
|
+
} else if (
|
|
464
|
+
unread > Math.max(...viewport) &&
|
|
465
|
+
unread < (after ?? model.messages.length)
|
|
466
|
+
) {
|
|
467
|
+
after = unread;
|
|
468
|
+
}
|
|
469
|
+
});
|
|
470
|
+
|
|
471
|
+
setUnreadBefore(before);
|
|
472
|
+
setUnreadAfter(after);
|
|
473
|
+
};
|
|
474
|
+
|
|
475
|
+
model.unreadChanged?.connect(unreadChanged);
|
|
476
|
+
|
|
477
|
+
unreadChanged(model, model.unreadMessages);
|
|
478
|
+
|
|
479
|
+
// Move to first the unread message or to last message on first rendering.
|
|
480
|
+
if (model.unreadMessages.length) {
|
|
481
|
+
gotoMessage(Math.min(...model.unreadMessages));
|
|
482
|
+
} else {
|
|
483
|
+
gotoMessage(model.messages.length - 1);
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
return () => {
|
|
487
|
+
model.unreadChanged?.disconnect(unreadChanged);
|
|
488
|
+
};
|
|
489
|
+
}, [model]);
|
|
490
|
+
|
|
491
|
+
// Listen for change in the viewport, to add a navigation button if the last is not
|
|
492
|
+
// in viewport.
|
|
493
|
+
useEffect(() => {
|
|
494
|
+
const viewportChanged = (model: IChatModel, viewport: number[]) => {
|
|
495
|
+
setLastInViewport(viewport.includes(model.messages.length - 1));
|
|
496
|
+
};
|
|
497
|
+
|
|
498
|
+
model.viewportChanged?.connect(viewportChanged);
|
|
499
|
+
|
|
500
|
+
viewportChanged(model, model.messagesInViewport ?? []);
|
|
501
|
+
|
|
502
|
+
return () => {
|
|
503
|
+
model.viewportChanged?.disconnect(viewportChanged);
|
|
504
|
+
};
|
|
505
|
+
}, [model]);
|
|
506
|
+
|
|
507
|
+
return (
|
|
508
|
+
<>
|
|
509
|
+
{unreadBefore !== null && (
|
|
510
|
+
<Button
|
|
511
|
+
className={`${NAVIGATION_BUTTON_CLASS} ${NAVIGATION_UNREAD_CLASS} ${NAVIGATION_TOP_CLASS}`}
|
|
512
|
+
onClick={() => gotoMessage!(unreadBefore)}
|
|
513
|
+
title={'Go to unread messages'}
|
|
514
|
+
>
|
|
515
|
+
<LabIcon.resolveReact
|
|
516
|
+
display={'flex'}
|
|
517
|
+
icon={caretDownEmptyIcon}
|
|
518
|
+
iconClass={classes('jp-Icon')}
|
|
519
|
+
/>
|
|
520
|
+
</Button>
|
|
521
|
+
)}
|
|
522
|
+
{(unreadAfter !== null || !lastInViewport) && (
|
|
523
|
+
<Button
|
|
524
|
+
className={`${NAVIGATION_BUTTON_CLASS} ${unreadAfter !== null ? NAVIGATION_UNREAD_CLASS : ''} ${NAVIGATION_BOTTOM_CLASS}`}
|
|
525
|
+
onClick={() =>
|
|
526
|
+
gotoMessage!(
|
|
527
|
+
unreadAfter !== null ? unreadAfter : model.messages.length - 1
|
|
528
|
+
)
|
|
529
|
+
}
|
|
530
|
+
title={
|
|
531
|
+
unreadAfter !== null
|
|
532
|
+
? 'Go to unread messages'
|
|
533
|
+
: 'Go to last message'
|
|
534
|
+
}
|
|
535
|
+
>
|
|
536
|
+
<LabIcon.resolveReact
|
|
537
|
+
display={'flex'}
|
|
538
|
+
icon={caretDownEmptyIcon}
|
|
539
|
+
iconClass={classes('jp-Icon')}
|
|
540
|
+
/>
|
|
541
|
+
</Button>
|
|
542
|
+
)}
|
|
543
|
+
</>
|
|
544
|
+
);
|
|
545
|
+
}
|