Skip to content

Commit

Permalink
Edition and deletion of messages (#22)
Browse files Browse the repository at this point in the history
* Allow edition and deletion in jupyter-chat package

* Handle edition and deletion of message in collaborative chat extension

* Automatic application of license header

* lint

* Button to cancel message edition

* Add ui-tests and fix flaky settings test

* Add deleted and edited message information in chat component

* Use edited and deleted flag in collaborative chat

* Handle the deleted messages from file (out of band changes)

* Uses global variables in tests

* Add tests on out of band changes

---------

Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
  • Loading branch information
brichet and github-actions[bot] authored May 3, 2024
1 parent c8dcd96 commit 80b8c6d
Show file tree
Hide file tree
Showing 15 changed files with 648 additions and 109 deletions.
2 changes: 1 addition & 1 deletion packages/jupyter-chat/src/__tests__/model.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,7 @@ describe('test chat model', () => {
id: 'message1',
time: Date.now() / 1000,
body: 'message test',
sender: { id: 'user' }
sender: { username: 'user' }
} as IChatMessage;

beforeEach(() => {
Expand Down
18 changes: 16 additions & 2 deletions packages/jupyter-chat/src/components/chat-input.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,11 +13,12 @@ import {
IconButton,
InputAdornment
} from '@mui/material';
import SendIcon from '@mui/icons-material/Send';
import { Send, Cancel } from '@mui/icons-material';
import clsx from 'clsx';

const INPUT_BOX_CLASS = 'jp-chat-input-container';
const SEND_BUTTON_CLASS = 'jp-chat-send-button';
const CANCEL_BUTTON_CLASS = 'jp-chat-cancel-button';

export function ChatInput(props: ChatInput.IProps): JSX.Element {
function handleKeyDown(event: React.KeyboardEvent<HTMLInputElement>) {
Expand Down Expand Up @@ -57,6 +58,18 @@ export function ChatInput(props: ChatInput.IProps): JSX.Element {
InputProps={{
endAdornment: (
<InputAdornment position="end">
{props.onCancel && (
<IconButton
size="small"
color="primary"
onClick={props.onCancel}
disabled={!props.value.trim().length}
title={'Cancel edition'}
className={clsx(CANCEL_BUTTON_CLASS)}
>
<Cancel />
</IconButton>
)}
<IconButton
size="small"
color="primary"
Expand All @@ -65,7 +78,7 @@ export function ChatInput(props: ChatInput.IProps): JSX.Element {
title={`Send message ${props.sendWithShiftEnter ? '(SHIFT+ENTER)' : '(ENTER)'}`}
className={clsx(SEND_BUTTON_CLASS)}
>
<SendIcon />
<Send />
</IconButton>
</InputAdornment>
)
Expand All @@ -92,6 +105,7 @@ export namespace ChatInput {
onChange: (newValue: string) => unknown;
onSend: () => unknown;
sendWithShiftEnter: boolean;
onCancel?: () => unknown;
sx?: SxProps<Theme>;
}
}
127 changes: 116 additions & 11 deletions packages/jupyter-chat/src/components/chat-messages.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,22 +9,34 @@ import type { SxProps, Theme } from '@mui/material';
import clsx from 'clsx';
import React, { useState, useEffect } from 'react';

import { ChatInput } from './chat-input';
import { RendermimeMarkdown } from './rendermime-markdown';
import { IChatModel } from '../model';
import { IChatMessage, IUser } from '../types';

const MESSAGES_BOX_CLASS = 'jp-chat-messages-container';
const MESSAGE_CLASS = 'jp-chat-message';
const MESSAGE_HEADER_CLASS = 'jp-chat-message-header';
const MESSAGE_TIME_CLASS = 'jp-chat-message-time';

type ChatMessagesProps = {
type BaseMessageProps = {
rmRegistry: IRenderMimeRegistry;
model: IChatModel;
};

type ChatMessageProps = BaseMessageProps & {
message: IChatMessage;
};

type ChatMessagesProps = BaseMessageProps & {
messages: IChatMessage[];
};

export type ChatMessageHeaderProps = IUser & {
timestamp: string;
rawTime?: boolean;
deleted?: boolean;
edited?: boolean;
sx?: SxProps<Theme>;
};

Expand Down Expand Up @@ -86,9 +98,24 @@ export function ChatMessageHeader(props: ChatMessageHeaderProps): JSX.Element {
alignItems: 'center'
}}
>
<Typography sx={{ fontWeight: 700, color: 'var(--jp-ui-font-color1)' }}>
{name}
</Typography>
<Box sx={{ display: 'flex', alignItems: 'center' }}>
<Typography
sx={{ fontWeight: 700, color: 'var(--jp-ui-font-color1)' }}
>
{name}
</Typography>
{(props.deleted || props.edited) && (
<Typography
sx={{
fontStyle: 'italic',
fontSize: 'var(--jp-content-font-size0)',
paddingLeft: '0.5em'
}}
>
{props.deleted ? '(message deleted)' : '(edited)'}
</Typography>
)}
</Box>
<Typography
className={MESSAGE_TIME_CLASS}
sx={{
Expand All @@ -105,6 +132,9 @@ export function ChatMessageHeader(props: ChatMessageHeaderProps): JSX.Element {
);
}

/**
* The messages list UI.
*/
export function ChatMessages(props: ChatMessagesProps): JSX.Element {
const [timestamps, setTimestamps] = useState<Record<string, string>>({});

Expand Down Expand Up @@ -163,26 +193,101 @@ export function ChatMessages(props: ChatMessagesProps): JSX.Element {
{props.messages.map((message, i) => {
let sender: IUser;
if (typeof message.sender === 'string') {
sender = { id: message.sender };
sender = { username: message.sender };
} else {
sender = message.sender;
}
return (
// extra div needed to ensure each bubble is on a new line
<Box key={i} sx={{ padding: 4 }} className={clsx(MESSAGE_CLASS)}>
<Box
key={i}
sx={{ padding: '1em 1em 0 1em' }}
className={clsx(MESSAGE_CLASS)}
>
<ChatMessageHeader
{...sender}
timestamp={timestamps[message.id]}
rawTime={message.raw_time || false}
rawTime={message.raw_time}
deleted={message.deleted}
edited={message.edited}
sx={{ marginBottom: 3 }}
/>
<RendermimeMarkdown
rmRegistry={props.rmRegistry}
markdownStr={message.body}
/>
<ChatMessage {...props} message={message} />
</Box>
);
})}
</Box>
);
}

/**
* the message UI.
*/
export function ChatMessage(props: ChatMessageProps): JSX.Element {
const { message, model, rmRegistry } = props;
let canEdit = false;
let canDelete = false;
if (model.user !== undefined && !message.deleted) {
const username =
typeof message.sender === 'string'
? message.sender
: message.sender.username;

if (model.user.username === username && model.updateMessage !== undefined) {
canEdit = true;
}
if (model.user.username === username && model.deleteMessage !== undefined) {
canDelete = true;
}
}
const [edit, setEdit] = useState<boolean>(false);
const [input, setInput] = useState(message.body);

const cancelEdition = (): void => {
setInput(message.body);
setEdit(false);
};

const updateMessage = (id: string): void => {
if (!canEdit) {
return;
}
// Update the message
const updatedMessage = { ...message };
updatedMessage.body = input;
model.updateMessage!(id, updatedMessage);
setEdit(false);
};

const deleteMessage = (id: string): void => {
if (!canDelete) {
return;
}
// Delete the message
model.deleteMessage!(id);
};

// Empty if the message has been deleted
return message.deleted ? (
<></>
) : (
<div>
{edit && canEdit ? (
<ChatInput
value={input}
onChange={setInput}
onSend={() => updateMessage(message.id)}
onCancel={() => cancelEdition()}
sendWithShiftEnter={model.config.sendWithShiftEnter ?? false}
/>
) : (
<RendermimeMarkdown
rmRegistry={rmRegistry}
markdownStr={message.body}
edit={canEdit ? () => setEdit(true) : undefined}
delete={canDelete ? () => deleteMessage(message.id) : undefined}
/>
)}
</div>
);
}
20 changes: 14 additions & 6 deletions packages/jupyter-chat/src/components/chat.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
* Distributed under the terms of the Modified BSD License.
*/

import { IThemeManager } from '@jupyterlab/apputils';
import { IRenderMimeRegistry } from '@jupyterlab/rendermime';
import ArrowBackIcon from '@mui/icons-material/ArrowBack';
import SettingsIcon from '@mui/icons-material/Settings';
Expand All @@ -16,7 +17,6 @@ import { ChatInput } from './chat-input';
import { ScrollContainer } from './scroll-container';
import { IChatModel } from '../model';
import { IChatMessage, IMessage } from '../types';
import { IThemeManager } from '@jupyterlab/apputils';

type ChatBodyProps = {
model: IChatModel;
Expand Down Expand Up @@ -55,20 +55,24 @@ function ChatBody({
if (message.type === 'clear') {
setMessages([]);
return;
} else if (message.type === 'msg') {
} else {
setMessages((messageGroups: IChatMessage[]) => {
const existingMessages = [...messageGroups];

const messageIndex = existingMessages.findIndex(
msg => msg.id === message.id
);
if (messageIndex > -1) {
// The message is an update of an existing one.
// Let's remove it (to avoid position conflict if timestamp has changed)
// and add the new one.
// The message is an update of an existing one (or a removal).
// Let's remove it anyway (to avoid position conflict if timestamp has
// changed) and add the new one if it is an update.
existingMessages.splice(messageIndex, 1);
}

if (message.type === 'remove') {
return existingMessages;
}

// Find the first message that should be after this one.
let nextMsgIndex = existingMessages.findIndex(
msg => msg.time > message.time
Expand Down Expand Up @@ -105,7 +109,11 @@ function ChatBody({
return (
<>
<ScrollContainer sx={{ flexGrow: 1 }}>
<ChatMessages messages={messages} rmRegistry={renderMimeRegistry} />
<ChatMessages
messages={messages}
rmRegistry={renderMimeRegistry}
model={model}
/>
</ScrollContainer>
<ChatInput
value={input}
Expand Down
6 changes: 5 additions & 1 deletion packages/jupyter-chat/src/components/rendermime-markdown.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,11 +3,12 @@
* Distributed under the terms of the Modified BSD License.
*/

import { IRenderMimeRegistry } from '@jupyterlab/rendermime';
import React, { useState, useEffect, useRef } from 'react';
import ReactDOM from 'react-dom';

import { CopyButton } from './copy-button';
import { IRenderMimeRegistry } from '@jupyterlab/rendermime';
import { MessageToolbar } from './toolbar';

const MD_MIME_TYPE = 'text/markdown';
const RENDERMIME_MD_CLASS = 'jp-chat-rendermime-markdown';
Expand All @@ -16,6 +17,8 @@ type RendermimeMarkdownProps = {
markdownStr: string;
rmRegistry: IRenderMimeRegistry;
appendContent?: boolean;
edit?: () => void;
delete?: () => void;
};

/**
Expand Down Expand Up @@ -77,6 +80,7 @@ function RendermimeMarkdownBase(props: RendermimeMarkdownProps): JSX.Element {
) : (
<div ref={node => node && node.replaceChildren(renderedContent)} />
))}
<MessageToolbar edit={props.edit} delete={props.delete} />
</div>
);
}
Expand Down
50 changes: 50 additions & 0 deletions packages/jupyter-chat/src/components/toolbar.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
/*
* Copyright (c) Jupyter Development Team.
* Distributed under the terms of the Modified BSD License.
*/

import {
ToolbarButtonComponent,
deleteIcon,
editIcon
} from '@jupyterlab/ui-components';
import React from 'react';

const TOOLBAR_CLASS = 'jp-chat-toolbar';

/**
* The toolbar attached to a message.
*/
export function MessageToolbar(props: MessageToolbar.IProps): JSX.Element {
const buttons: JSX.Element[] = [];

if (props.edit !== undefined) {
const editButton = ToolbarButtonComponent({
icon: editIcon,
onClick: props.edit,
tooltip: 'Edit'
});
buttons.push(editButton);
}
if (props.delete !== undefined) {
const deleteButton = ToolbarButtonComponent({
icon: deleteIcon,
onClick: props.delete,
tooltip: 'Delete'
});
buttons.push(deleteButton);
}

return (
<div className={TOOLBAR_CLASS}>
{buttons.map(toolbarButton => toolbarButton)}
</div>
);
}

export namespace MessageToolbar {
export interface IProps {
edit?: () => void;
delete?: () => void;
}
}
Loading

0 comments on commit 80b8c6d

Please sign in to comment.