diff --git a/src/components/elementRenderer/elementRenderer.cy.tsx b/src/components/elementRenderer/elementRenderer.cy.tsx index cb833437..c45905d3 100644 --- a/src/components/elementRenderer/elementRenderer.cy.tsx +++ b/src/components/elementRenderer/elementRenderer.cy.tsx @@ -1,16 +1,18 @@ import React from 'react' +import { v4 as getUUID } from 'uuid' import { supportedViewports, testUser, } from '../../../cypress/support/variables' -import { YoutubeVideo } from '..' +import { StreamingText, YoutubeVideo } from '..' import Text from '../text/text' import ElementRenderer from './elementRenderer' const supportedElements = { text: Text, video: YoutubeVideo, + streamingText: StreamingText, } const sampleMessage = { @@ -38,11 +40,13 @@ describe('ElementRenderer', () => { cy.viewport(viewport) cy.mount( @@ -50,11 +54,13 @@ describe('ElementRenderer', () => { cy.get('p').should('contain.text', 'Test Text') cy.mount( @@ -71,6 +77,60 @@ describe('ElementRenderer', () => { }) }) + it(`renders the original message and its update messages correctly on ${viewport} screen`, () => { + const mockWsClient = { + send: cy.stub(), + close: cy.stub(), + reconnect: cy.stub(), + } + + cy.viewport(viewport) + cy.mount( + + ) + cy.get('p').should('contain.text', 'This is streaming text.') + }) + it(`renders a message for an unsupported format on ${viewport} screen`, () => { const mockWsClient = { send: cy.stub(), @@ -81,11 +141,13 @@ describe('ElementRenderer', () => { cy.viewport(viewport) cy.mount( diff --git a/src/components/elementRenderer/elementRenderer.tsx b/src/components/elementRenderer/elementRenderer.tsx index 11f12e40..9c987b63 100644 --- a/src/components/elementRenderer/elementRenderer.tsx +++ b/src/components/elementRenderer/elementRenderer.tsx @@ -1,39 +1,36 @@ import Typography from '@mui/material/Typography' import React from 'react' -import type { - ComponentMap, - Sender, - ThreadableMessage, - WebSocketClient, -} from '../types' +import type { ComponentMap, Message, Sender, WebSocketClient } from '../types' interface ElementRendererProps { sender: Sender ws: WebSocketClient - message: ThreadableMessage + messages: Message[] supportedElements: ComponentMap } const ElementRenderer = (props: ElementRendererProps) => { - const MaybeElement = props.supportedElements[props.message.format] + const rootMessage = props.messages[0] + const updateMessages = props.messages.slice(1) + const MaybeElement = props.supportedElements[rootMessage.format] return ( <> {MaybeElement ? ( React.createElement(MaybeElement, { sender: props.sender, ws: props.ws, - messageId: props.message.id, - conversationId: props.message.conversationId, - ...props.message.data, - ...(props.message.threadMessagesData && { - updatedData: props.message.threadMessagesData, + messageId: rootMessage.id, + conversationId: rootMessage.conversationId, + ...rootMessage.data, + ...(updateMessages.length > 0 && { + updatedData: updateMessages.map((message) => message.data), }), }) ) : ( - Unsupported element format: {props.message.format} + Unsupported element format: {rootMessage.format} )} diff --git a/src/components/input/textInput/textInput.stories.tsx b/src/components/input/textInput/textInput.stories.tsx index a83d9d57..96e64af7 100644 --- a/src/components/input/textInput/textInput.stories.tsx +++ b/src/components/input/textInput/textInput.stories.tsx @@ -58,7 +58,8 @@ meta.argTypes = { 'A websocket client with supports the following methods:\n' + 'send: (msg: Message) => void\n' + 'close: () => void\n' + - 'reconnect: () => void', + 'reconnect: () => void\n' + + 'onReceive?: (handler: (message: Message) => void) => void', }, }, }, diff --git a/src/components/messageCanvas/actions/action.tsx b/src/components/messageCanvas/actions/action.tsx index 67998b0e..4971bdbf 100644 --- a/src/components/messageCanvas/actions/action.tsx +++ b/src/components/messageCanvas/actions/action.tsx @@ -5,13 +5,13 @@ import Tooltip from '@mui/material/Tooltip' import type { ReactNode } from 'react' import React from 'react' -import type { ThreadableMessage } from '../../types' +import type { Message } from '../../types' interface ActionProps { label: string - message: ThreadableMessage + message: Message icon: ReactNode - onClick: (message: ThreadableMessage) => void + onClick: (message: Message) => void } export default function Action(props: ActionProps) { diff --git a/src/components/messageCanvas/actions/copy/copyText.tsx b/src/components/messageCanvas/actions/copy/copyText.tsx index 506020b5..1c433d56 100644 --- a/src/components/messageCanvas/actions/copy/copyText.tsx +++ b/src/components/messageCanvas/actions/copy/copyText.tsx @@ -1,18 +1,18 @@ import React, { useState } from 'react' import Icon from '../../../icon/icon' -import type { ThreadableMessage } from '../../../types' +import type { Message } from '../../../types' import Action from '../index' export interface CopyTextProps { - message: ThreadableMessage + message: Message } export default function CopyText(props: CopyTextProps) { const [tooltipContent, setTooltipContent] = useState('Copy text') const twoSeconds = 2000 - function handleOnClick(message: ThreadableMessage) { + function handleOnClick(message: Message) { if (message.data.text) { navigator.clipboard .writeText(message.data.text) diff --git a/src/components/messageCanvas/actions/textToSpeech/textToSpeech.cy.tsx b/src/components/messageCanvas/actions/textToSpeech/textToSpeech.cy.tsx index 2958726c..59a8afbd 100644 --- a/src/components/messageCanvas/actions/textToSpeech/textToSpeech.cy.tsx +++ b/src/components/messageCanvas/actions/textToSpeech/textToSpeech.cy.tsx @@ -1,8 +1,8 @@ -import type { ThreadableMessage } from '../../../types' // Adjust if needed +import type { Message } from '../../../types' // Adjust if needed import TextToSpeech from './textToSpeech' describe('TextToSpeech Component', () => { - const mockMessage: ThreadableMessage = { + const mockMessage: Message = { id: '1', timestamp: '2020-01-02T00:00:00.000Z', conversationId: 'lkd9vc', diff --git a/src/components/messageCanvas/actions/textToSpeech/textToSpeech.tsx b/src/components/messageCanvas/actions/textToSpeech/textToSpeech.tsx index 1e106f2c..fd0f4cfe 100644 --- a/src/components/messageCanvas/actions/textToSpeech/textToSpeech.tsx +++ b/src/components/messageCanvas/actions/textToSpeech/textToSpeech.tsx @@ -1,11 +1,11 @@ import React, { useState } from 'react' import Icon from '../../../icon/icon' -import type { ThreadableMessage } from '../../../types' +import type { Message } from '../../../types' import Action from '../index' export interface TextToSpeechProps { - message: ThreadableMessage + message: Message } export default function TextToSpeech(props: TextToSpeechProps) { diff --git a/src/components/messageCanvas/messageCanvas.cy.tsx b/src/components/messageCanvas/messageCanvas.cy.tsx index 001aad3e..89cdf349 100644 --- a/src/components/messageCanvas/messageCanvas.cy.tsx +++ b/src/components/messageCanvas/messageCanvas.cy.tsx @@ -6,7 +6,7 @@ import { testUser, } from '../../../cypress/support/variables' import Icon from '../icon/icon' -import type { ThreadableMessage } from '../types' +import type { Message } from '../types' import CopyText from './actions/copy/copyText' import MessageCanvas from './messageCanvas' @@ -43,7 +43,7 @@ describe('MessageCanvas', () => { cy.mount( { + getActionsComponent={(message: Message) => { const copyButton = message.format === 'text' && ( ) diff --git a/src/components/messageCanvas/messageCanvas.stories.tsx b/src/components/messageCanvas/messageCanvas.stories.tsx index 5d816a48..079f28a0 100644 --- a/src/components/messageCanvas/messageCanvas.stories.tsx +++ b/src/components/messageCanvas/messageCanvas.stories.tsx @@ -1,9 +1,11 @@ import Typography from '@mui/material/Typography' import React from 'react' -import { ElementRenderer, MarkedMarkdown, type ThreadableMessage } from '..' +import ElementRenderer from '../elementRenderer' import Icon from '../icon/icon' +import MarkedMarkdown from '../markdown' import Text from '../text/text' +import type { Message } from '../types' import CopyText from './actions/copy/copyText' import TextToSpeech from './actions/textToSpeech/textToSpeech' import MessageCanvas from './messageCanvas' @@ -111,7 +113,7 @@ const elementRendererString = `` -const profileString = `(message: ThreadableMessage) => { +const profileString = `(message: Message) => { <> {getProfileIcon(message)} @@ -120,7 +122,7 @@ const profileString = `(message: ThreadableMessage) => { }` -function getProfileIcon(message: ThreadableMessage) { +function getProfileIcon(message: Message) { if (message.sender.name?.includes('agent')) { return } else { @@ -128,7 +130,7 @@ function getProfileIcon(message: ThreadableMessage) { } } -function getProfileName(message: ThreadableMessage) { +function getProfileName(message: Message) { return ( {message.sender.name} @@ -136,7 +138,7 @@ function getProfileName(message: ThreadableMessage) { ) } -function getProfileIconAndName(message: ThreadableMessage) { +function getProfileIconAndName(message: Message) { return ( <> {getProfileIcon(message)} @@ -149,7 +151,7 @@ export const WithProfileIcon = { args: { children: ( @@ -175,7 +177,7 @@ export const NoIcon = { args: { children: ( @@ -201,14 +203,14 @@ export const WithCopyIcon = { args: { children: ( ), message: messageFromHuman, getProfileComponent: getProfileIconAndName, - getActionsComponent: (message: ThreadableMessage) => { + getActionsComponent: (message: Message) => { const copyButton = message.format === 'text' && ( ) @@ -222,7 +224,7 @@ export const WithCopyIcon = { source: { code: ` { + getActionsComponent={(message: Message) => { const copyButton = message.format === 'text' && if (copyButton) { return <>{copyButton} @@ -241,14 +243,14 @@ export const WithTextToSpeech = { args: { children: ( ), message: markdownMessage, getProfileComponent: getProfileIconAndName, - getActionsComponent: (message: ThreadableMessage) => { + getActionsComponent: (message: Message) => { return ( <> @@ -265,7 +267,7 @@ export const WithTextToSpeech = { source: { code: ` { + getActionsComponent={(message: Message) => { const copyButton = message.format === 'text' && if (copyButton) { return <>{copyButton} diff --git a/src/components/messageCanvas/messageCanvas.tsx b/src/components/messageCanvas/messageCanvas.tsx index 1eff8b88..220aa066 100644 --- a/src/components/messageCanvas/messageCanvas.tsx +++ b/src/components/messageCanvas/messageCanvas.tsx @@ -6,23 +6,23 @@ import Stack from '@mui/material/Stack' import React, { forwardRef, type ReactNode } from 'react' import Timestamp from '../timestamp/timestamp' -import type { ThreadableMessage } from '../types' +import type { Message } from '../types' export interface MessageContainerProps { /** A function that returns a React element to display sender details, like names and/or avatars. */ - getProfileComponent?: (message: ThreadableMessage) => ReactNode + getProfileComponent?: (message: Message) => ReactNode /** A function that returns a single React element which may be composed of several actions supported for the message, such as editing, copying, and deleting, etc. * In case no actions are applicable or available for a particular message, the function may return `undefined`. * This approach offers flexibility in tailoring message interactions to specific application requirements. * To define individual message actions, developers can extend the `Action` component's functionality. * One such example is the `CopyText` component. */ - getActionsComponent?: (message: ThreadableMessage) => ReactNode | undefined + getActionsComponent?: (message: Message) => ReactNode | undefined } export interface MessageCanvasProps extends MessageContainerProps { - /** Message information to be displayed. Please see the `MessageSpace` docs for more information about the `ThreadableMessage` interface. */ - message: ThreadableMessage + /** Message information to be displayed. Please see the `MessageSpace` docs for more information about the `Message` interface. */ + message: Message /** React component to be displayed in the message canvas. */ children: ReactNode } diff --git a/src/components/messageSpace/messageSpace.cy.tsx b/src/components/messageSpace/messageSpace.cy.tsx index 8c875bf0..d9e30093 100644 --- a/src/components/messageSpace/messageSpace.cy.tsx +++ b/src/components/messageSpace/messageSpace.cy.tsx @@ -1,5 +1,6 @@ import 'cypress-real-events' +import { Server } from 'mock-socket' import { v4 as getUUID } from 'uuid' import { @@ -21,9 +22,12 @@ import { YoutubeVideo, } from '..' import Icon from '../icon/icon' +import { getMockWebSocketClient } from '../mockWebSocket' import MessageSpace from './messageSpace' describe('MessageSpace Component', () => { + const messageCanvas = '[data-cy=message-canvas]' + const supportedElements = { text: Text, streamingText: StreamingText, @@ -80,7 +84,80 @@ describe('MessageSpace Component', () => { ] const messageSpace = '[data-cy=message-space]' + const webSocketUrl = 'ws://localhost:8082' + const streamingTextRootMessageId = getUUID() + const messagesToBeSent = [ + { + ...humanMessageData, + id: getUUID(), + timestamp: new Date().toISOString(), + format: 'text', + data: { + text: 'Could you show me an example of the streaming text component?', + }, + }, + { + ...agentMessageData, + id: streamingTextRootMessageId, + timestamp: new Date().toISOString(), + format: 'streamingText', + data: { + text: 'Sure!', + }, + }, + { + ...agentMessageData, + id: getUUID(), + threadId: streamingTextRootMessageId, + timestamp: new Date().toISOString(), + format: 'updateStreamingText', + data: { + text: ' The text', + }, + }, + { + ...agentMessageData, + id: streamingTextRootMessageId, + threadId: streamingTextRootMessageId, + timestamp: new Date().toISOString(), + format: 'updateStreamingText', + data: { + text: ' is displayed', + }, + }, + { + ...agentMessageData, + id: streamingTextRootMessageId, + threadId: streamingTextRootMessageId, + timestamp: new Date().toISOString(), + format: 'updateStreamingText', + data: { + text: ' progressively.', + }, + }, + ] + + let server: Server | null + + const setupWebSocketServer = () => { + server = new Server(webSocketUrl) + const serverDelay = 50 + server.on('connection', (socket) => { + messagesToBeSent.forEach((message, index) => { + setTimeout( + () => socket.send(JSON.stringify(message)), + serverDelay + index * serverDelay + ) + }) + }) + } + const teardownWebSocketServer = () => { + if (server) { + server.stop() + server = null + } + } supportedViewports.forEach((viewport) => { it(`renders correctly with provided messages on ${viewport} screen`, () => { const mockWsClient = { @@ -94,7 +171,7 @@ describe('MessageSpace Component', () => { { if (message.sender.name?.includes('Agent')) { @@ -127,6 +204,54 @@ describe('MessageSpace Component', () => { }) }) + it.only(`can receive and render messages from websocket on ${viewport} screen`, () => { + setupWebSocketServer() + cy.viewport(viewport) + cy.mount( + { + if (message.sender.name?.includes('Agent')) { + return + } else { + return + } + }} + /> + ) + const messageSpace = '[data-cy=message-space]' + cy.get(messageSpace).should('exist') + cy.get(messageSpace).should('contain', 'Existing message') + cy.get(messageSpace).should( + 'not.contain', + 'Sure! The text is displayed progressively.' + ) + cy.get(messageCanvas).should('have.length', 1) + messagesToBeSent.forEach((message) => { + cy.get(messageSpace).should('contain', message.data.text) + }) + cy.get(messageSpace).should( + 'contain', + 'Sure! The text is displayed progressively.' + ) + const totalDisplayedMessages = 3 + cy.get(messageCanvas).should('have.length', totalDisplayedMessages) + teardownWebSocketServer() + }) + it(`scrolls to bottom when "Go to bottom" button is clicked on ${viewport} screen`, () => { const waitTime = 500 @@ -142,7 +267,7 @@ describe('MessageSpace Component', () => { diff --git a/src/components/messageSpace/messageSpace.stories.tsx b/src/components/messageSpace/messageSpace.stories.tsx index 857794b7..d6eef4fe 100644 --- a/src/components/messageSpace/messageSpace.stories.tsx +++ b/src/components/messageSpace/messageSpace.stories.tsx @@ -9,6 +9,7 @@ import { Image, MarkedMarkdown, MarkedStreamingMarkdown, + type Message, Multipart, OpenLayersMap, RechartsTimeSeries, @@ -16,7 +17,6 @@ import { StreamingText, Table, Text, - type ThreadableMessage, Video, YoutubeVideo, } from '..' @@ -45,7 +45,7 @@ const meta: Meta> = { export default meta -function getProfileIcon(message: ThreadableMessage) { +function getProfileIcon(message: Message) { if (message.sender.name?.toLowerCase().includes('agent')) { return } else { @@ -53,7 +53,7 @@ function getProfileIcon(message: ThreadableMessage) { } } -function getProfileIconAndName(message: ThreadableMessage) { +function getProfileIconAndName(message: Message) { return ( <> {getProfileIcon(message)} @@ -65,12 +65,12 @@ function getProfileIconAndName(message: ThreadableMessage) { } meta.argTypes = { - messages: { + receivedMessages: { table: { type: { - summary: 'Array of ThreadableMessage.\n', + summary: 'Array of Message.\n', detail: - 'ThreadableMessage extends the Message interface which has the following fields:\n' + + 'Message interface has the following fields:\n' + ' id: A string representing the unique identifier of the message.\n' + ' timestamp: A string representing the timestamp of the message.\n' + ' sender: An object representing the sender of the message. Refer to the `sender` prop.\n' + @@ -81,19 +81,22 @@ meta.argTypes = { ' threadId: An optional string representing the identifier of the thread to which this message belongs.\n' + ' priority: An optional string representing the priority of the message.\n' + ' taggedParticipants: An optional array of strings representing the participants tagged in the message.\n' + - ' topic: An optional string representing the identifier of the topic associated with the message.\n' + - 'Other than the fields described above, ThreadableMessage also has the following fields:\n' + - ' lastThreadMessage: An optional object of Message interface representing the last message in the thread.\n' + - ' threadMessagesData: An optional array of objects of type MessageData, which can contain any key-value pairs.', + ' topic: An optional string representing the identifier of the topic associated with the message.\n', }, }, }, ws: { + description: + 'WebSocket connection to send and receive messages to and from a backend. The onReceive prop will override the default handler once it is set. If you need to use the WebSocket for purposes other than chat, you will need to create a separate WebSocket connection.', table: { type: { summary: 'WebSocketClient', detail: - 'send: (message: Message) => void\nclose: () => void\nreconnect: () => void\n', + 'A websocket client with supports the following methods:\n' + + 'send: (msg: Message) => void\n' + + 'close: () => void\n' + + 'reconnect: () => void\n' + + 'onReceive?: (handler: (message: Message) => void) => void', }, }, }, @@ -197,12 +200,12 @@ const tableData = [ ] const chartColors = ['#648FFF', '#785EF0', '#DC267F', '#FE6100', '#FFB000'] - +const streamingMarkdownRootMessageId = getUUID() export const Default = { args: { ws: { send: () => {} }, sender: humanMessageData.sender, - messages: [ + receivedMessages: [ { ...humanMessageData, id: getUUID(), @@ -214,11 +217,21 @@ export const Default = { }, { ...agentMessageData, - id: getUUID(), + id: streamingMarkdownRootMessageId, timestamp: '2024-01-02T00:01:00.000Z', - format: 'markdown', + format: 'streamingMarkdown', + data: { + text: '# Title\n\n---\n\n ## Subtitle', + }, + }, + { + ...agentMessageData, + id: getUUID(), + timestamp: '2024-01-02T00:02:01.000Z', + format: 'updateStreamingMarkdown', + threadId: streamingMarkdownRootMessageId, data: { - text: '# Title\n\n---\n\n ## Subtitle\n\nThis is a paragraph. Lorem Ipsum is simply dummy text of the printing and typesetting industry. Lorem Ipsum has been the industry\'s standard dummy text ever since the 1500s, when an unknown printer took a galley of type and scrambled it to make a type specimen book. It has survived not only five centuries, but also the leap into electronic typesetting, remaining essentially unchanged. It was popularised in the 1960s with the release of Letraset sheets containing Lorem Ipsum passages, and more recently with desktop publishing software like Aldus PageMaker including versions of Lorem Ipsum.\n\n- This is an **inline notation**\n- This is a *inline notation*.\n- This is a _inline notation_.\n- This is a __inline notation__.\n- This is a ~~inline notation~~.\n\n```\nconst string = "Hello World"\nconst number = 123\n```\n\n> This is a blockquote.\n\n1. Item 1\n2. Item 2\n3. Item 3\n\n| Column 1 | Column 2 | Column 3 |\n| -------- | -------- | -------- |\n| Item 1 | Item 2 | Item 3 |', + text: '\n\nThis is a paragraph. Lorem Ipsum is simply dummy text of the printing and typesetting industry. Lorem Ipsum has been the industry\'s standard dummy text ever since the 1500s, when an unknown printer took a galley of type and scrambled it to make a type specimen book. It has survived not only five centuries, but also the leap into electronic typesetting, remaining essentially unchanged. It was popularised in the 1960s with the release of Letraset sheets containing Lorem Ipsum passages, and more recently with desktop publishing software like Aldus PageMaker including versions of Lorem Ipsum.\n\n- This is an **inline notation**\n- This is a *inline notation*.\n- This is a _inline notation_.\n- This is a __inline notation__.\n- This is a ~~inline notation~~.\n\n```\nconst string = "Hello World"\nconst number = 123\n```\n\n> This is a blockquote.\n\n1. Item 1\n2. Item 2\n3. Item 3\n\n| Column 1 | Column 2 | Column 3 |\n| -------- | -------- | -------- |\n| Item 1 | Item 2 | Item 3 |', }, }, { @@ -467,7 +480,7 @@ export const Default = { multipart: Multipart, }, getProfileComponent: getProfileIconAndName, - getActionsComponent: (message: ThreadableMessage) => { + getActionsComponent: (message: Message) => { const copyButton = message.format === 'text' && ( ) diff --git a/src/components/messageSpace/messageSpace.tsx b/src/components/messageSpace/messageSpace.tsx index 767ca6d5..f1a2893d 100644 --- a/src/components/messageSpace/messageSpace.tsx +++ b/src/components/messageSpace/messageSpace.tsx @@ -9,12 +9,7 @@ import Icon from '../icon/icon' import MessageCanvas, { type MessageContainerProps, } from '../messageCanvas/messageCanvas' -import type { - ComponentMap, - Sender, - ThreadableMessage, - WebSocketClient, -} from '../types' +import type { ComponentMap, Message, Sender, WebSocketClient } from '../types' export interface MessageSpaceProps extends MessageContainerProps { /** WebSocket connection to send and receive messages to and from a backend. This can be useful for component interactions, for example, to send filter conditions, user location, etc. */ @@ -23,7 +18,8 @@ export interface MessageSpaceProps extends MessageContainerProps { sender: Sender /** A component map contains message formats as keys and their corresponding React components as values. */ supportedElements: ComponentMap - messages?: ThreadableMessage[] + /** Messages received before the component was mounted. These messages are rendered along with new messages received from the websocket. */ + receivedMessages?: Message[] /** Text label for scroll down button. Default value is 'scroll down'. */ scrollDownLabel?: string } @@ -36,8 +32,42 @@ function usePrevious(value: number) { return ref.current } +function getCombinedMessages( + messages: { [key: string]: Message[] }, + message: Message +) { + let key = message.format.includes('update') ? message.threadId : message.id + + if (key) { + const newMessages = { ...messages } + const existingMessages = messages[key] || [] + const originalMessage = existingMessages[0] + + // Check if sender is the same for update messages + if ( + message.format.includes('update') && + originalMessage && + originalMessage.sender.id !== message.sender.id + ) { + key = message.id + } + + if (!newMessages[key]) { + newMessages[key] = [] + } + + newMessages[key] = newMessages[key].concat(message) + + return newMessages + } else { + return messages + } +} + /** - The `MessageSpace` component uses `MessageCanvas` and `ElementRenderer` to render a list of messages. It serves as a container for individual message items, each encapsulated within a `MessageCanvas` for consistent styling and layout. + The `MessageSpace` component uses `MessageCanvas` and `ElementRenderer` to render a list of messages. It serves as a container for individual message items, each encapsulated within a `MessageCanvas` for consistent styling and layout. It can receive and process messages to dynamically update the displayed content. + + The `MessageSpace` component can combine update messages with the original message and render them as a single message. For this to work, the `threadId` of the update message must match the `id` of the original message, and the format of the update message should be prefixed with 'update'. For example, if the original message format is 'streamingText', the update message format should be 'updateStreamingText'. Note: For more information about the `getActionsComponent` and `getProfileComponent` fields, refer to the [MessageCanvas' docs](http://localhost:6006/?path=/docs/rustic-ui-message-canvas-message-canvas--docs). */ @@ -51,8 +81,10 @@ export default function MessageSpace(props: MessageSpaceProps) { const [isScrolledToBottom, setIsScrolledToBottom] = useState(true) const [isScrollButtonHidden, setIsScrollButtonHidden] = useState(true) const [areVideosLoaded, setAreVideosLoaded] = useState(false) - - const currentMessagesLength = props.messages?.length || 0 + const [chatMessages, setChatMessages] = useState<{ + [messageId: string]: Message[] + }>({}) + const currentMessagesLength = Object.keys(chatMessages).length const previousMessagesLength = usePrevious(currentMessagesLength) const hideScrollButtonDuration = 2000 @@ -66,14 +98,6 @@ export default function MessageSpace(props: MessageSpaceProps) { }, hideScrollButtonDuration) } - function handleScrollDown() { - scrollEndRef.current?.scrollIntoView({ - behavior: 'smooth', - block: 'end', - }) - hideScrollButton() - } - function getVideoStatus() { const videos = containerRef.current?.querySelectorAll('video') if (!videos || videos.length === 0) { @@ -112,7 +136,7 @@ export default function MessageSpace(props: MessageSpaceProps) { } }, [isScrolledToBottom]) - function scrollDownIfNeeded() { + function scrollDown() { if (getVideoStatus()) { const container = containerRef.current setAreVideosLoaded(true) @@ -124,29 +148,14 @@ export default function MessageSpace(props: MessageSpaceProps) { }, 0) } } else { - setTimeout(scrollDownIfNeeded, 1) + setTimeout(scrollDown, 1) } } useEffect(() => { - scrollDownIfNeeded() + scrollDown() }, [areVideosLoaded]) - function scrollToLastMessage() { - if (getVideoStatus()) { - const lastMessage = scrollEndRef.current - - if (lastMessage) { - // Use setTimeout to delay smooth scrolling - setTimeout(() => { - lastMessage.scrollIntoView({ block: 'start', inline: 'nearest' }) - }, 0) - } - } else { - setTimeout(scrollToLastMessage, 1) - } - } - useEffect(() => { const hasNewMessage = previousMessagesLength !== 0 && @@ -154,9 +163,32 @@ export default function MessageSpace(props: MessageSpaceProps) { if (isScrolledToBottom && hasNewMessage) { hideScrollButton() - scrollToLastMessage() + scrollDown() + } + }, [isScrolledToBottom, Object.keys(chatMessages).length]) + + useEffect(() => { + let messageDict: { [messageId: string]: Message[] } = {} + + props.receivedMessages?.forEach((message) => { + const newMessageDict = getCombinedMessages(messageDict, message) + messageDict = newMessageDict + }) + + setChatMessages(messageDict) + }, [props.receivedMessages?.length]) + + function handleIncomingMessage(message: Message) { + setChatMessages((prevMessages) => + getCombinedMessages(prevMessages, message) + ) + } + + useEffect(() => { + if (props.ws.onReceive) { + props.ws.onReceive(handleIncomingMessage) } - }, [isScrolledToBottom, props.messages?.length]) + }, []) return ( - {props.messages && - props.messages.length > 0 && - props.messages.map((message, index) => { - return ( - - - - ) - })} + {Object.keys(chatMessages).map((key, index) => { + const messages = chatMessages[key] + const lastestMessage = messages[messages.length - 1] + return ( + + + + ) + })} {!isScrolledToBottom && !isScrollButtonHidden && ( {props.scrollDownLabel} diff --git a/src/components/promptBuilder/mockWebSocket.ts b/src/components/mockWebSocket.ts similarity index 55% rename from src/components/promptBuilder/mockWebSocket.ts rename to src/components/mockWebSocket.ts index 3af7957f..7f7f7fb3 100644 --- a/src/components/promptBuilder/mockWebSocket.ts +++ b/src/components/mockWebSocket.ts @@ -1,7 +1,7 @@ import { type Client, WebSocket } from 'mock-socket' import { v4 as getUUID } from 'uuid' -import type { Message, MessageData, WebSocketClient } from '../types' +import type { Message, MessageData, WebSocketClient } from './types' const serverDelay = 500 @@ -25,22 +25,37 @@ export function sendMessageToClient( } export function getMockWebSocketClient(webSocketUrl: string): WebSocketClient { - let ws = new WebSocket(webSocketUrl) + let ws: WebSocket | null = null + const connect = (): WebSocket => { + const socket = new WebSocket(webSocketUrl) + socket.onopen = () => {} + return socket + } + ws = connect() return { send: (message: Message) => { - ws.send(JSON.stringify(message)) + if (ws) { + ws.send(JSON.stringify(message)) + } }, close: () => { - ws.close() + if (ws) { + ws.close() + } }, reconnect: () => { - if (ws.readyState === WebSocket.CLOSED) { - ws = new WebSocket(webSocketUrl) + if (ws && ws.readyState === WebSocket.CLOSED) { + ws = connect() } }, - onReceive: (handler: (event: MessageEvent) => void) => { - ws.onmessage = handler + onReceive: (handler: (message: Message) => void) => { + if (ws) { + ws.onmessage = (event) => { + const receivedMessage = JSON.parse(event.data) + handler(receivedMessage) + } + } }, } } diff --git a/src/components/promptBuilder/promptBuilder.cy.tsx b/src/components/promptBuilder/promptBuilder.cy.tsx index ca178507..d1a1b54b 100644 --- a/src/components/promptBuilder/promptBuilder.cy.tsx +++ b/src/components/promptBuilder/promptBuilder.cy.tsx @@ -1,7 +1,7 @@ import { Server } from 'mock-socket' +import { getMockWebSocketClient, sendMessageToClient } from '../mockWebSocket' import Question from '../question/question' -import { getMockWebSocketClient, sendMessageToClient } from './mockWebSocket' import PromptBuilder from './promptBuilder' const webSocketUrl = 'ws://localhost:8081' diff --git a/src/components/promptBuilder/promptBuilder.stories.tsx b/src/components/promptBuilder/promptBuilder.stories.tsx index 9491557d..afea5603 100644 --- a/src/components/promptBuilder/promptBuilder.stories.tsx +++ b/src/components/promptBuilder/promptBuilder.stories.tsx @@ -14,10 +14,10 @@ import { v4 as getUUID } from 'uuid' import TextInput from '../input/textInput/textInput' import MessageSpace from '../messageSpace/messageSpace' +import { getMockWebSocketClient, sendMessageToClient } from '../mockWebSocket' import Question from '../question/question' import Text from '../text/text' import type { Message, QuestionProps } from '../types' -import { getMockWebSocketClient, sendMessageToClient } from './mockWebSocket' import PromptBuilder from './promptBuilder' const meta: Meta> = { @@ -53,7 +53,7 @@ meta.argTypes = { 'send: (msg: Message) => void\n' + 'close: () => void\n' + 'reconnect: () => void\n' + - 'onReceive?: (handler: (event: MessageEvent) => void) => void\n', + 'onReceive?: (handler: (message: Message) => void) => void', }, }, }, @@ -85,7 +85,7 @@ meta.argTypes = { type: 'function', table: { type: { - summary: '(message: ThreadableMessage) => ReactNode', + summary: '(message: Message) => ReactNode', }, }, }, @@ -301,7 +301,7 @@ export const Default = { ws={boilerplateWs} sender={user} supportedElements={{ text: Text }} - messages={messageSpaceMessages} + receivedMessages={messageSpaceMessages} getProfileComponent={(message) => { return <>{message.sender.name} }} diff --git a/src/components/promptBuilder/promptBuilder.tsx b/src/components/promptBuilder/promptBuilder.tsx index 9963bfff..5e3826ad 100644 --- a/src/components/promptBuilder/promptBuilder.tsx +++ b/src/components/promptBuilder/promptBuilder.tsx @@ -55,9 +55,8 @@ export default function PromptBuilder(props: PromptBuilderProps) { }, } - function handleIncomingMessage(event: MessageEvent) { - const receivedMessage = JSON.parse(event.data) - setMessages((prev) => [...prev, receivedMessage]) + function handleIncomingMessage(message: Message) { + setMessages((prev) => [...prev, message]) } useEffect(() => { @@ -121,7 +120,7 @@ export default function PromptBuilder(props: PromptBuilderProps) { key={message.id} ws={inputCapturer} sender={props.sender} - message={message} + messages={[message]} supportedElements={props.supportedElements} /> ) diff --git a/src/components/question/question.stories.tsx b/src/components/question/question.stories.tsx index 4ecc1b30..808920ba 100644 --- a/src/components/question/question.stories.tsx +++ b/src/components/question/question.stories.tsx @@ -32,7 +32,11 @@ meta.argTypes = { type: { summary: 'WebSocketClient', detail: - 'send: (message: Message) => void\nclose: () => void\nreconnect: () => void\n', + 'A websocket client with supports the following methods:\n' + + 'send: (msg: Message) => void\n' + + 'close: () => void\n' + + 'reconnect: () => void\n' + + 'onReceive?: (handler: (message: Message) => void) => void', }, }, }, diff --git a/src/components/types.ts b/src/components/types.ts index 7fbda4f8..b0c60c0b 100644 --- a/src/components/types.ts +++ b/src/components/types.ts @@ -25,11 +25,6 @@ export interface Message { topic?: string } -export interface ThreadableMessage extends Message { - lastThreadMessage?: Message - threadMessagesData?: MessageData[] -} - export interface ComponentMap { // eslint-disable-next-line @typescript-eslint/no-explicit-any [key: string]: React.ComponentType @@ -39,7 +34,7 @@ export interface WebSocketClient { send: (message: Message) => void close: () => void reconnect: () => void - onReceive?: (handler: (event: MessageEvent) => void) => void + onReceive?: (handler: (message: Message) => void) => void } export enum ParticipantRole {