diff --git a/packages/jupyter-chat/src/components/chat.tsx b/packages/jupyter-chat/src/components/chat.tsx index 019c0acd..49bd90df 100644 --- a/packages/jupyter-chat/src/components/chat.tsx +++ b/packages/jupyter-chat/src/components/chat.tsx @@ -17,7 +17,7 @@ import { InputToolbarRegistry } from './input'; import { JlThemeProvider } from './jl-theme-provider'; -import { ChatMessages } from './messages'; +import { ChatMessages, WriterComponent } from './messages'; import { AttachmentOpenerContext } from '../context'; import { IChatModel } from '../model'; import { @@ -42,6 +42,7 @@ export function ChatBody(props: Chat.IChatBodyProps): JSX.Element { inputToolbarRegistry={inputToolbarRegistry} messageFooterRegistry={props.messageFooterRegistry} welcomeMessage={props.welcomeMessage} + writerComponent={props.writerComponent} /> (model.messages); const refMsgBox = useRef(null); - const [currentWriters, setCurrentWriters] = useState([]); + const [currentWriters, setCurrentWriters] = useState( + [] + ); const [allRendered, setAllRendered] = useState(false); // The list of message DOM and their rendered promises. @@ -96,7 +102,7 @@ export function ChatMessages(props: BaseMessageProps): JSX.Element { } function handleWritersChange(_: IChatModel, writers: IChatModel.IWriter[]) { - setCurrentWriters(writers.map(writer => writer.user)); + setCurrentWriters([...writers]); } model.messagesUpdated.connect(handleChatEvents); @@ -211,7 +217,10 @@ export function ChatMessages(props: BaseMessageProps): JSX.Element { })} - + ); diff --git a/packages/jupyter-chat/src/components/messages/writers.tsx b/packages/jupyter-chat/src/components/messages/writers.tsx index b5d5cd92..c71e43e8 100644 --- a/packages/jupyter-chat/src/components/messages/writers.tsx +++ b/packages/jupyter-chat/src/components/messages/writers.tsx @@ -7,9 +7,14 @@ import { Box, Typography } from '@mui/material'; import React, { useMemo } from 'react'; import { Avatar } from '../avatar'; +import { IChatModel } from '../../model'; +import { ISignal, Signal } from '@lumino/signaling'; import { IUser } from '../../types'; const WRITERS_CLASS = 'jp-chat-writers'; +const WRITERS_ROW_CLASS = 'jp-chat-writers-row'; + +const DEFAULT_TEXT = 'is writing'; /** * The writers component props. @@ -18,7 +23,11 @@ type writersProps = { /** * The list of users currently writing. */ - writers: IUser[]; + writers: IChatModel.IWriter[]; + /** + * The component to render next to the writers. + */ + writerComponent?: WriterComponent; }; /** @@ -36,6 +45,45 @@ const TypingIndicator = (): JSX.Element => ( * The writers component, displaying the current writers. */ export function WritingUsersList(props: writersProps): JSX.Element | null { + const { writers, writerComponent } = props; + + // Don't render if no writers + if (writers.length === 0) { + return null; + } + + // Default rendering for users without custom typing indicator and if there is no + // component to add to the writing notification. + const defaultWriters = writerComponent?.component + ? [] + : writers.filter(writer => !writer.typingIndicator); + const defaultWritersComponent = defaultWritingUsers({ + writers: defaultWriters.map(writer => writer.user) + }); + + // Custom rendering for users with custom typing indicator or if there is a component + // to add to the writing notification. + const customWriters = writerComponent?.component + ? writers + : writers.filter(writer => writer.typingIndicator); + const customWritersComponent = customWritingUser({ + writers: customWriters, + writerComponent: writerComponent?.component + }); + + return ( + + {defaultWritersComponent !== null && defaultWritersComponent} + {customWritersComponent !== null && customWritersComponent} + + ); +} + +/** + * The default rendering of writing users, all in a row. + * This renderer is used if there is no custom component and no custom typing indicator. + */ +function defaultWritingUsers(props: { writers: IUser[] }): JSX.Element | null { const { writers } = props; // Don't render if no writers @@ -43,7 +91,7 @@ export function WritingUsersList(props: writersProps): JSX.Element | null { return null; } - const writersText = writers.length > 1 ? ' are writing' : ' is writing'; + const writersText = writers.length > 1 ? 'are writing' : DEFAULT_TEXT; const writingUsers: JSX.Element[] = useMemo( () => @@ -66,12 +114,12 @@ export function WritingUsersList(props: writersProps): JSX.Element | null { ); return ( - + {writingUsers} - {writersText} + {` ${writersText}`} @@ -79,3 +127,91 @@ export function WritingUsersList(props: writersProps): JSX.Element | null { ); } + +/** + * The custom rendering of writing users, one per row. + * This renderer is used if there is a custom component or a custom typing indicator. + */ +function customWritingUser(props: { + writers: IChatModel.IWriter[]; + writerComponent?: React.FC; +}): JSX.Element | null { + const { writers } = props; + + // Don't render if no writers + if (writers.length === 0) { + return null; + } + + const writingUsers: JSX.Element[] = writers.map(writer => { + const username = + writer.user.display_name ?? + writer.user.name ?? + (writer.user.username || 'User undefined'); + + const writerText = writer.typingIndicator ?? DEFAULT_TEXT; + return ( + + + + {username} + + + + {` ${writerText}`} + + + + {props.writerComponent && } + + ); + }); + + return ( + <> + {writingUsers.map(writingUser => ( + {writingUser} + ))} + + ); +} + +export type WriterComponentProps = { + /** + * The writer associated to this component. + */ + writer: IChatModel.IWriter; +}; + +/** + * The writer component class containing a react component to display with the user + * writing notification. + */ +export class WriterComponent { + /** + * The react component. + */ + get component(): React.FC | undefined { + return this._component; + } + set component(value: React.FC | undefined) { + this._component = value; + this._changed.emit(this._component); + } + + /** + * Emitting when the component changed. + */ + get changed(): ISignal< + WriterComponent, + React.FC | undefined + > { + return this._changed; + } + + private _component: React.FC | undefined; + private _changed = new Signal< + WriterComponent, + React.FC | undefined + >(this); +} diff --git a/packages/jupyter-chat/src/model.ts b/packages/jupyter-chat/src/model.ts index 90a50399..70cb220a 100644 --- a/packages/jupyter-chat/src/model.ts +++ b/packages/jupyter-chat/src/model.ts @@ -791,9 +791,13 @@ export namespace IChatModel { */ user: IUser; /** - * The message ID (optional) + * The message ID (optional). */ messageID?: string; + /** + * The writer typing indicator (optional) + */ + typingIndicator?: string; } } diff --git a/packages/jupyter-chat/src/widgets/multichat-panel.tsx b/packages/jupyter-chat/src/widgets/multichat-panel.tsx index 10047153..d3e14cc1 100644 --- a/packages/jupyter-chat/src/widgets/multichat-panel.tsx +++ b/packages/jupyter-chat/src/widgets/multichat-panel.tsx @@ -30,7 +30,8 @@ import { ChatWidget } from './chat-widget'; import { Chat, IInputToolbarRegistry, - IInputToolbarRegistryFactory + IInputToolbarRegistryFactory, + WriterComponent } from '../components'; import { chatIcon, readIcon } from '../icons'; import { IChatModel } from '../model'; @@ -65,6 +66,7 @@ export class MultiChatPanel extends SidePanel { this._inputToolbarFactory = options.inputToolbarFactory; this._messageFooterRegistry = options.messageFooterRegistry; this._welcomeMessage = options.welcomeMessage; + this._writerComponent = options.writerComponent; this._getChatNames = options.getChatNames; this._createModel = options.createModel; @@ -155,7 +157,8 @@ export class MultiChatPanel extends SidePanel { attachmentOpenerRegistry: this._attachmentOpenerRegistry, inputToolbarRegistry, messageFooterRegistry: this._messageFooterRegistry, - welcomeMessage: this._welcomeMessage + welcomeMessage: this._welcomeMessage, + writerComponent: this._writerComponent }); const section = new ChatSection({ @@ -273,6 +276,7 @@ export class MultiChatPanel extends SidePanel { private _inputToolbarFactory?: IInputToolbarRegistryFactory; private _messageFooterRegistry?: IMessageFooterRegistry; private _welcomeMessage?: string; + private _writerComponent?: WriterComponent; private _updateChatListDebouncer: Debouncer; private _createModel?: ( diff --git a/packages/jupyter-chat/style/chat.css b/packages/jupyter-chat/style/chat.css index 5e6d262a..e26aafd7 100644 --- a/packages/jupyter-chat/style/chat.css +++ b/packages/jupyter-chat/style/chat.css @@ -74,7 +74,12 @@ margin-top: 0; } -.jp-chat-writers { +.jp-chat-writer { + display: flex; + flex-direction: column; +} + +.jp-chat-writers-row { display: flex; flex-wrap: wrap; position: sticky; @@ -160,7 +165,7 @@ } } -.jp-chat-writers > div { +.jp-chat-writers-row > div { display: flex; align-items: center; gap: 0.2em; diff --git a/packages/jupyterlab-chat-extension/src/index.ts b/packages/jupyterlab-chat-extension/src/index.ts index d86f831c..7fb2abac 100644 --- a/packages/jupyterlab-chat-extension/src/index.ts +++ b/packages/jupyterlab-chat-extension/src/index.ts @@ -17,6 +17,7 @@ import { InputToolbarRegistry, MessageFooterRegistry, SelectionWatcher, + WriterComponent, chatIcon, readIcon, IInputToolbarRegistryFactory, @@ -58,6 +59,7 @@ import { IChatPanel, ISelectionWatcherToken, IWelcomeMessage, + IWriterComponent, LabChatModelFactory, LabChatPanel, WidgetConfig, @@ -77,8 +79,10 @@ const pluginIds = { chatCommands: 'jupyterlab-chat-extension:commands', chatPanel: 'jupyterlab-chat-extension:chat-panel', docFactories: 'jupyterlab-chat-extension:factory', + footerRegistry: 'jupyterlab-chat/footerRegistry', inputToolbarFactory: 'jupyterlab-chat-extension:inputToolbarFactory', - selectionWatcher: 'jupyterlab-chat-extension:selectionWatcher' + selectionWatcher: 'jupyterlab-chat-extension:selectionWatcher', + writerComponentRegistry: 'jupyterlab-chat-extension:writerComponent' }; /** @@ -178,7 +182,8 @@ const docFactories: JupyterFrontEndPlugin = { IThemeManager, IToolbarWidgetRegistry, ITranslator, - IWelcomeMessage + IWelcomeMessage, + IWriterComponent ], provides: IChatFactory, activate: ( @@ -197,7 +202,8 @@ const docFactories: JupyterFrontEndPlugin = { themeManager: IThemeManager | null, toolbarRegistry: IToolbarWidgetRegistry | null, translator_: ITranslator | null, - welcomeMessage: string + welcomeMessage: string, + writerComponent: WriterComponent ): IChatFactory => { const translator = translator_ ?? nullTranslator; @@ -362,7 +368,8 @@ const docFactories: JupyterFrontEndPlugin = { attachmentOpenerRegistry, inputToolbarFactory, messageFooterRegistry, - welcomeMessage + welcomeMessage, + writerComponent }); // Add the widget to the tracker when it's created @@ -788,7 +795,8 @@ const chatPanel: JupyterFrontEndPlugin = { ILayoutRestorer, IMessageFooterRegistry, IThemeManager, - IWelcomeMessage + IWelcomeMessage, + IWriterComponent ], activate: ( app: JupyterFrontEnd, @@ -801,7 +809,8 @@ const chatPanel: JupyterFrontEndPlugin = { restorer: ILayoutRestorer | null, messageFooterRegistry: IMessageFooterRegistry, themeManager: IThemeManager | null, - welcomeMessage: string + welcomeMessage: string, + writerComponent: WriterComponent ): MultiChatPanel => { const { commands, serviceManager } = app; @@ -847,7 +856,8 @@ const chatPanel: JupyterFrontEndPlugin = { attachmentOpenerRegistry, inputToolbarFactory, messageFooterRegistry, - welcomeMessage + welcomeMessage, + writerComponent }); chatPanel.id = 'JupyterlabChat:sidepanel'; @@ -1002,7 +1012,7 @@ const inputToolbarFactory: JupyterFrontEndPlugin = * Extension providing the message footer registry. */ const footerRegistry: JupyterFrontEndPlugin = { - id: 'jupyterlab-chat/footerRegistry', + id: pluginIds.footerRegistry, description: 'The footer registry plugin.', autoStart: true, provides: IMessageFooterRegistry, @@ -1011,6 +1021,19 @@ const footerRegistry: JupyterFrontEndPlugin = { } }; +/** + * Plugin providing a writer component. + */ +const writerComponent: JupyterFrontEndPlugin = { + id: pluginIds.writerComponentRegistry, + description: 'The writer component registry plugin.', + autoStart: true, + provides: IWriterComponent, + activate: (): WriterComponent => { + return new WriterComponent(); + } +}; + export default [ activeCellManager, attachmentOpeners, @@ -1022,5 +1045,6 @@ export default [ inputToolbarFactory, selectionWatcher, emojiCommandsPlugin, - mentionCommandsPlugin + mentionCommandsPlugin, + writerComponent ]; diff --git a/packages/jupyterlab-chat/src/factory.ts b/packages/jupyterlab-chat/src/factory.ts index 2badb370..c43e027a 100644 --- a/packages/jupyterlab-chat/src/factory.ts +++ b/packages/jupyterlab-chat/src/factory.ts @@ -11,7 +11,8 @@ import { IInputToolbarRegistry, IMessageFooterRegistry, ISelectionWatcher, - IInputToolbarRegistryFactory + IInputToolbarRegistryFactory, + WriterComponent } from '@jupyter/chat'; import { IThemeManager } from '@jupyterlab/apputils'; import { IDocumentManager } from '@jupyterlab/docmanager'; @@ -84,6 +85,7 @@ export class ChatWidgetFactory extends ABCWidgetFactory< this._inputToolbarFactory = options.inputToolbarFactory; this._messageFooterRegistry = options.messageFooterRegistry; this._welcomeMessage = options.welcomeMessage; + this._writerComponent = options.writerComponent; } /** @@ -99,6 +101,7 @@ export class ChatWidgetFactory extends ABCWidgetFactory< context.attachmentOpenerRegistry = this._attachmentOpenerRegistry; context.messageFooterRegistry = this._messageFooterRegistry; context.welcomeMessage = this._welcomeMessage; + context.writerComponent = this._writerComponent; if (this._inputToolbarFactory) { context.inputToolbarRegistry = this._inputToolbarFactory.create(); } @@ -125,6 +128,7 @@ export class ChatWidgetFactory extends ABCWidgetFactory< private _inputToolbarFactory?: IInputToolbarRegistryFactory; private _messageFooterRegistry?: IMessageFooterRegistry; private _welcomeMessage?: string; + private _writerComponent?: WriterComponent; } export namespace ChatWidgetFactory { @@ -137,6 +141,7 @@ export namespace ChatWidgetFactory { inputToolbarRegistry?: IInputToolbarRegistry; messageFooterRegistry?: IMessageFooterRegistry; welcomeMessage?: string; + writerComponent?: WriterComponent; } export interface IOptions @@ -148,6 +153,7 @@ export namespace ChatWidgetFactory { inputToolbarFactory?: IInputToolbarRegistryFactory; messageFooterRegistry?: IMessageFooterRegistry; welcomeMessage?: string; + writerComponent?: WriterComponent; } } diff --git a/packages/jupyterlab-chat/src/model.ts b/packages/jupyterlab-chat/src/model.ts index 2ab14afb..9de66a44 100644 --- a/packages/jupyterlab-chat/src/model.ts +++ b/packages/jupyterlab-chat/src/model.ts @@ -353,7 +353,8 @@ export class LabChatModel if (state.isWriting !== undefined && state.isWriting !== false) { const writer: IChatModel.IWriter = { user: state.user, - messageID: state.isWriting === true ? undefined : state.isWriting + messageID: state.isWriting === true ? undefined : state.isWriting, + typingIndicator: state.typingIndicator }; writers.push(writer); } diff --git a/packages/jupyterlab-chat/src/token.ts b/packages/jupyterlab-chat/src/token.ts index ee312da3..23b56160 100644 --- a/packages/jupyterlab-chat/src/token.ts +++ b/packages/jupyterlab-chat/src/token.ts @@ -8,7 +8,8 @@ import { chatIcon, IActiveCellManager, ISelectionWatcher, - ChatWidget + ChatWidget, + WriterComponent } from '@jupyter/chat'; import { WidgetTracker } from '@jupyterlab/apputils'; import { DocumentRegistry } from '@jupyterlab/docregistry'; @@ -143,3 +144,10 @@ export const ISelectionWatcherToken = new Token( export const IWelcomeMessage = new Token( 'jupyterlab-chat:IWelcomeMessage' ); + +/** + * The token to add a component to the writing notification. + */ +export const IWriterComponent = new Token( + 'jupyterlab-chat:IWriterComponent' +); diff --git a/ui-tests/tests/typing-notification.spec.ts b/ui-tests/tests/typing-notification.spec.ts new file mode 100644 index 00000000..23a1a2b9 --- /dev/null +++ b/ui-tests/tests/typing-notification.spec.ts @@ -0,0 +1,241 @@ +/* + * Copyright (c) Jupyter Development Team. + * Distributed under the terms of the Modified BSD License. + */ + +import { + IJupyterLabPageFixture, + expect, + galata, + test +} from '@jupyterlab/galata'; +import { User } from '@jupyterlab/services'; + +import { openChat, openSettings, sendMessage, USER } from './test-utils'; + +const FILENAME = 'my-chat.chat'; + +test.use({ + mockUser: USER, + mockSettings: { ...galata.DEFAULT_SETTINGS } +}); + +test.describe('#typingNotification', () => { + let guestPage: IJupyterLabPageFixture; + test.beforeEach( + async ({ baseURL, browser, page, tmpPath, waitForApplication }) => { + // Create a chat file + await page.filebrowser.contents.uploadContent('{}', 'text', FILENAME); + + // Create a new user. + const user2: Partial = { + identity: { + username: 'jovyan_2', + name: 'jovyan_2', + display_name: 'jovyan_2', + initials: 'JP', + color: 'var(--jp-collaborator-color2)' + } + }; + + // Create a new page for guest. + const { page: newPage } = await galata.newPage({ + baseURL: baseURL!, + browser, + mockUser: user2, + tmpPath, + waitForApplication + }); + await newPage.evaluate(() => { + // Acknowledge any dialog + window.galataip.on('dialog', d => { + d?.resolve(); + }); + }); + guestPage = newPage; + } + ); + + test.afterEach(async ({ page }) => { + await guestPage.close(); + if (await page.filebrowser.contents.fileExists(FILENAME)) { + await page.filebrowser.contents.deleteFile(FILENAME); + } + }); + + test('should display typing user', async ({ page }) => { + const chatPanel = await openChat(page, FILENAME); + const writers = chatPanel.locator('.jp-chat-writers'); + + const guestChatPanel = await openChat(guestPage, FILENAME); + const guestInput = guestChatPanel + .locator('.jp-chat-input-container') + .getByRole('combobox'); + + await guestInput.press('a'); + await expect(writers).toBeAttached(); + const start = Date.now(); + await expect(writers).toHaveText(/jovyan_2 is writing/); + await expect(writers).not.toBeAttached(); + + // Message should disappear after 1s, but this delay include the awareness update. + expect(Date.now() - start).toBeLessThanOrEqual(2000); + }); + + test('should display typing user editing a message', async ({ page }) => { + const chatPanel = await openChat(page, FILENAME); + const writers = chatPanel.locator('.jp-chat-writers'); + + const guestChatPanel = await openChat(guestPage, FILENAME); + + await sendMessage(guestPage, FILENAME, 'test'); + await expect(writers).not.toBeAttached(); + const message = guestChatPanel + .locator('.jp-chat-messages-container .jp-chat-message') + .first(); + const messageContent = message.locator('.jp-chat-rendered-markdown'); + + // Should display the message toolbar + await messageContent.hover({ position: { x: 5, y: 5 } }); + await messageContent.locator('.jp-chat-toolbar jp-button').first().click(); + + const editInput = guestChatPanel + .locator('.jp-chat-messages-container .jp-chat-input-container') + .getByRole('combobox'); + + await editInput.focus(); + + await editInput.press('a'); + await expect(writers).toBeAttached(); + const start = Date.now(); + await expect(writers).toHaveText(/jovyan_2 is writing/); + await expect(writers).not.toBeAttached(); + + // Message should disappear after 1s, but this delay include the awareness update. + expect(Date.now() - start).toBeLessThanOrEqual(2000); + }); + + test('should not display typing users if disabled', async ({ page }) => { + const chatPanel = await openChat(page, FILENAME); + const writers = chatPanel.locator('.jp-chat-writers'); + + // Modify the guest settings + const settings = await openSettings(guestPage); + const sendTypingNotification = settings?.getByRole('checkbox', { + name: 'sendTypingNotification' + }); + await sendTypingNotification?.uncheck(); + // wait for the settings to be saved + await expect(guestPage.activity.getTabLocator('Settings')).toHaveAttribute( + 'class', + /jp-mod-dirty/ + ); + await expect( + guestPage.activity.getTabLocator('Settings') + ).not.toHaveAttribute('class', /jp-mod-dirty/); + + const guestChatPanel = await openChat(guestPage, FILENAME); + const guestInput = guestChatPanel + .locator('.jp-chat-input-container') + .getByRole('combobox'); + + await guestInput.press('a'); + + let visible = true; + try { + await page.waitForCondition(() => writers.isVisible(), 3000); + } catch { + visible = false; + } + + if (visible) { + throw Error('The typing notification should not be attached.'); + } + }); + + test('should display several typing users', async ({ + baseURL, + browser, + page, + tmpPath, + waitForApplication + }) => { + // Create a new user. + const user3: Partial = { + identity: { + username: 'jovyan_3', + name: 'jovyan_3', + display_name: 'jovyan_3', + initials: 'JP', + color: 'var(--jp-collaborator-color3)' + } + }; + + // Create a new page for guest. + const { page: newPage } = await galata.newPage({ + baseURL: baseURL!, + browser, + mockUser: user3, + tmpPath, + waitForApplication + }); + await newPage.evaluate(() => { + // Acknowledge any dialog + window.galataip.on('dialog', d => { + d?.resolve(); + }); + }); + const guestPage2 = newPage; + + const chatPanel = await openChat(page, FILENAME); + const writers = chatPanel.locator('.jp-chat-writers'); + + const guestChatPanel = await openChat(guestPage, FILENAME); + const guestInput = guestChatPanel + .locator('.jp-chat-input-container') + .getByRole('combobox'); + + const guest2ChatPanel = await openChat(guestPage2, FILENAME); + const guest2Input = guest2ChatPanel + .locator('.jp-chat-input-container') + .getByRole('combobox'); + + await guestInput.press('a'); + await guest2Input.press('a'); + + await expect(writers).toBeAttached(); + const regexp = /JP(jovyan_[2|3]) and JP(jovyan_[2|3]) are writing/; + await expect(writers).toHaveText(regexp); + + const result = regexp.exec((await writers.textContent()) ?? ''); + expect(result?.[1] !== undefined); + expect(result?.[1] !== result?.[2]); + await expect(writers).not.toBeAttached(); + }); + + test('should display custom typing notification', async ({ page }) => { + const NOTIFICATION = 'is doing something awesome'; + const chatPanel = await openChat(page, FILENAME); + const writers = chatPanel.locator('.jp-chat-writers'); + + const guestChatPanel = await openChat(guestPage, FILENAME); + + // Update the typing notification for jovyan_2 + await guestPage.evaluate(notification => { + const chatWidget = window.jupyterapp.shell.currentWidget as any; + const clientID = chatWidget.model.sharedModel.awareness.clientID; + chatWidget.model.sharedModel.awareness.states.get( + clientID + ).typingIndicator = notification; + }, NOTIFICATION); + + const guestInput = guestChatPanel + .locator('.jp-chat-input-container') + .getByRole('combobox'); + await guestInput.press('a'); + + await expect(writers).toBeAttached(); + const regexp = new RegExp(`jovyan_2 ${NOTIFICATION}`); + await expect(writers).toHaveText(regexp); + }); +}); diff --git a/ui-tests/tests/ui-config.spec.ts b/ui-tests/tests/ui-config.spec.ts index 979c1ea6..ea44f5b9 100644 --- a/ui-tests/tests/ui-config.spec.ts +++ b/ui-tests/tests/ui-config.spec.ts @@ -3,16 +3,10 @@ * Distributed under the terms of the Modified BSD License. */ -import { - IJupyterLabPageFixture, - expect, - galata, - test -} from '@jupyterlab/galata'; -import { User } from '@jupyterlab/services'; +import { expect, galata, test } from '@jupyterlab/galata'; import { UUID } from '@lumino/coreutils'; -import { openChat, openSettings, sendMessage, USER } from './test-utils'; +import { openChat, openSettings, USER } from './test-utils'; const FILENAME = 'my-chat.chat'; const MSG_CONTENT = 'Hello World!'; @@ -154,197 +148,3 @@ test.describe('#stackedMessages', () => { ); }); }); - -test.describe('#typingNotification', () => { - let guestPage: IJupyterLabPageFixture; - test.beforeEach( - async ({ baseURL, browser, page, tmpPath, waitForApplication }) => { - // Create a chat file - await page.filebrowser.contents.uploadContent('{}', 'text', FILENAME); - - // Create a new user. - const user2: Partial = { - identity: { - username: 'jovyan_2', - name: 'jovyan_2', - display_name: 'jovyan_2', - initials: 'JP', - color: 'var(--jp-collaborator-color2)' - } - }; - - // Create a new page for guest. - const { page: newPage } = await galata.newPage({ - baseURL: baseURL!, - browser, - mockUser: user2, - tmpPath, - waitForApplication - }); - await newPage.evaluate(() => { - // Acknowledge any dialog - window.galataip.on('dialog', d => { - d?.resolve(); - }); - }); - guestPage = newPage; - } - ); - - test.afterEach(async ({ page }) => { - await guestPage.close(); - if (await page.filebrowser.contents.fileExists(FILENAME)) { - await page.filebrowser.contents.deleteFile(FILENAME); - } - }); - - test('should display typing user', async ({ page }) => { - const chatPanel = await openChat(page, FILENAME); - const writers = chatPanel.locator('.jp-chat-writers'); - - const guestChatPanel = await openChat(guestPage, FILENAME); - const guestInput = guestChatPanel - .locator('.jp-chat-input-container') - .getByRole('combobox'); - - await guestInput.press('a'); - await expect(writers).toBeAttached(); - const start = Date.now(); - await expect(writers).toHaveText(/jovyan_2 is writing/); - await expect(writers).not.toBeAttached(); - - // Message should disappear after 1s, but this delay include the awareness update. - expect(Date.now() - start).toBeLessThanOrEqual(2000); - }); - - test('should display typing user editing a message', async ({ page }) => { - const chatPanel = await openChat(page, FILENAME); - const writers = chatPanel.locator('.jp-chat-writers'); - - const guestChatPanel = await openChat(guestPage, FILENAME); - - await sendMessage(guestPage, FILENAME, 'test'); - await expect(writers).not.toBeAttached(); - const message = guestChatPanel - .locator('.jp-chat-messages-container .jp-chat-message') - .first(); - const messageContent = message.locator('.jp-chat-rendered-markdown'); - - // Should display the message toolbar - await messageContent.hover({ position: { x: 5, y: 5 } }); - await messageContent.locator('.jp-chat-toolbar jp-button').first().click(); - - const editInput = guestChatPanel - .locator('.jp-chat-messages-container .jp-chat-input-container') - .getByRole('combobox'); - - await editInput.focus(); - - await editInput.press('a'); - await expect(writers).toBeAttached(); - const start = Date.now(); - await expect(writers).toHaveText(/jovyan_2 is writing/); - await expect(writers).not.toBeAttached(); - - // Message should disappear after 1s, but this delay include the awareness update. - expect(Date.now() - start).toBeLessThanOrEqual(2000); - }); - - test('should not display typing users if disabled', async ({ page }) => { - const chatPanel = await openChat(page, FILENAME); - const writers = chatPanel.locator('.jp-chat-writers'); - - // Modify the guest settings - const settings = await openSettings(guestPage); - const sendTypingNotification = settings?.getByRole('checkbox', { - name: 'sendTypingNotification' - }); - await sendTypingNotification?.uncheck(); - // wait for the settings to be saved - await expect(guestPage.activity.getTabLocator('Settings')).toHaveAttribute( - 'class', - /jp-mod-dirty/ - ); - await expect( - guestPage.activity.getTabLocator('Settings') - ).not.toHaveAttribute('class', /jp-mod-dirty/); - - const guestChatPanel = await openChat(guestPage, FILENAME); - const guestInput = guestChatPanel - .locator('.jp-chat-input-container') - .getByRole('combobox'); - - await guestInput.press('a'); - - let visible = true; - try { - await page.waitForCondition(() => writers.isVisible(), 3000); - } catch { - visible = false; - } - - if (visible) { - throw Error('The typing notification should not be attached.'); - } - }); - - test('should display several typing users', async ({ - baseURL, - browser, - page, - tmpPath, - waitForApplication - }) => { - // Create a new user. - const user3: Partial = { - identity: { - username: 'jovyan_3', - name: 'jovyan_3', - display_name: 'jovyan_3', - initials: 'JP', - color: 'var(--jp-collaborator-color3)' - } - }; - - // Create a new page for guest. - const { page: newPage } = await galata.newPage({ - baseURL: baseURL!, - browser, - mockUser: user3, - tmpPath, - waitForApplication - }); - await newPage.evaluate(() => { - // Acknowledge any dialog - window.galataip.on('dialog', d => { - d?.resolve(); - }); - }); - const guestPage2 = newPage; - - const chatPanel = await openChat(page, FILENAME); - const writers = chatPanel.locator('.jp-chat-writers'); - - const guestChatPanel = await openChat(guestPage, FILENAME); - const guestInput = guestChatPanel - .locator('.jp-chat-input-container') - .getByRole('combobox'); - - const guest2ChatPanel = await openChat(guestPage2, FILENAME); - const guest2Input = guest2ChatPanel - .locator('.jp-chat-input-container') - .getByRole('combobox'); - - await guestInput.press('a'); - await guest2Input.press('a'); - - await expect(writers).toBeAttached(); - const regexp = /JP(jovyan_[2|3]) and JP(jovyan_[2|3]) are writing/; - await expect(writers).toHaveText(regexp); - - const result = regexp.exec((await writers.textContent()) ?? ''); - expect(result?.[1] !== undefined); - expect(result?.[1] !== result?.[2]); - await expect(writers).not.toBeAttached(); - }); -});