@jupyter/chat 0.13.0 → 0.15.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} +8 -5
- 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 +32 -13
- 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/input-model.js +30 -3
- 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/types.d.ts +7 -2
- package/lib/utils.js +8 -6
- 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} +13 -8
- 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 +39 -16
- 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/input-model.ts +33 -4
- 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/types.ts +7 -2
- package/src/utils.ts +8 -6
- 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
|
@@ -11,7 +11,7 @@ import type {
|
|
|
11
11
|
import { Box } from '@mui/material';
|
|
12
12
|
import React, { useEffect, useState } from 'react';
|
|
13
13
|
|
|
14
|
-
import { ChatCommand, IChatCommandRegistry } from '../../
|
|
14
|
+
import { ChatCommand, IChatCommandRegistry } from '../../registers';
|
|
15
15
|
import { IInputModel } from '../../input-model';
|
|
16
16
|
|
|
17
17
|
type AutocompleteProps = GenericAutocompleteProps<any, any, any, any>;
|
|
@@ -37,7 +37,9 @@ export function useChatCommands(
|
|
|
37
37
|
// whether an option is highlighted in the chat commands menu
|
|
38
38
|
const [highlighted, setHighlighted] = useState(false);
|
|
39
39
|
|
|
40
|
-
// whether the chat commands menu is open
|
|
40
|
+
// whether the chat commands menu is open.
|
|
41
|
+
// NOTE: every `setOpen(false)` call should be followed by a
|
|
42
|
+
// `setHighlighted(false)` call.
|
|
41
43
|
const [open, setOpen] = useState(false);
|
|
42
44
|
|
|
43
45
|
// current list of chat commands matched by the current word.
|
|
@@ -45,6 +47,9 @@ export function useChatCommands(
|
|
|
45
47
|
const [commands, setCommands] = useState<ChatCommand[]>([]);
|
|
46
48
|
|
|
47
49
|
useEffect(() => {
|
|
50
|
+
/**
|
|
51
|
+
* Callback that runs whenever the current word changes.
|
|
52
|
+
*/
|
|
48
53
|
async function getCommands(_: IInputModel, currentWord: string | null) {
|
|
49
54
|
const providers = chatCommandRegistry?.getProviders();
|
|
50
55
|
if (!providers) {
|
|
@@ -58,12 +63,12 @@ export function useChatCommands(
|
|
|
58
63
|
return;
|
|
59
64
|
}
|
|
60
65
|
|
|
61
|
-
let
|
|
66
|
+
let commandCompletions: ChatCommand[] = [];
|
|
62
67
|
for (const provider of providers) {
|
|
63
68
|
// TODO: optimize performance when this method is truly async
|
|
64
69
|
try {
|
|
65
|
-
|
|
66
|
-
await provider.
|
|
70
|
+
commandCompletions = commandCompletions.concat(
|
|
71
|
+
await provider.listCommandCompletions(inputModel)
|
|
67
72
|
);
|
|
68
73
|
} catch (e) {
|
|
69
74
|
console.error(
|
|
@@ -72,10 +77,28 @@ export function useChatCommands(
|
|
|
72
77
|
);
|
|
73
78
|
}
|
|
74
79
|
}
|
|
75
|
-
|
|
80
|
+
|
|
81
|
+
// Immediately replace the current word if it exactly matches one command
|
|
82
|
+
// and 'replaceWith' is set.
|
|
83
|
+
if (
|
|
84
|
+
commandCompletions.length === 1 &&
|
|
85
|
+
commandCompletions[0].name === inputModel.currentWord &&
|
|
86
|
+
commandCompletions[0].replaceWith !== undefined
|
|
87
|
+
) {
|
|
88
|
+
const replacement = commandCompletions[0].replaceWith;
|
|
89
|
+
inputModel.replaceCurrentWord(replacement);
|
|
90
|
+
return;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// Otherwise, open/close the menu based on the presence of command
|
|
94
|
+
// completions and set the menu entries.
|
|
95
|
+
if (commandCompletions.length) {
|
|
76
96
|
setOpen(true);
|
|
97
|
+
} else {
|
|
98
|
+
setOpen(false);
|
|
99
|
+
setHighlighted(false);
|
|
77
100
|
}
|
|
78
|
-
setCommands(
|
|
101
|
+
setCommands(commandCompletions);
|
|
79
102
|
}
|
|
80
103
|
|
|
81
104
|
inputModel.currentWordChanged.connect(getCommands);
|
|
@@ -87,7 +110,8 @@ export function useChatCommands(
|
|
|
87
110
|
|
|
88
111
|
/**
|
|
89
112
|
* onChange(): the callback invoked when a command is selected from the chat
|
|
90
|
-
* commands menu
|
|
113
|
+
* commands menu. When a command `cmd` is selected, this function replaces the
|
|
114
|
+
* current word with `cmd.replaceWith` if set, `cmd.name` otherwise.
|
|
91
115
|
*/
|
|
92
116
|
const onChange: AutocompleteProps['onChange'] = (
|
|
93
117
|
e: unknown,
|
|
@@ -109,21 +133,20 @@ export function useChatCommands(
|
|
|
109
133
|
return;
|
|
110
134
|
}
|
|
111
135
|
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
136
|
+
let replacement =
|
|
137
|
+
command.replaceWith === undefined ? command.name : command.replaceWith;
|
|
138
|
+
if (command.spaceOnAccept) {
|
|
139
|
+
replacement += ' ';
|
|
116
140
|
}
|
|
117
|
-
|
|
118
|
-
// otherwise, defer handling to the command provider
|
|
119
|
-
chatCommandRegistry.handleChatCommand(command, inputModel);
|
|
141
|
+
inputModel.replaceCurrentWord(replacement);
|
|
120
142
|
};
|
|
121
143
|
|
|
122
144
|
return {
|
|
123
145
|
autocompleteProps: {
|
|
124
146
|
open,
|
|
125
147
|
options: commands,
|
|
126
|
-
getOptionLabel: (command: ChatCommand) =>
|
|
148
|
+
getOptionLabel: (command: ChatCommand | string) =>
|
|
149
|
+
typeof command === 'string' ? '' : command.name,
|
|
127
150
|
renderOption: (
|
|
128
151
|
defaultProps,
|
|
129
152
|
command: ChatCommand,
|
|
@@ -5,10 +5,11 @@
|
|
|
5
5
|
|
|
6
6
|
import { Box } from '@mui/material';
|
|
7
7
|
import React from 'react';
|
|
8
|
+
|
|
8
9
|
import {
|
|
9
10
|
IMessageFooterRegistry,
|
|
10
11
|
MessageFooterSectionProps
|
|
11
|
-
} from '../../
|
|
12
|
+
} from '../../registers';
|
|
12
13
|
|
|
13
14
|
/**
|
|
14
15
|
* The chat footer component properties.
|
|
@@ -24,7 +25,9 @@ export interface IMessageFootersProps extends MessageFooterSectionProps {
|
|
|
24
25
|
* The chat footer component, which displays footer components on a row according to
|
|
25
26
|
* their respective positions.
|
|
26
27
|
*/
|
|
27
|
-
export function
|
|
28
|
+
export function MessageFooterComponent(
|
|
29
|
+
props: IMessageFootersProps
|
|
30
|
+
): JSX.Element {
|
|
28
31
|
const { message, model, registry } = props;
|
|
29
32
|
const footer = registry.getFooter();
|
|
30
33
|
|
|
@@ -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
|
+
}
|