@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.
Files changed (96) hide show
  1. package/lib/active-cell-manager.d.ts +2 -0
  2. package/lib/active-cell-manager.js +7 -2
  3. package/lib/components/avatar.d.ts +20 -0
  4. package/lib/components/avatar.js +29 -0
  5. package/lib/components/chat.d.ts +1 -3
  6. package/lib/components/chat.js +2 -3
  7. package/lib/components/index.d.ts +2 -3
  8. package/lib/components/index.js +2 -3
  9. package/lib/components/input/buttons/send-button.js +15 -5
  10. package/lib/components/{chat-input.d.ts → input/chat-input.d.ts} +3 -3
  11. package/lib/components/{chat-input.js → input/chat-input.js} +8 -5
  12. package/lib/components/input/index.d.ts +1 -0
  13. package/lib/components/input/index.js +1 -0
  14. package/lib/components/input/toolbar-registry.d.ts +6 -0
  15. package/lib/components/input/use-chat-commands.d.ts +1 -1
  16. package/lib/components/input/use-chat-commands.js +32 -13
  17. package/lib/components/messages/footer.d.ts +2 -2
  18. package/lib/components/messages/footer.js +1 -1
  19. package/lib/components/messages/header.d.ts +16 -0
  20. package/lib/components/messages/header.js +85 -0
  21. package/lib/components/messages/index.d.ts +9 -0
  22. package/lib/components/messages/index.js +13 -0
  23. package/lib/components/messages/message-renderer.js +1 -1
  24. package/lib/components/messages/message.d.ts +21 -0
  25. package/lib/components/messages/message.js +102 -0
  26. package/lib/components/messages/messages.d.ts +38 -0
  27. package/lib/components/messages/messages.js +139 -0
  28. package/lib/components/messages/navigation.d.ts +20 -0
  29. package/lib/components/messages/navigation.js +98 -0
  30. package/lib/components/messages/writers.d.ts +16 -0
  31. package/lib/components/messages/writers.js +39 -0
  32. package/lib/context.d.ts +1 -1
  33. package/lib/index.d.ts +2 -6
  34. package/lib/index.js +2 -6
  35. package/lib/input-model.js +30 -3
  36. package/lib/{registry.d.ts → registers/attachment-openers.d.ts} +1 -1
  37. package/lib/registers/chat-commands.d.ts +108 -0
  38. package/lib/{chat-commands/registry.js → registers/chat-commands.js} +8 -8
  39. package/lib/{footers/registry.d.ts → registers/footers.d.ts} +30 -5
  40. package/lib/registers/index.d.ts +3 -0
  41. package/lib/{footers → registers}/index.js +3 -2
  42. package/lib/selection-watcher.d.ts +11 -1
  43. package/lib/selection-watcher.js +10 -4
  44. package/lib/types.d.ts +7 -2
  45. package/lib/utils.js +8 -6
  46. package/lib/widgets/index.d.ts +3 -0
  47. package/lib/{chat-commands → widgets}/index.js +3 -2
  48. package/package.json +3 -1
  49. package/src/active-cell-manager.ts +10 -1
  50. package/src/components/avatar.tsx +68 -0
  51. package/src/components/chat.tsx +11 -6
  52. package/src/components/index.ts +2 -3
  53. package/src/components/input/buttons/send-button.tsx +17 -5
  54. package/src/components/{chat-input.tsx → input/chat-input.tsx} +13 -8
  55. package/src/components/input/index.ts +1 -0
  56. package/src/components/input/toolbar-registry.tsx +6 -0
  57. package/src/components/input/use-chat-commands.tsx +39 -16
  58. package/src/components/messages/footer.tsx +5 -2
  59. package/src/components/messages/header.tsx +133 -0
  60. package/src/components/messages/index.ts +14 -0
  61. package/src/components/messages/message-renderer.tsx +1 -1
  62. package/src/components/messages/message.tsx +156 -0
  63. package/src/components/messages/messages.tsx +218 -0
  64. package/src/components/messages/navigation.tsx +167 -0
  65. package/src/components/messages/welcome.tsx +1 -0
  66. package/src/components/messages/writers.tsx +81 -0
  67. package/src/context.ts +1 -1
  68. package/src/index.ts +2 -6
  69. package/src/input-model.ts +33 -4
  70. package/src/{registry.ts → registers/attachment-openers.ts} +2 -1
  71. package/src/registers/chat-commands.ts +142 -0
  72. package/src/{footers/registry.ts → registers/footers.ts} +35 -8
  73. package/src/{footers → registers}/index.ts +3 -2
  74. package/src/selection-watcher.ts +28 -5
  75. package/src/types.ts +7 -2
  76. package/src/utils.ts +8 -6
  77. package/src/{chat-commands → widgets}/index.ts +3 -2
  78. package/style/chat.css +82 -0
  79. package/lib/chat-commands/index.d.ts +0 -2
  80. package/lib/chat-commands/registry.d.ts +0 -28
  81. package/lib/chat-commands/types.d.ts +0 -52
  82. package/lib/chat-commands/types.js +0 -5
  83. package/lib/components/chat-messages.d.ts +0 -119
  84. package/lib/components/chat-messages.js +0 -446
  85. package/lib/footers/index.d.ts +0 -2
  86. package/lib/footers/types.d.ts +0 -26
  87. package/lib/footers/types.js +0 -5
  88. package/src/chat-commands/registry.ts +0 -60
  89. package/src/chat-commands/types.ts +0 -67
  90. package/src/components/chat-messages.tsx +0 -739
  91. package/src/footers/types.ts +0 -33
  92. package/lib/components/{toolbar.d.ts → messages/toolbar.d.ts} +0 -0
  93. package/lib/components/{toolbar.js → messages/toolbar.js} +0 -0
  94. package/lib/{registry.js → registers/attachment-openers.js} +0 -0
  95. package/lib/{footers/registry.js → registers/footers.js} +4 -4
  96. /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 '../../chat-commands';
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 newCommands: ChatCommand[] = [];
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
- newCommands = newCommands.concat(
66
- await provider.getChatCommands(inputModel)
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
- if (newCommands) {
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(newCommands);
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 by the user.
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
- // if replaceWith is set, handle the command immediately
113
- if (command.replaceWith) {
114
- inputModel.replaceCurrentWord(command.replaceWith);
115
- return;
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) => command.name,
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 '../../footers';
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 MessageFooter(props: IMessageFootersProps): JSX.Element {
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 '../toolbar';
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
+ }