Skip to content

Commit

Permalink
Add typing notifications (#85)
Browse files Browse the repository at this point in the history
* 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
  • Loading branch information
brichet authored Oct 8, 2024
1 parent b73f5a8 commit 4b0a1eb
Show file tree
Hide file tree
Showing 9 changed files with 401 additions and 57 deletions.
7 changes: 7 additions & 0 deletions packages/jupyter-chat/src/components/chat-input.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,9 @@ export function ChatInput(props: ChatInput.IProps): JSX.Element {
const [sendWithShiftEnter, setSendWithShiftEnter] = useState<boolean>(
model.config.sendWithShiftEnter ?? false
);
const [typingNotification, setTypingNotification] = useState<boolean>(
model.config.sendTypingNotification ?? false
);

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

Expand Down Expand Up @@ -247,6 +251,9 @@ ${selection.source}
inputValue={input}
onInputChange={(_, newValue: string) => {
setInput(newValue);
if (typingNotification && model.inputChanged) {
model.inputChanged(newValue);
}
}}
onHighlightChange={
/**
Expand Down
137 changes: 105 additions & 32 deletions packages/jupyter-chat/src/components/chat-messages.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ import {
caretDownEmptyIcon,
classes
} from '@jupyterlab/ui-components';
import { Avatar, Box, Typography } from '@mui/material';
import { Avatar as MuiAvatar, Box, Typography } from '@mui/material';
import type { SxProps, Theme } from '@mui/material';
import clsx from 'clsx';
import React, { useEffect, useState, useRef } from 'react';
Expand All @@ -19,13 +19,14 @@ import { ChatInput } from './chat-input';
import { RendermimeMarkdown } from './rendermime-markdown';
import { ScrollContainer } from './scroll-container';
import { IChatModel } from '../model';
import { IChatMessage } from '../types';
import { IChatMessage, IUser } from '../types';

const MESSAGES_BOX_CLASS = 'jp-chat-messages-container';
const MESSAGE_CLASS = 'jp-chat-message';
const MESSAGE_STACKED_CLASS = 'jp-chat-message-stacked';
const MESSAGE_HEADER_CLASS = 'jp-chat-message-header';
const MESSAGE_TIME_CLASS = 'jp-chat-message-time';
const WRITERS_CLASS = 'jp-chat-writers';
const NAVIGATION_BUTTON_CLASS = 'jp-chat-navigation';
const NAVIGATION_UNREAD_CLASS = 'jp-chat-navigation-unread';
const NAVIGATION_TOP_CLASS = 'jp-chat-navigation-top';
Expand All @@ -47,6 +48,7 @@ export function ChatMessages(props: BaseMessageProps): JSX.Element {
const [messages, setMessages] = useState<IChatMessage[]>(model.messages);
const refMsgBox = useRef<HTMLDivElement>(null);
const inViewport = useRef<number[]>([]);
const [currentWriters, setCurrentWriters] = useState<IUser[]>([]);

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

fetchHistory();
setCurrentWriters([]);
}, [model]);

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

function handleWritersChange(_: IChatModel, writers: IUser[]) {
setCurrentWriters(writers);
}

model.messagesUpdated.connect(handleChatEvents);
model.writersChanged?.connect(handleWritersChange);

return function cleanup() {
model.messagesUpdated.disconnect(handleChatEvents);
model.writersChanged?.disconnect(handleChatEvents);
};
}, [model]);

Expand Down Expand Up @@ -144,6 +154,7 @@ export function ChatMessages(props: BaseMessageProps): JSX.Element {
);
})}
</Box>
<Writers writers={currentWriters}></Writers>
</ScrollContainer>
<Navigation {...props} refMsgBox={refMsgBox} />
</>
Expand All @@ -163,10 +174,6 @@ type ChatMessageHeaderProps = {
*/
export function ChatMessageHeader(props: ChatMessageHeaderProps): JSX.Element {
const [datetime, setDatetime] = useState<Record<number, string>>({});
const sharedStyles: SxProps<Theme> = {
height: '24px',
width: '24px'
};
const message = props.message;
const sender = message.sender;
/**
Expand Down Expand Up @@ -206,32 +213,7 @@ export function ChatMessageHeader(props: ChatMessageHeaderProps): JSX.Element {
}
});

const bgcolor = sender.color;
const avatar = message.stacked ? null : sender.avatar_url ? (
<Avatar
sx={{
...sharedStyles,
...(bgcolor && { bgcolor })
}}
src={sender.avatar_url}
></Avatar>
) : sender.initials ? (
<Avatar
sx={{
...sharedStyles,
...(bgcolor && { bgcolor })
}}
>
<Typography
sx={{
fontSize: 'var(--jp-ui-font-size1)',
color: 'var(--jp-ui-inverse-font-color1)'
}}
>
{sender.initials}
</Typography>
</Avatar>
) : null;
const avatar = message.stacked ? null : Avatar({ user: sender });

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

/**
* The writers component props.
*/
type writersProps = {
/**
* The list of users currently writing.
*/
writers: IUser[];
};

/**
* The writers component, displaying the current writers.
*/
export function Writers(props: writersProps): JSX.Element | null {
const { writers } = props;
return writers.length > 0 ? (
<Box className={WRITERS_CLASS}>
{writers.map((writer, index) => (
<div>
<Avatar user={writer} small />
<span>
{writer.display_name ??
writer.name ??
(writer.username || 'User undefined')}
</span>
<span>
{index < writers.length - 1
? index < writers.length - 2
? ', '
: ' and '
: ''}
</span>
</div>
))}
<span>{(writers.length > 1 ? ' are' : ' is') + ' writing'}</span>
</Box>
) : null;
}

/**
* The navigation component props.
*/
Expand Down Expand Up @@ -544,3 +565,55 @@ export function Navigation(props: NavigationProps): JSX.Element {
</>
);
}

/**
* The avatar props.
*/
type AvatarProps = {
/**
* The user to display an avatar.
*/
user: IUser;
/**
* Whether the avatar should be small.
*/
small?: boolean;
};

/**
* the avatar component.
*/
export function Avatar(props: AvatarProps): JSX.Element | null {
const { user } = props;

const sharedStyles: SxProps<Theme> = {
height: `${props.small ? '16' : '24'}px`,
width: `${props.small ? '16' : '24'}px`,
bgcolor: user.color,
fontSize: `var(--jp-ui-font-size${props.small ? '0' : '1'})`
};

return user.avatar_url ? (
<MuiAvatar
sx={{
...sharedStyles
}}
src={user.avatar_url}
></MuiAvatar>
) : user.initials ? (
<MuiAvatar
sx={{
...sharedStyles
}}
>
<Typography
sx={{
fontSize: `var(--jp-ui-font-size${props.small ? '0' : '1'})`,
color: 'var(--jp-ui-inverse-font-color1)'
}}
>
{user.initials}
</Typography>
</MuiAvatar>
) : null;
}
42 changes: 41 additions & 1 deletion packages/jupyter-chat/src/model.ts
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,11 @@ export interface IChatModel extends IDisposable {
*/
readonly viewportChanged?: ISignal<IChatModel, number[]>;

/**
* A signal emitting when the writers change.
*/
readonly writersChanged?: ISignal<IChatModel, IUser[]>;

/**
* A signal emitting when the focus is requested on the input.
*/
Expand Down Expand Up @@ -151,10 +156,20 @@ export interface IChatModel extends IDisposable {
*/
messagesDeleted(index: number, count: number): void;

/**
* Update the current writers list.
*/
updateWriters(writers: IUser[]): void;

/**
* Function to request the focus on the input of the chat.
*/
focusInput(): void;

/**
* Function called by the input on key pressed.
*/
inputChanged?(input?: string): void;
}

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

// Stack consecutive messages from the same user by default.
this._config = { stackMessages: true, ...config };
this._config = {
stackMessages: true,
sendTypingNotification: true,
...config
};

this._commands = options.commands;

Expand Down Expand Up @@ -356,6 +375,13 @@ export class ChatModel implements IChatModel {
return this._viewportChanged;
}

/**
* A signal emitting when the writers change.
*/
get writersChanged(): ISignal<IChatModel, IUser[]> {
return this._writersChanged;
}

/**
* A signal emitting when the focus is requested on the input.
*/
Expand Down Expand Up @@ -470,13 +496,26 @@ export class ChatModel implements IChatModel {
this._messagesUpdated.emit();
}

/**
* Update the current writers list.
* This implementation only propagate the list via a signal.
*/
updateWriters(writers: IUser[]): void {
this._writersChanged.emit(writers);
}

/**
* Function to request the focus on the input of the chat.
*/
focusInput(): void {
this._focusInputSignal.emit();
}

/**
* Function called by the input on key pressed.
*/
inputChanged?(input?: string): void {}

/**
* Add unread messages to the list.
* @param indexes - list of new indexes.
Expand Down Expand Up @@ -541,6 +580,7 @@ export class ChatModel implements IChatModel {
private _configChanged = new Signal<IChatModel, IConfig>(this);
private _unreadChanged = new Signal<IChatModel, number[]>(this);
private _viewportChanged = new Signal<IChatModel, number[]>(this);
private _writersChanged = new Signal<IChatModel, IUser[]>(this);
private _focusInputSignal = new Signal<ChatModel, void>(this);
}

Expand Down
4 changes: 4 additions & 0 deletions packages/jupyter-chat/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,10 @@ export interface IConfig {
* Whether to enable or not the code toolbar.
*/
enableCodeToolbar?: boolean;
/**
* Whether to send typing notification.
*/
sendTypingNotification?: boolean;
}

/**
Expand Down
13 changes: 13 additions & 0 deletions packages/jupyter-chat/style/chat.css
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,19 @@
margin-top: 0px;
}

.jp-chat-writers {
display: flex;
flex-wrap: wrap;
}

.jp-chat-writers > div {
display: flex;
align-items: center;
gap: 0.2em;
white-space: pre;
padding-left: 0.5em;
}

.jp-chat-navigation {
position: absolute;
right: 10px;
Expand Down
Loading

0 comments on commit 4b0a1eb

Please sign in to comment.