@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.
- package/lib/active-cell-manager.d.ts +2 -0
- package/lib/active-cell-manager.js +7 -2
- package/lib/components/avatar.d.ts +20 -0
- package/lib/components/avatar.js +29 -0
- package/lib/components/chat.d.ts +1 -3
- package/lib/components/chat.js +2 -3
- package/lib/components/index.d.ts +2 -3
- package/lib/components/index.js +2 -3
- package/lib/components/input/buttons/send-button.js +15 -5
- package/lib/components/{chat-input.d.ts → input/chat-input.d.ts} +3 -3
- package/lib/components/{chat-input.js → input/chat-input.js} +7 -4
- package/lib/components/input/index.d.ts +1 -0
- package/lib/components/input/index.js +1 -0
- package/lib/components/input/toolbar-registry.d.ts +6 -0
- package/lib/components/input/use-chat-commands.d.ts +1 -1
- package/lib/components/input/use-chat-commands.js +23 -12
- package/lib/components/messages/footer.d.ts +2 -2
- package/lib/components/messages/footer.js +1 -1
- package/lib/components/messages/header.d.ts +16 -0
- package/lib/components/messages/header.js +85 -0
- package/lib/components/messages/index.d.ts +9 -0
- package/lib/components/messages/index.js +13 -0
- package/lib/components/messages/message-renderer.js +1 -1
- package/lib/components/messages/message.d.ts +21 -0
- package/lib/components/messages/message.js +102 -0
- package/lib/components/messages/messages.d.ts +38 -0
- package/lib/components/messages/messages.js +139 -0
- package/lib/components/messages/navigation.d.ts +20 -0
- package/lib/components/messages/navigation.js +98 -0
- package/lib/components/messages/writers.d.ts +16 -0
- package/lib/components/messages/writers.js +39 -0
- package/lib/context.d.ts +1 -1
- package/lib/index.d.ts +2 -6
- package/lib/index.js +2 -6
- package/lib/{registry.d.ts → registers/attachment-openers.d.ts} +1 -1
- package/lib/registers/chat-commands.d.ts +108 -0
- package/lib/{chat-commands/registry.js → registers/chat-commands.js} +8 -8
- package/lib/{footers/registry.d.ts → registers/footers.d.ts} +30 -5
- package/lib/registers/index.d.ts +3 -0
- package/lib/{footers → registers}/index.js +3 -2
- package/lib/selection-watcher.d.ts +11 -1
- package/lib/selection-watcher.js +10 -4
- package/lib/widgets/index.d.ts +3 -0
- package/lib/{chat-commands → widgets}/index.js +3 -2
- package/package.json +3 -1
- package/src/active-cell-manager.ts +10 -1
- package/src/components/avatar.tsx +68 -0
- package/src/components/chat.tsx +11 -6
- package/src/components/index.ts +2 -3
- package/src/components/input/buttons/send-button.tsx +17 -5
- package/src/components/{chat-input.tsx → input/chat-input.tsx} +12 -7
- package/src/components/input/index.ts +1 -0
- package/src/components/input/toolbar-registry.tsx +6 -0
- package/src/components/input/use-chat-commands.tsx +30 -15
- package/src/components/messages/footer.tsx +5 -2
- package/src/components/messages/header.tsx +133 -0
- package/src/components/messages/index.ts +14 -0
- package/src/components/messages/message-renderer.tsx +1 -1
- package/src/components/messages/message.tsx +156 -0
- package/src/components/messages/messages.tsx +218 -0
- package/src/components/messages/navigation.tsx +167 -0
- package/src/components/messages/welcome.tsx +1 -0
- package/src/components/messages/writers.tsx +81 -0
- package/src/context.ts +1 -1
- package/src/index.ts +2 -6
- package/src/{registry.ts → registers/attachment-openers.ts} +2 -1
- package/src/registers/chat-commands.ts +142 -0
- package/src/{footers/registry.ts → registers/footers.ts} +35 -8
- package/src/{footers → registers}/index.ts +3 -2
- package/src/selection-watcher.ts +28 -5
- package/src/{chat-commands → widgets}/index.ts +3 -2
- package/style/chat.css +82 -0
- package/lib/chat-commands/index.d.ts +0 -2
- package/lib/chat-commands/registry.d.ts +0 -28
- package/lib/chat-commands/types.d.ts +0 -52
- package/lib/chat-commands/types.js +0 -5
- package/lib/components/chat-messages.d.ts +0 -119
- package/lib/components/chat-messages.js +0 -446
- package/lib/footers/index.d.ts +0 -2
- package/lib/footers/types.d.ts +0 -26
- package/lib/footers/types.js +0 -5
- package/src/chat-commands/registry.ts +0 -60
- package/src/chat-commands/types.ts +0 -67
- package/src/components/chat-messages.tsx +0 -739
- package/src/footers/types.ts +0 -33
- package/lib/components/{toolbar.d.ts → messages/toolbar.d.ts} +0 -0
- package/lib/components/{toolbar.js → messages/toolbar.js} +0 -0
- package/lib/{registry.js → registers/attachment-openers.js} +0 -0
- package/lib/{footers/registry.js → registers/footers.js} +4 -4
- /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 '
|
|
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
|
+
}
|