Skip to content

Commit 4b0a1eb

Browse files
authored
Add typing notifications (#85)
* Add current writers components in the chat, handled from the model * Writing event when input changes instead of key pressed * Handle the writing event in collaborative chat, using the document awareness * Add a config to disable sending typing notification * Add tests * Reset writing status when sending a message
1 parent b73f5a8 commit 4b0a1eb

File tree

9 files changed

+401
-57
lines changed

9 files changed

+401
-57
lines changed

packages/jupyter-chat/src/components/chat-input.tsx

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,9 @@ export function ChatInput(props: ChatInput.IProps): JSX.Element {
3535
const [sendWithShiftEnter, setSendWithShiftEnter] = useState<boolean>(
3636
model.config.sendWithShiftEnter ?? false
3737
);
38+
const [typingNotification, setTypingNotification] = useState<boolean>(
39+
model.config.sendTypingNotification ?? false
40+
);
3841

3942
// Display the include selection menu if it is not explicitly hidden, and if at least
4043
// one of the tool to check for text or cell selection is enabled.
@@ -49,6 +52,7 @@ export function ChatInput(props: ChatInput.IProps): JSX.Element {
4952
useEffect(() => {
5053
const configChanged = (_: IChatModel, config: IConfig) => {
5154
setSendWithShiftEnter(config.sendWithShiftEnter ?? false);
55+
setTypingNotification(config.sendTypingNotification ?? false);
5256
};
5357
model.configChanged.connect(configChanged);
5458

@@ -247,6 +251,9 @@ ${selection.source}
247251
inputValue={input}
248252
onInputChange={(_, newValue: string) => {
249253
setInput(newValue);
254+
if (typingNotification && model.inputChanged) {
255+
model.inputChanged(newValue);
256+
}
250257
}}
251258
onHighlightChange={
252259
/**

packages/jupyter-chat/src/components/chat-messages.tsx

Lines changed: 105 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ import {
1010
caretDownEmptyIcon,
1111
classes
1212
} from '@jupyterlab/ui-components';
13-
import { Avatar, Box, Typography } from '@mui/material';
13+
import { Avatar as MuiAvatar, Box, Typography } from '@mui/material';
1414
import type { SxProps, Theme } from '@mui/material';
1515
import clsx from 'clsx';
1616
import React, { useEffect, useState, useRef } from 'react';
@@ -19,13 +19,14 @@ import { ChatInput } from './chat-input';
1919
import { RendermimeMarkdown } from './rendermime-markdown';
2020
import { ScrollContainer } from './scroll-container';
2121
import { IChatModel } from '../model';
22-
import { IChatMessage } from '../types';
22+
import { IChatMessage, IUser } from '../types';
2323

2424
const MESSAGES_BOX_CLASS = 'jp-chat-messages-container';
2525
const MESSAGE_CLASS = 'jp-chat-message';
2626
const MESSAGE_STACKED_CLASS = 'jp-chat-message-stacked';
2727
const MESSAGE_HEADER_CLASS = 'jp-chat-message-header';
2828
const MESSAGE_TIME_CLASS = 'jp-chat-message-time';
29+
const WRITERS_CLASS = 'jp-chat-writers';
2930
const NAVIGATION_BUTTON_CLASS = 'jp-chat-navigation';
3031
const NAVIGATION_UNREAD_CLASS = 'jp-chat-navigation-unread';
3132
const NAVIGATION_TOP_CLASS = 'jp-chat-navigation-top';
@@ -47,6 +48,7 @@ export function ChatMessages(props: BaseMessageProps): JSX.Element {
4748
const [messages, setMessages] = useState<IChatMessage[]>(model.messages);
4849
const refMsgBox = useRef<HTMLDivElement>(null);
4950
const inViewport = useRef<number[]>([]);
51+
const [currentWriters, setCurrentWriters] = useState<IUser[]>([]);
5052

5153
// The intersection observer that listen to all the message visibility.
5254
const observerRef = useRef<IntersectionObserver>(
@@ -68,6 +70,7 @@ export function ChatMessages(props: BaseMessageProps): JSX.Element {
6870
}
6971

7072
fetchHistory();
73+
setCurrentWriters([]);
7174
}, [model]);
7275

7376
/**
@@ -78,9 +81,16 @@ export function ChatMessages(props: BaseMessageProps): JSX.Element {
7881
setMessages([...model.messages]);
7982
}
8083

84+
function handleWritersChange(_: IChatModel, writers: IUser[]) {
85+
setCurrentWriters(writers);
86+
}
87+
8188
model.messagesUpdated.connect(handleChatEvents);
89+
model.writersChanged?.connect(handleWritersChange);
90+
8291
return function cleanup() {
8392
model.messagesUpdated.disconnect(handleChatEvents);
93+
model.writersChanged?.disconnect(handleChatEvents);
8494
};
8595
}, [model]);
8696

@@ -144,6 +154,7 @@ export function ChatMessages(props: BaseMessageProps): JSX.Element {
144154
);
145155
})}
146156
</Box>
157+
<Writers writers={currentWriters}></Writers>
147158
</ScrollContainer>
148159
<Navigation {...props} refMsgBox={refMsgBox} />
149160
</>
@@ -163,10 +174,6 @@ type ChatMessageHeaderProps = {
163174
*/
164175
export function ChatMessageHeader(props: ChatMessageHeaderProps): JSX.Element {
165176
const [datetime, setDatetime] = useState<Record<number, string>>({});
166-
const sharedStyles: SxProps<Theme> = {
167-
height: '24px',
168-
width: '24px'
169-
};
170177
const message = props.message;
171178
const sender = message.sender;
172179
/**
@@ -206,32 +213,7 @@ export function ChatMessageHeader(props: ChatMessageHeaderProps): JSX.Element {
206213
}
207214
});
208215

209-
const bgcolor = sender.color;
210-
const avatar = message.stacked ? null : sender.avatar_url ? (
211-
<Avatar
212-
sx={{
213-
...sharedStyles,
214-
...(bgcolor && { bgcolor })
215-
}}
216-
src={sender.avatar_url}
217-
></Avatar>
218-
) : sender.initials ? (
219-
<Avatar
220-
sx={{
221-
...sharedStyles,
222-
...(bgcolor && { bgcolor })
223-
}}
224-
>
225-
<Typography
226-
sx={{
227-
fontSize: 'var(--jp-ui-font-size1)',
228-
color: 'var(--jp-ui-inverse-font-color1)'
229-
}}
230-
>
231-
{sender.initials}
232-
</Typography>
233-
</Avatar>
234-
) : null;
216+
const avatar = message.stacked ? null : Avatar({ user: sender });
235217

236218
const name =
237219
sender.display_name ?? sender.name ?? (sender.username || 'User undefined');
@@ -408,6 +390,45 @@ export function ChatMessage(props: ChatMessageProps): JSX.Element {
408390
);
409391
}
410392

393+
/**
394+
* The writers component props.
395+
*/
396+
type writersProps = {
397+
/**
398+
* The list of users currently writing.
399+
*/
400+
writers: IUser[];
401+
};
402+
403+
/**
404+
* The writers component, displaying the current writers.
405+
*/
406+
export function Writers(props: writersProps): JSX.Element | null {
407+
const { writers } = props;
408+
return writers.length > 0 ? (
409+
<Box className={WRITERS_CLASS}>
410+
{writers.map((writer, index) => (
411+
<div>
412+
<Avatar user={writer} small />
413+
<span>
414+
{writer.display_name ??
415+
writer.name ??
416+
(writer.username || 'User undefined')}
417+
</span>
418+
<span>
419+
{index < writers.length - 1
420+
? index < writers.length - 2
421+
? ', '
422+
: ' and '
423+
: ''}
424+
</span>
425+
</div>
426+
))}
427+
<span>{(writers.length > 1 ? ' are' : ' is') + ' writing'}</span>
428+
</Box>
429+
) : null;
430+
}
431+
411432
/**
412433
* The navigation component props.
413434
*/
@@ -544,3 +565,55 @@ export function Navigation(props: NavigationProps): JSX.Element {
544565
</>
545566
);
546567
}
568+
569+
/**
570+
* The avatar props.
571+
*/
572+
type AvatarProps = {
573+
/**
574+
* The user to display an avatar.
575+
*/
576+
user: IUser;
577+
/**
578+
* Whether the avatar should be small.
579+
*/
580+
small?: boolean;
581+
};
582+
583+
/**
584+
* the avatar component.
585+
*/
586+
export function Avatar(props: AvatarProps): JSX.Element | null {
587+
const { user } = props;
588+
589+
const sharedStyles: SxProps<Theme> = {
590+
height: `${props.small ? '16' : '24'}px`,
591+
width: `${props.small ? '16' : '24'}px`,
592+
bgcolor: user.color,
593+
fontSize: `var(--jp-ui-font-size${props.small ? '0' : '1'})`
594+
};
595+
596+
return user.avatar_url ? (
597+
<MuiAvatar
598+
sx={{
599+
...sharedStyles
600+
}}
601+
src={user.avatar_url}
602+
></MuiAvatar>
603+
) : user.initials ? (
604+
<MuiAvatar
605+
sx={{
606+
...sharedStyles
607+
}}
608+
>
609+
<Typography
610+
sx={{
611+
fontSize: `var(--jp-ui-font-size${props.small ? '0' : '1'})`,
612+
color: 'var(--jp-ui-inverse-font-color1)'
613+
}}
614+
>
615+
{user.initials}
616+
</Typography>
617+
</MuiAvatar>
618+
) : null;
619+
}

packages/jupyter-chat/src/model.ts

Lines changed: 41 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -81,6 +81,11 @@ export interface IChatModel extends IDisposable {
8181
*/
8282
readonly viewportChanged?: ISignal<IChatModel, number[]>;
8383

84+
/**
85+
* A signal emitting when the writers change.
86+
*/
87+
readonly writersChanged?: ISignal<IChatModel, IUser[]>;
88+
8489
/**
8590
* A signal emitting when the focus is requested on the input.
8691
*/
@@ -151,10 +156,20 @@ export interface IChatModel extends IDisposable {
151156
*/
152157
messagesDeleted(index: number, count: number): void;
153158

159+
/**
160+
* Update the current writers list.
161+
*/
162+
updateWriters(writers: IUser[]): void;
163+
154164
/**
155165
* Function to request the focus on the input of the chat.
156166
*/
157167
focusInput(): void;
168+
169+
/**
170+
* Function called by the input on key pressed.
171+
*/
172+
inputChanged?(input?: string): void;
158173
}
159174

160175
/**
@@ -170,7 +185,11 @@ export class ChatModel implements IChatModel {
170185
const config = options.config ?? {};
171186

172187
// Stack consecutive messages from the same user by default.
173-
this._config = { stackMessages: true, ...config };
188+
this._config = {
189+
stackMessages: true,
190+
sendTypingNotification: true,
191+
...config
192+
};
174193

175194
this._commands = options.commands;
176195

@@ -356,6 +375,13 @@ export class ChatModel implements IChatModel {
356375
return this._viewportChanged;
357376
}
358377

378+
/**
379+
* A signal emitting when the writers change.
380+
*/
381+
get writersChanged(): ISignal<IChatModel, IUser[]> {
382+
return this._writersChanged;
383+
}
384+
359385
/**
360386
* A signal emitting when the focus is requested on the input.
361387
*/
@@ -470,13 +496,26 @@ export class ChatModel implements IChatModel {
470496
this._messagesUpdated.emit();
471497
}
472498

499+
/**
500+
* Update the current writers list.
501+
* This implementation only propagate the list via a signal.
502+
*/
503+
updateWriters(writers: IUser[]): void {
504+
this._writersChanged.emit(writers);
505+
}
506+
473507
/**
474508
* Function to request the focus on the input of the chat.
475509
*/
476510
focusInput(): void {
477511
this._focusInputSignal.emit();
478512
}
479513

514+
/**
515+
* Function called by the input on key pressed.
516+
*/
517+
inputChanged?(input?: string): void {}
518+
480519
/**
481520
* Add unread messages to the list.
482521
* @param indexes - list of new indexes.
@@ -541,6 +580,7 @@ export class ChatModel implements IChatModel {
541580
private _configChanged = new Signal<IChatModel, IConfig>(this);
542581
private _unreadChanged = new Signal<IChatModel, number[]>(this);
543582
private _viewportChanged = new Signal<IChatModel, number[]>(this);
583+
private _writersChanged = new Signal<IChatModel, IUser[]>(this);
544584
private _focusInputSignal = new Signal<ChatModel, void>(this);
545585
}
546586

packages/jupyter-chat/src/types.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,10 @@ export interface IConfig {
3939
* Whether to enable or not the code toolbar.
4040
*/
4141
enableCodeToolbar?: boolean;
42+
/**
43+
* Whether to send typing notification.
44+
*/
45+
sendTypingNotification?: boolean;
4246
}
4347

4448
/**

packages/jupyter-chat/style/chat.css

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,19 @@
6363
margin-top: 0px;
6464
}
6565

66+
.jp-chat-writers {
67+
display: flex;
68+
flex-wrap: wrap;
69+
}
70+
71+
.jp-chat-writers > div {
72+
display: flex;
73+
align-items: center;
74+
gap: 0.2em;
75+
white-space: pre;
76+
padding-left: 0.5em;
77+
}
78+
6679
.jp-chat-navigation {
6780
position: absolute;
6881
right: 10px;

0 commit comments

Comments
 (0)