@jupyter/chat 0.1.0 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/lib/components/chat-input.d.ts +9 -0
- package/lib/components/chat-input.js +110 -9
- package/lib/components/chat-messages.d.ts +45 -15
- package/lib/components/chat-messages.js +237 -54
- package/lib/components/chat.d.ts +21 -6
- package/lib/components/chat.js +15 -44
- package/lib/components/scroll-container.js +1 -19
- package/lib/icons.d.ts +1 -0
- package/lib/icons.js +5 -0
- package/lib/index.d.ts +1 -0
- package/lib/index.js +1 -0
- package/lib/model.d.ts +78 -14
- package/lib/model.js +183 -5
- package/lib/registry.d.ts +78 -0
- package/lib/registry.js +83 -0
- package/lib/types.d.ts +56 -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 +202 -200
- package/src/components/chat-input.tsx +182 -45
- package/src/components/chat-messages.tsx +355 -94
- package/src/components/chat.tsx +42 -68
- package/src/components/scroll-container.tsx +1 -25
- package/src/icons.ts +6 -0
- package/src/index.ts +1 -0
- package/src/model.ts +242 -21
- package/src/registry.ts +129 -0
- package/src/types.ts +58 -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
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
/// <reference types="react" />
|
|
2
2
|
import { SxProps, Theme } from '@mui/material';
|
|
3
|
+
import { IAutocompletionRegistry } from '../registry';
|
|
3
4
|
export declare function ChatInput(props: ChatInput.IProps): JSX.Element;
|
|
4
5
|
/**
|
|
5
6
|
* The chat input namespace.
|
|
@@ -29,5 +30,13 @@ export declare namespace ChatInput {
|
|
|
29
30
|
* Custom mui/material styles.
|
|
30
31
|
*/
|
|
31
32
|
sx?: SxProps<Theme>;
|
|
33
|
+
/**
|
|
34
|
+
* Autocompletion properties.
|
|
35
|
+
*/
|
|
36
|
+
autocompletionRegistry?: IAutocompletionRegistry;
|
|
37
|
+
/**
|
|
38
|
+
* Autocompletion name.
|
|
39
|
+
*/
|
|
40
|
+
autocompletionName?: string;
|
|
32
41
|
}
|
|
33
42
|
}
|
|
@@ -2,19 +2,79 @@
|
|
|
2
2
|
* Copyright (c) Jupyter Development Team.
|
|
3
3
|
* Distributed under the terms of the Modified BSD License.
|
|
4
4
|
*/
|
|
5
|
-
import React, { useState } from 'react';
|
|
6
|
-
import {
|
|
5
|
+
import React, { useEffect, useRef, useState } from 'react';
|
|
6
|
+
import { Autocomplete, Box, IconButton, InputAdornment, TextField } from '@mui/material';
|
|
7
7
|
import { Send, Cancel } from '@mui/icons-material';
|
|
8
8
|
import clsx from 'clsx';
|
|
9
9
|
const INPUT_BOX_CLASS = 'jp-chat-input-container';
|
|
10
10
|
const SEND_BUTTON_CLASS = 'jp-chat-send-button';
|
|
11
11
|
const CANCEL_BUTTON_CLASS = 'jp-chat-cancel-button';
|
|
12
12
|
export function ChatInput(props) {
|
|
13
|
+
var _a;
|
|
14
|
+
const { autocompletionName, autocompletionRegistry, sendWithShiftEnter } = props;
|
|
15
|
+
const autocompletion = useRef();
|
|
13
16
|
const [input, setInput] = useState(props.value || '');
|
|
17
|
+
// The autocomplete commands options.
|
|
18
|
+
const [commandOptions, setCommandOptions] = useState([]);
|
|
19
|
+
// whether any option is highlighted in the slash command autocomplete
|
|
20
|
+
const [highlighted, setHighlighted] = useState(false);
|
|
21
|
+
// controls whether the slash command autocomplete is open
|
|
22
|
+
const [open, setOpen] = useState(false);
|
|
23
|
+
/**
|
|
24
|
+
* Effect: fetch the list of available autocomplete commands.
|
|
25
|
+
*/
|
|
26
|
+
useEffect(() => {
|
|
27
|
+
if (autocompletionRegistry === undefined) {
|
|
28
|
+
return;
|
|
29
|
+
}
|
|
30
|
+
autocompletion.current = autocompletionName
|
|
31
|
+
? autocompletionRegistry.get(autocompletionName)
|
|
32
|
+
: autocompletionRegistry.getDefaultCompletion();
|
|
33
|
+
if (autocompletion.current === undefined) {
|
|
34
|
+
return;
|
|
35
|
+
}
|
|
36
|
+
if (Array.isArray(autocompletion.current.commands)) {
|
|
37
|
+
setCommandOptions(autocompletion.current.commands);
|
|
38
|
+
}
|
|
39
|
+
else if (typeof autocompletion.current.commands === 'function') {
|
|
40
|
+
autocompletion.current
|
|
41
|
+
.commands()
|
|
42
|
+
.then((commands) => {
|
|
43
|
+
setCommandOptions(commands);
|
|
44
|
+
});
|
|
45
|
+
}
|
|
46
|
+
}, []);
|
|
47
|
+
/**
|
|
48
|
+
* Effect: Open the autocomplete when the user types the 'opener' string into an
|
|
49
|
+
* empty chat input. Close the autocomplete and reset the last selected value when
|
|
50
|
+
* the user clears the chat input.
|
|
51
|
+
*/
|
|
52
|
+
useEffect(() => {
|
|
53
|
+
var _a, _b;
|
|
54
|
+
if (!((_a = autocompletion.current) === null || _a === void 0 ? void 0 : _a.opener)) {
|
|
55
|
+
return;
|
|
56
|
+
}
|
|
57
|
+
if (input === ((_b = autocompletion.current) === null || _b === void 0 ? void 0 : _b.opener)) {
|
|
58
|
+
setOpen(true);
|
|
59
|
+
return;
|
|
60
|
+
}
|
|
61
|
+
if (input === '') {
|
|
62
|
+
setOpen(false);
|
|
63
|
+
return;
|
|
64
|
+
}
|
|
65
|
+
}, [input]);
|
|
14
66
|
function handleKeyDown(event) {
|
|
67
|
+
if (event.key !== 'Enter') {
|
|
68
|
+
return;
|
|
69
|
+
}
|
|
70
|
+
// do not send the message if the user was selecting a suggested command from the
|
|
71
|
+
// Autocomplete component.
|
|
72
|
+
if (highlighted) {
|
|
73
|
+
return;
|
|
74
|
+
}
|
|
15
75
|
if (event.key === 'Enter' &&
|
|
16
|
-
((
|
|
17
|
-
(!
|
|
76
|
+
((sendWithShiftEnter && event.shiftKey) ||
|
|
77
|
+
(!sendWithShiftEnter && !event.shiftKey))) {
|
|
18
78
|
onSend();
|
|
19
79
|
event.stopPropagation();
|
|
20
80
|
event.preventDefault();
|
|
@@ -47,14 +107,55 @@ export function ChatInput(props) {
|
|
|
47
107
|
React.createElement("b", null, "Enter"),
|
|
48
108
|
" to add a new line"));
|
|
49
109
|
return (React.createElement(Box, { sx: props.sx, className: clsx(INPUT_BOX_CLASS) },
|
|
50
|
-
React.createElement(
|
|
51
|
-
|
|
110
|
+
React.createElement(Autocomplete, { options: commandOptions, value: props.value, open: open, autoHighlight: true, freeSolo: true,
|
|
111
|
+
// ensure the autocomplete popup always renders on top
|
|
112
|
+
componentsProps: {
|
|
113
|
+
popper: {
|
|
114
|
+
placement: 'top'
|
|
115
|
+
},
|
|
116
|
+
paper: {
|
|
117
|
+
sx: {
|
|
118
|
+
border: '1px solid lightgray'
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
}, ListboxProps: {
|
|
122
|
+
sx: {
|
|
123
|
+
'& .MuiAutocomplete-option': {
|
|
124
|
+
padding: 2
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
}, renderInput: params => (React.createElement(TextField, { ...params, fullWidth: true, variant: "outlined", multiline: true, onKeyDown: handleKeyDown, placeholder: "Start chatting", InputProps: {
|
|
128
|
+
...params.InputProps,
|
|
52
129
|
endAdornment: (React.createElement(InputAdornment, { position: "end" },
|
|
53
|
-
props.onCancel && (React.createElement(IconButton, { size: "small", color: "primary", onClick: onCancel,
|
|
130
|
+
props.onCancel && (React.createElement(IconButton, { size: "small", color: "primary", onClick: onCancel, title: 'Cancel edition', className: clsx(CANCEL_BUTTON_CLASS) },
|
|
54
131
|
React.createElement(Cancel, null))),
|
|
55
|
-
React.createElement(IconButton, { size: "small", color: "primary", onClick: onSend, disabled:
|
|
132
|
+
React.createElement(IconButton, { size: "small", color: "primary", onClick: onSend, disabled: props.onCancel
|
|
133
|
+
? input === props.value
|
|
134
|
+
: !input.trim().length, title: `Send message ${sendWithShiftEnter ? '(SHIFT+ENTER)' : '(ENTER)'}`, className: clsx(SEND_BUTTON_CLASS) },
|
|
56
135
|
React.createElement(Send, null))))
|
|
57
136
|
}, FormHelperTextProps: {
|
|
58
137
|
sx: { marginLeft: 'auto', marginRight: 0 }
|
|
59
|
-
}, helperText: input.length > 2 ? helperText : ' ' }))))
|
|
138
|
+
}, helperText: input.length > 2 ? helperText : ' ' })), ...(_a = autocompletion.current) === null || _a === void 0 ? void 0 : _a.props, inputValue: input, onInputChange: (_, newValue) => {
|
|
139
|
+
setInput(newValue);
|
|
140
|
+
}, onHighlightChange:
|
|
141
|
+
/**
|
|
142
|
+
* On highlight change: set `highlighted` to whether an option is
|
|
143
|
+
* highlighted by the user.
|
|
144
|
+
*
|
|
145
|
+
* This isn't called when an option is selected for some reason, so we
|
|
146
|
+
* need to call `setHighlighted(false)` in `onClose()`.
|
|
147
|
+
*/
|
|
148
|
+
(_, highlightedOption) => {
|
|
149
|
+
setHighlighted(!!highlightedOption);
|
|
150
|
+
}, onClose:
|
|
151
|
+
/**
|
|
152
|
+
* On close: set `highlighted` to `false` and close the popup by
|
|
153
|
+
* setting `open` to `false`.
|
|
154
|
+
*/
|
|
155
|
+
() => {
|
|
156
|
+
setHighlighted(false);
|
|
157
|
+
setOpen(false);
|
|
158
|
+
},
|
|
159
|
+
// hide default extra right padding in the text field
|
|
160
|
+
disableClearable: true })));
|
|
60
161
|
}
|
|
@@ -1,32 +1,62 @@
|
|
|
1
|
-
/// <reference types="react" />
|
|
2
1
|
import { IRenderMimeRegistry } from '@jupyterlab/rendermime';
|
|
3
2
|
import type { SxProps, Theme } from '@mui/material';
|
|
3
|
+
import React from 'react';
|
|
4
4
|
import { IChatModel } from '../model';
|
|
5
|
-
import { IChatMessage
|
|
5
|
+
import { IChatMessage } from '../types';
|
|
6
|
+
/**
|
|
7
|
+
* The base components props.
|
|
8
|
+
*/
|
|
6
9
|
type BaseMessageProps = {
|
|
7
10
|
rmRegistry: IRenderMimeRegistry;
|
|
8
11
|
model: IChatModel;
|
|
9
12
|
};
|
|
10
|
-
|
|
13
|
+
/**
|
|
14
|
+
* The messages list component.
|
|
15
|
+
*/
|
|
16
|
+
export declare function ChatMessages(props: BaseMessageProps): JSX.Element;
|
|
17
|
+
/**
|
|
18
|
+
* The message header props.
|
|
19
|
+
*/
|
|
20
|
+
type ChatMessageHeaderProps = {
|
|
11
21
|
message: IChatMessage;
|
|
12
|
-
};
|
|
13
|
-
type ChatMessagesProps = BaseMessageProps & {
|
|
14
|
-
messages: IChatMessage[];
|
|
15
|
-
};
|
|
16
|
-
export type ChatMessageHeaderProps = IUser & {
|
|
17
|
-
timestamp: number;
|
|
18
|
-
rawTime?: boolean;
|
|
19
|
-
deleted?: boolean;
|
|
20
|
-
edited?: boolean;
|
|
21
22
|
sx?: SxProps<Theme>;
|
|
22
23
|
};
|
|
24
|
+
/**
|
|
25
|
+
* The message header component.
|
|
26
|
+
*/
|
|
23
27
|
export declare function ChatMessageHeader(props: ChatMessageHeaderProps): JSX.Element;
|
|
24
28
|
/**
|
|
25
|
-
* The
|
|
29
|
+
* The message component props.
|
|
26
30
|
*/
|
|
27
|
-
|
|
31
|
+
type ChatMessageProps = BaseMessageProps & {
|
|
32
|
+
/**
|
|
33
|
+
* The message to display.
|
|
34
|
+
*/
|
|
35
|
+
message: IChatMessage;
|
|
36
|
+
/**
|
|
37
|
+
* The index of the message in the list.
|
|
38
|
+
*/
|
|
39
|
+
index: number;
|
|
40
|
+
/**
|
|
41
|
+
* The intersection observer for all the messages.
|
|
42
|
+
*/
|
|
43
|
+
observer: IntersectionObserver | null;
|
|
44
|
+
};
|
|
28
45
|
/**
|
|
29
|
-
*
|
|
46
|
+
* The message component body.
|
|
30
47
|
*/
|
|
31
48
|
export declare function ChatMessage(props: ChatMessageProps): JSX.Element;
|
|
49
|
+
/**
|
|
50
|
+
* The navigation component props.
|
|
51
|
+
*/
|
|
52
|
+
type NavigationProps = BaseMessageProps & {
|
|
53
|
+
/**
|
|
54
|
+
* The reference to the messages container.
|
|
55
|
+
*/
|
|
56
|
+
refMsgBox: React.RefObject<HTMLDivElement>;
|
|
57
|
+
};
|
|
58
|
+
/**
|
|
59
|
+
* The navigation component, to navigate to unread messages.
|
|
60
|
+
*/
|
|
61
|
+
export declare function Navigation(props: NavigationProps): JSX.Element;
|
|
32
62
|
export {};
|
|
@@ -2,15 +2,109 @@
|
|
|
2
2
|
* Copyright (c) Jupyter Development Team.
|
|
3
3
|
* Distributed under the terms of the Modified BSD License.
|
|
4
4
|
*/
|
|
5
|
+
import { Button } from '@jupyter/react-components';
|
|
6
|
+
import { LabIcon, caretDownEmptyIcon, classes } from '@jupyterlab/ui-components';
|
|
5
7
|
import { Avatar, Box, Typography } from '@mui/material';
|
|
6
8
|
import clsx from 'clsx';
|
|
7
|
-
import React, { useState,
|
|
9
|
+
import React, { useEffect, useState, useRef } from 'react';
|
|
8
10
|
import { ChatInput } from './chat-input';
|
|
9
11
|
import { RendermimeMarkdown } from './rendermime-markdown';
|
|
12
|
+
import { ScrollContainer } from './scroll-container';
|
|
10
13
|
const MESSAGES_BOX_CLASS = 'jp-chat-messages-container';
|
|
11
14
|
const MESSAGE_CLASS = 'jp-chat-message';
|
|
15
|
+
const MESSAGE_STACKED_CLASS = 'jp-chat-message-stacked';
|
|
12
16
|
const MESSAGE_HEADER_CLASS = 'jp-chat-message-header';
|
|
13
17
|
const MESSAGE_TIME_CLASS = 'jp-chat-message-time';
|
|
18
|
+
const NAVIGATION_BUTTON_CLASS = 'jp-chat-navigation';
|
|
19
|
+
const NAVIGATION_UNREAD_CLASS = 'jp-chat-navigation-unread';
|
|
20
|
+
const NAVIGATION_TOP_CLASS = 'jp-chat-navigation-top';
|
|
21
|
+
const NAVIGATION_BOTTOM_CLASS = 'jp-chat-navigation-bottom';
|
|
22
|
+
/**
|
|
23
|
+
* The messages list component.
|
|
24
|
+
*/
|
|
25
|
+
export function ChatMessages(props) {
|
|
26
|
+
const { model } = props;
|
|
27
|
+
const [messages, setMessages] = useState(model.messages);
|
|
28
|
+
const refMsgBox = useRef(null);
|
|
29
|
+
const inViewport = useRef([]);
|
|
30
|
+
// The intersection observer that listen to all the message visibility.
|
|
31
|
+
const observerRef = useRef(new IntersectionObserver(viewportChange));
|
|
32
|
+
/**
|
|
33
|
+
* Effect: fetch history and config on initial render
|
|
34
|
+
*/
|
|
35
|
+
useEffect(() => {
|
|
36
|
+
async function fetchHistory() {
|
|
37
|
+
if (!model.getHistory) {
|
|
38
|
+
return;
|
|
39
|
+
}
|
|
40
|
+
model
|
|
41
|
+
.getHistory()
|
|
42
|
+
.then(history => setMessages(history.messages))
|
|
43
|
+
.catch(e => console.error(e));
|
|
44
|
+
}
|
|
45
|
+
fetchHistory();
|
|
46
|
+
}, [model]);
|
|
47
|
+
/**
|
|
48
|
+
* Effect: listen to chat messages.
|
|
49
|
+
*/
|
|
50
|
+
useEffect(() => {
|
|
51
|
+
function handleChatEvents(_) {
|
|
52
|
+
setMessages([...model.messages]);
|
|
53
|
+
}
|
|
54
|
+
model.messagesUpdated.connect(handleChatEvents);
|
|
55
|
+
return function cleanup() {
|
|
56
|
+
model.messagesUpdated.disconnect(handleChatEvents);
|
|
57
|
+
};
|
|
58
|
+
}, [model]);
|
|
59
|
+
/**
|
|
60
|
+
* Function called when a message enter or leave the viewport.
|
|
61
|
+
*/
|
|
62
|
+
function viewportChange(entries) {
|
|
63
|
+
const unread = [...model.unreadMessages];
|
|
64
|
+
let unreadModified = false;
|
|
65
|
+
entries.forEach(entry => {
|
|
66
|
+
var _a;
|
|
67
|
+
const index = parseInt((_a = entry.target.getAttribute('data-index')) !== null && _a !== void 0 ? _a : '');
|
|
68
|
+
if (!isNaN(index)) {
|
|
69
|
+
if (unread.length) {
|
|
70
|
+
const unreadIdx = unread.indexOf(index);
|
|
71
|
+
if (unreadIdx !== -1 && entry.isIntersecting) {
|
|
72
|
+
unread.splice(unreadIdx, 1);
|
|
73
|
+
unreadModified = true;
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
const viewportIdx = inViewport.current.indexOf(index);
|
|
77
|
+
if (!entry.isIntersecting && viewportIdx !== -1) {
|
|
78
|
+
inViewport.current.splice(viewportIdx, 1);
|
|
79
|
+
}
|
|
80
|
+
else if (entry.isIntersecting && viewportIdx === -1) {
|
|
81
|
+
inViewport.current.push(index);
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
});
|
|
85
|
+
props.model.messagesInViewport = inViewport.current;
|
|
86
|
+
if (unreadModified) {
|
|
87
|
+
props.model.unreadMessages = unread;
|
|
88
|
+
}
|
|
89
|
+
return () => {
|
|
90
|
+
var _a;
|
|
91
|
+
(_a = observerRef.current) === null || _a === void 0 ? void 0 : _a.disconnect();
|
|
92
|
+
};
|
|
93
|
+
}
|
|
94
|
+
return (React.createElement(React.Fragment, null,
|
|
95
|
+
React.createElement(ScrollContainer, { sx: { flexGrow: 1 } },
|
|
96
|
+
React.createElement(Box, { ref: refMsgBox, className: clsx(MESSAGES_BOX_CLASS) }, messages.map((message, i) => {
|
|
97
|
+
return (
|
|
98
|
+
// extra div needed to ensure each bubble is on a new line
|
|
99
|
+
React.createElement(Box, { key: i, className: clsx(MESSAGE_CLASS, message.stacked ? MESSAGE_STACKED_CLASS : '') },
|
|
100
|
+
React.createElement(ChatMessageHeader, { message: message }),
|
|
101
|
+
React.createElement(ChatMessage, { ...props, message: message, observer: observerRef.current, index: i })));
|
|
102
|
+
}))),
|
|
103
|
+
React.createElement(Navigation, { ...props, refMsgBox: refMsgBox })));
|
|
104
|
+
}
|
|
105
|
+
/**
|
|
106
|
+
* The message header component.
|
|
107
|
+
*/
|
|
14
108
|
export function ChatMessageHeader(props) {
|
|
15
109
|
var _a, _b;
|
|
16
110
|
const [datetime, setDatetime] = useState({});
|
|
@@ -18,18 +112,20 @@ export function ChatMessageHeader(props) {
|
|
|
18
112
|
height: '24px',
|
|
19
113
|
width: '24px'
|
|
20
114
|
};
|
|
115
|
+
const message = props.message;
|
|
116
|
+
const sender = message.sender;
|
|
21
117
|
/**
|
|
22
118
|
* Effect: update cached datetime strings upon receiving a new message.
|
|
23
119
|
*/
|
|
24
120
|
useEffect(() => {
|
|
25
|
-
if (!datetime[
|
|
121
|
+
if (!datetime[message.time]) {
|
|
26
122
|
const newDatetime = {};
|
|
27
123
|
let datetime;
|
|
28
124
|
const currentDate = new Date();
|
|
29
125
|
const sameDay = (date) => date.getFullYear() === currentDate.getFullYear() &&
|
|
30
126
|
date.getMonth() === currentDate.getMonth() &&
|
|
31
127
|
date.getDate() === currentDate.getDate();
|
|
32
|
-
const msgDate = new Date(
|
|
128
|
+
const msgDate = new Date(message.time * 1000); // Convert message time to milliseconds
|
|
33
129
|
// Display only the time if the day of the message is the current one.
|
|
34
130
|
if (sameDay(msgDate)) {
|
|
35
131
|
// Use the browser's default locale
|
|
@@ -48,29 +144,30 @@ export function ChatMessageHeader(props) {
|
|
|
48
144
|
minute: '2-digit'
|
|
49
145
|
});
|
|
50
146
|
}
|
|
51
|
-
newDatetime[
|
|
147
|
+
newDatetime[message.time] = datetime;
|
|
52
148
|
setDatetime(newDatetime);
|
|
53
149
|
}
|
|
54
150
|
});
|
|
55
|
-
const bgcolor =
|
|
56
|
-
const avatar =
|
|
151
|
+
const bgcolor = sender.color;
|
|
152
|
+
const avatar = message.stacked ? null : sender.avatar_url ? (React.createElement(Avatar, { sx: {
|
|
57
153
|
...sharedStyles,
|
|
58
154
|
...(bgcolor && { bgcolor })
|
|
59
|
-
}, src:
|
|
155
|
+
}, src: sender.avatar_url })) : sender.initials ? (React.createElement(Avatar, { sx: {
|
|
60
156
|
...sharedStyles,
|
|
61
157
|
...(bgcolor && { bgcolor })
|
|
62
158
|
} },
|
|
63
159
|
React.createElement(Typography, { sx: {
|
|
64
160
|
fontSize: 'var(--jp-ui-font-size1)',
|
|
65
161
|
color: 'var(--jp-ui-inverse-font-color1)'
|
|
66
|
-
} },
|
|
67
|
-
const name = (_b = (_a =
|
|
162
|
+
} }, sender.initials))) : null;
|
|
163
|
+
const name = (_b = (_a = sender.display_name) !== null && _a !== void 0 ? _a : sender.name) !== null && _b !== void 0 ? _b : (sender.username || 'User undefined');
|
|
68
164
|
return (React.createElement(Box, { className: MESSAGE_HEADER_CLASS, sx: {
|
|
69
165
|
display: 'flex',
|
|
70
166
|
alignItems: 'center',
|
|
71
167
|
'& > :not(:last-child)': {
|
|
72
168
|
marginRight: 3
|
|
73
169
|
},
|
|
170
|
+
marginBottom: message.stacked ? '0px' : '12px',
|
|
74
171
|
...props.sx
|
|
75
172
|
} },
|
|
76
173
|
avatar,
|
|
@@ -82,64 +179,67 @@ export function ChatMessageHeader(props) {
|
|
|
82
179
|
alignItems: 'center'
|
|
83
180
|
} },
|
|
84
181
|
React.createElement(Box, { sx: { display: 'flex', alignItems: 'center' } },
|
|
85
|
-
React.createElement(Typography, { sx: {
|
|
86
|
-
|
|
182
|
+
!message.stacked && (React.createElement(Typography, { sx: {
|
|
183
|
+
fontWeight: 700,
|
|
184
|
+
color: 'var(--jp-ui-font-color1)',
|
|
185
|
+
paddingRight: '0.5em'
|
|
186
|
+
} }, name)),
|
|
187
|
+
(message.deleted || message.edited) && (React.createElement(Typography, { sx: {
|
|
87
188
|
fontStyle: 'italic',
|
|
88
|
-
fontSize: 'var(--jp-content-font-size0)'
|
|
89
|
-
|
|
90
|
-
} }, props.deleted ? '(message deleted)' : '(edited)'))),
|
|
189
|
+
fontSize: 'var(--jp-content-font-size0)'
|
|
190
|
+
} }, message.deleted ? '(message deleted)' : '(edited)'))),
|
|
91
191
|
React.createElement(Typography, { className: MESSAGE_TIME_CLASS, sx: {
|
|
92
192
|
fontSize: '0.8em',
|
|
93
193
|
color: 'var(--jp-ui-font-color2)',
|
|
94
194
|
fontWeight: 300
|
|
95
|
-
}, title:
|
|
195
|
+
}, title: message.raw_time ? 'Unverified time' : '' }, `${datetime[message.time]}${message.raw_time ? '*' : ''}`))));
|
|
96
196
|
}
|
|
97
197
|
/**
|
|
98
|
-
* The
|
|
99
|
-
*/
|
|
100
|
-
export function ChatMessages(props) {
|
|
101
|
-
return (React.createElement(Box, { sx: {
|
|
102
|
-
'& > :not(:last-child)': {
|
|
103
|
-
borderBottom: '1px solid var(--jp-border-color2)'
|
|
104
|
-
}
|
|
105
|
-
}, className: clsx(MESSAGES_BOX_CLASS) }, props.messages.map((message, i) => {
|
|
106
|
-
let sender;
|
|
107
|
-
if (typeof message.sender === 'string') {
|
|
108
|
-
sender = { username: message.sender };
|
|
109
|
-
}
|
|
110
|
-
else {
|
|
111
|
-
sender = message.sender;
|
|
112
|
-
}
|
|
113
|
-
return (
|
|
114
|
-
// extra div needed to ensure each bubble is on a new line
|
|
115
|
-
React.createElement(Box, { key: i, sx: { padding: '1em 1em 0 1em' }, className: clsx(MESSAGE_CLASS) },
|
|
116
|
-
React.createElement(ChatMessageHeader, { ...sender, timestamp: message.time, rawTime: message.raw_time, deleted: message.deleted, edited: message.edited, sx: { marginBottom: 3 } }),
|
|
117
|
-
React.createElement(ChatMessage, { ...props, message: message })));
|
|
118
|
-
})));
|
|
119
|
-
}
|
|
120
|
-
/**
|
|
121
|
-
* the message UI.
|
|
198
|
+
* The message component body.
|
|
122
199
|
*/
|
|
123
200
|
export function ChatMessage(props) {
|
|
124
201
|
var _a;
|
|
125
202
|
const { message, model, rmRegistry } = props;
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
203
|
+
const elementRef = useRef(null);
|
|
204
|
+
const [edit, setEdit] = useState(false);
|
|
205
|
+
const [deleted, setDeleted] = useState(false);
|
|
206
|
+
const [canEdit, setCanEdit] = useState(false);
|
|
207
|
+
const [canDelete, setCanDelete] = useState(false);
|
|
208
|
+
// Add the current message to the observer, to actualize viewport and unread messages.
|
|
209
|
+
useEffect(() => {
|
|
210
|
+
var _a;
|
|
211
|
+
if (elementRef.current === null) {
|
|
212
|
+
return;
|
|
134
213
|
}
|
|
135
|
-
|
|
136
|
-
|
|
214
|
+
// If the observer is defined, let's observe the message.
|
|
215
|
+
(_a = props.observer) === null || _a === void 0 ? void 0 : _a.observe(elementRef.current);
|
|
216
|
+
return () => {
|
|
217
|
+
var _a;
|
|
218
|
+
if (elementRef.current !== null) {
|
|
219
|
+
(_a = props.observer) === null || _a === void 0 ? void 0 : _a.unobserve(elementRef.current);
|
|
220
|
+
}
|
|
221
|
+
};
|
|
222
|
+
}, [model]);
|
|
223
|
+
// Look if the message can be deleted or edited.
|
|
224
|
+
useEffect(() => {
|
|
225
|
+
var _a;
|
|
226
|
+
setDeleted((_a = message.deleted) !== null && _a !== void 0 ? _a : false);
|
|
227
|
+
if (model.user !== undefined && !message.deleted) {
|
|
228
|
+
if (model.user.username === message.sender.username) {
|
|
229
|
+
setCanEdit(model.updateMessage !== undefined);
|
|
230
|
+
setCanDelete(model.deleteMessage !== undefined);
|
|
231
|
+
}
|
|
137
232
|
}
|
|
138
|
-
|
|
139
|
-
|
|
233
|
+
else {
|
|
234
|
+
setCanEdit(false);
|
|
235
|
+
setCanDelete(false);
|
|
236
|
+
}
|
|
237
|
+
}, [model, message]);
|
|
238
|
+
// Cancel the current edition of the message.
|
|
140
239
|
const cancelEdition = () => {
|
|
141
240
|
setEdit(false);
|
|
142
241
|
};
|
|
242
|
+
// Update the content of the message.
|
|
143
243
|
const updateMessage = (id, input) => {
|
|
144
244
|
if (!canEdit) {
|
|
145
245
|
return;
|
|
@@ -150,13 +250,96 @@ export function ChatMessage(props) {
|
|
|
150
250
|
model.updateMessage(id, updatedMessage);
|
|
151
251
|
setEdit(false);
|
|
152
252
|
};
|
|
253
|
+
// Delete the message.
|
|
153
254
|
const deleteMessage = (id) => {
|
|
154
255
|
if (!canDelete) {
|
|
155
256
|
return;
|
|
156
257
|
}
|
|
157
|
-
// Delete the message
|
|
158
258
|
model.deleteMessage(id);
|
|
159
259
|
};
|
|
160
|
-
// Empty if the message has been deleted
|
|
161
|
-
return
|
|
260
|
+
// Empty if the message has been deleted.
|
|
261
|
+
return deleted ? (React.createElement("div", { ref: elementRef, "data-index": props.index })) : (React.createElement("div", { ref: elementRef, "data-index": props.index }, edit && canEdit ? (React.createElement(ChatInput, { value: message.body, onSend: (input) => updateMessage(message.id, input), onCancel: () => cancelEdition(), sendWithShiftEnter: (_a = model.config.sendWithShiftEnter) !== null && _a !== void 0 ? _a : false })) : (React.createElement(RendermimeMarkdown, { rmRegistry: rmRegistry, markdownStr: message.body, edit: canEdit ? () => setEdit(true) : undefined, delete: canDelete ? () => deleteMessage(message.id) : undefined }))));
|
|
262
|
+
}
|
|
263
|
+
/**
|
|
264
|
+
* The navigation component, to navigate to unread messages.
|
|
265
|
+
*/
|
|
266
|
+
export function Navigation(props) {
|
|
267
|
+
const { model } = props;
|
|
268
|
+
const [lastInViewport, setLastInViewport] = useState(true);
|
|
269
|
+
const [unreadBefore, setUnreadBefore] = useState(null);
|
|
270
|
+
const [unreadAfter, setUnreadAfter] = useState(null);
|
|
271
|
+
const gotoMessage = (msgIdx) => {
|
|
272
|
+
var _a, _b;
|
|
273
|
+
(_b = (_a = props.refMsgBox.current) === null || _a === void 0 ? void 0 : _a.children.item(msgIdx)) === null || _b === void 0 ? void 0 : _b.scrollIntoView();
|
|
274
|
+
};
|
|
275
|
+
// Listen for change in unread messages, and find the first unread message before or
|
|
276
|
+
// after the current viewport, to display navigation buttons.
|
|
277
|
+
useEffect(() => {
|
|
278
|
+
var _a;
|
|
279
|
+
const unreadChanged = (model, unreadIndexes) => {
|
|
280
|
+
const viewport = model.messagesInViewport;
|
|
281
|
+
if (!viewport) {
|
|
282
|
+
return;
|
|
283
|
+
}
|
|
284
|
+
// Initialize the next values with the current values if there still relevant.
|
|
285
|
+
let before = unreadBefore !== null &&
|
|
286
|
+
unreadIndexes.includes(unreadBefore) &&
|
|
287
|
+
unreadBefore < Math.min(...viewport)
|
|
288
|
+
? unreadBefore
|
|
289
|
+
: null;
|
|
290
|
+
let after = unreadAfter !== null &&
|
|
291
|
+
unreadIndexes.includes(unreadAfter) &&
|
|
292
|
+
unreadAfter > Math.max(...viewport)
|
|
293
|
+
? unreadAfter
|
|
294
|
+
: null;
|
|
295
|
+
unreadIndexes.forEach(unread => {
|
|
296
|
+
if (viewport === null || viewport === void 0 ? void 0 : viewport.includes(unread)) {
|
|
297
|
+
return;
|
|
298
|
+
}
|
|
299
|
+
if (unread < (before !== null && before !== void 0 ? before : Math.min(...viewport))) {
|
|
300
|
+
before = unread;
|
|
301
|
+
}
|
|
302
|
+
else if (unread > Math.max(...viewport) &&
|
|
303
|
+
unread < (after !== null && after !== void 0 ? after : model.messages.length)) {
|
|
304
|
+
after = unread;
|
|
305
|
+
}
|
|
306
|
+
});
|
|
307
|
+
setUnreadBefore(before);
|
|
308
|
+
setUnreadAfter(after);
|
|
309
|
+
};
|
|
310
|
+
(_a = model.unreadChanged) === null || _a === void 0 ? void 0 : _a.connect(unreadChanged);
|
|
311
|
+
unreadChanged(model, model.unreadMessages);
|
|
312
|
+
// Move to first the unread message or to last message on first rendering.
|
|
313
|
+
if (model.unreadMessages.length) {
|
|
314
|
+
gotoMessage(Math.min(...model.unreadMessages));
|
|
315
|
+
}
|
|
316
|
+
else {
|
|
317
|
+
gotoMessage(model.messages.length - 1);
|
|
318
|
+
}
|
|
319
|
+
return () => {
|
|
320
|
+
var _a;
|
|
321
|
+
(_a = model.unreadChanged) === null || _a === void 0 ? void 0 : _a.disconnect(unreadChanged);
|
|
322
|
+
};
|
|
323
|
+
}, [model]);
|
|
324
|
+
// Listen for change in the viewport, to add a navigation button if the last is not
|
|
325
|
+
// in viewport.
|
|
326
|
+
useEffect(() => {
|
|
327
|
+
var _a, _b;
|
|
328
|
+
const viewportChanged = (model, viewport) => {
|
|
329
|
+
setLastInViewport(viewport.includes(model.messages.length - 1));
|
|
330
|
+
};
|
|
331
|
+
(_a = model.viewportChanged) === null || _a === void 0 ? void 0 : _a.connect(viewportChanged);
|
|
332
|
+
viewportChanged(model, (_b = model.messagesInViewport) !== null && _b !== void 0 ? _b : []);
|
|
333
|
+
return () => {
|
|
334
|
+
var _a;
|
|
335
|
+
(_a = model.viewportChanged) === null || _a === void 0 ? void 0 : _a.disconnect(viewportChanged);
|
|
336
|
+
};
|
|
337
|
+
}, [model]);
|
|
338
|
+
return (React.createElement(React.Fragment, null,
|
|
339
|
+
unreadBefore !== null && (React.createElement(Button, { className: `${NAVIGATION_BUTTON_CLASS} ${NAVIGATION_UNREAD_CLASS} ${NAVIGATION_TOP_CLASS}`, onClick: () => gotoMessage(unreadBefore), title: 'Go to unread messages' },
|
|
340
|
+
React.createElement(LabIcon.resolveReact, { display: 'flex', icon: caretDownEmptyIcon, iconClass: classes('jp-Icon') }))),
|
|
341
|
+
(unreadAfter !== null || !lastInViewport) && (React.createElement(Button, { className: `${NAVIGATION_BUTTON_CLASS} ${unreadAfter !== null ? NAVIGATION_UNREAD_CLASS : ''} ${NAVIGATION_BOTTOM_CLASS}`, onClick: () => gotoMessage(unreadAfter !== null ? unreadAfter : model.messages.length - 1), title: unreadAfter !== null
|
|
342
|
+
? 'Go to unread messages'
|
|
343
|
+
: 'Go to last message' },
|
|
344
|
+
React.createElement(LabIcon.resolveReact, { display: 'flex', icon: caretDownEmptyIcon, iconClass: classes('jp-Icon') })))));
|
|
162
345
|
}
|