@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.
@@ -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 { Box, TextField, IconButton, InputAdornment } from '@mui/material';
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
- ((props.sendWithShiftEnter && event.shiftKey) ||
17
- (!props.sendWithShiftEnter && !event.shiftKey))) {
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(Box, { sx: { display: 'flex' } },
51
- React.createElement(TextField, { value: input, onChange: e => setInput(e.target.value), fullWidth: true, variant: "outlined", multiline: true, onKeyDown: handleKeyDown, placeholder: "Start chatting", InputProps: {
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, disabled: !input.trim().length, title: 'Cancel edition', className: clsx(CANCEL_BUTTON_CLASS) },
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: !input.trim().length, title: `Send message ${props.sendWithShiftEnter ? '(SHIFT+ENTER)' : '(ENTER)'}`, className: clsx(SEND_BUTTON_CLASS) },
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, IUser } from '../types';
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
- type ChatMessageProps = BaseMessageProps & {
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 messages list UI.
29
+ * The message component props.
26
30
  */
27
- export declare function ChatMessages(props: ChatMessagesProps): JSX.Element;
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
- * the message UI.
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, useEffect } from 'react';
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[props.timestamp]) {
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(props.timestamp * 1000); // Convert message time to milliseconds
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[props.timestamp] = datetime;
147
+ newDatetime[message.time] = datetime;
52
148
  setDatetime(newDatetime);
53
149
  }
54
150
  });
55
- const bgcolor = props.color;
56
- const avatar = props.avatar_url ? (React.createElement(Avatar, { sx: {
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: props.avatar_url })) : props.initials ? (React.createElement(Avatar, { sx: {
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
- } }, props.initials))) : null;
67
- const name = (_b = (_a = props.display_name) !== null && _a !== void 0 ? _a : props.name) !== null && _b !== void 0 ? _b : (props.username || 'User undefined');
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: { fontWeight: 700, color: 'var(--jp-ui-font-color1)' } }, name),
86
- (props.deleted || props.edited) && (React.createElement(Typography, { sx: {
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
- paddingLeft: '0.5em'
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: props.rawTime ? 'Unverified time' : '' }, `${datetime[props.timestamp]}${props.rawTime ? '*' : ''}`))));
195
+ }, title: message.raw_time ? 'Unverified time' : '' }, `${datetime[message.time]}${message.raw_time ? '*' : ''}`))));
96
196
  }
97
197
  /**
98
- * The messages list UI.
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
- let canEdit = false;
127
- let canDelete = false;
128
- if (model.user !== undefined && !message.deleted) {
129
- const username = typeof message.sender === 'string'
130
- ? message.sender
131
- : message.sender.username;
132
- if (model.user.username === username && model.updateMessage !== undefined) {
133
- canEdit = true;
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
- if (model.user.username === username && model.deleteMessage !== undefined) {
136
- canDelete = true;
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
- const [edit, setEdit] = useState(false);
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 message.deleted ? (React.createElement(React.Fragment, null)) : (React.createElement("div", null, 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 }))));
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
  }