From d85f3f4fd49b58eead32e2bd85c83e4bc4ddc59d Mon Sep 17 00:00:00 2001 From: Steven Lindsay Date: Tue, 10 Dec 2024 10:24:57 +0000 Subject: [PATCH 1/2] Demo: Refactor demo app to reduce single component complexity. Refactored the demo app Chat container component. Everything was packed into this one container, so it made it confusing to understand the separation of features. This is a first pass, for now I have just moved things into their own components, but we should look at ensuring each is fully isolated and reusable. --- demo/src/App.tsx | 4 +- .../ChatBoxComponent/ChatBoxComponent.tsx | 179 ++++++++++ demo/src/components/ChatBoxComponent/index.ts | 1 + .../components/MessageInput/MessageInput.tsx | 104 +++--- .../ReactionComponent/ReactionComponent.tsx | 48 +++ .../src/components/ReactionComponent/index.ts | 1 + .../TypingIndicatorPanel.tsx | 25 ++ .../components/TypingIndicatorPanel/index.ts | 1 + demo/src/containers/Chat/Chat.tsx | 312 +++--------------- demo/src/index.css | 23 ++ demo/src/main.tsx | 2 +- 11 files changed, 388 insertions(+), 312 deletions(-) create mode 100644 demo/src/components/ChatBoxComponent/ChatBoxComponent.tsx create mode 100644 demo/src/components/ChatBoxComponent/index.ts create mode 100644 demo/src/components/ReactionComponent/ReactionComponent.tsx create mode 100644 demo/src/components/ReactionComponent/index.ts create mode 100644 demo/src/components/TypingIndicatorPanel/TypingIndicatorPanel.tsx create mode 100644 demo/src/components/TypingIndicatorPanel/index.ts diff --git a/demo/src/App.tsx b/demo/src/App.tsx index 76aea3f4..e20fc1a6 100644 --- a/demo/src/App.tsx +++ b/demo/src/App.tsx @@ -55,7 +55,9 @@ const App: FC = () => { attach={true} options={RoomOptionsDefaults} > -
+
= () => { + const [loading, setLoading] = useState(true); + const [messages, setMessages] = useState([]); + const chatClient = useChatClient(); + const clientId = chatClient.clientId; + + const { getPreviousMessages, deleteMessage, update } = useMessages({ + listener: (message: MessageEventPayload) => { + switch (message.type) { + case MessageEvents.Created: { + setMessages((prevMessages) => { + // if already exists do nothing + const index = prevMessages.findIndex((m) => m.serial === message.message.serial); + if (index !== -1) { + return prevMessages; + } + + // if the message is not in the list, add it + const newArray = [...prevMessages, message.message]; + + // and put it at the right place + newArray.sort((a, b) => (a.before(b) ? -1 : 1)); + + return newArray; + }); + break; + } + case MessageEvents.Deleted: { + setMessages((prevMessage) => { + const updatedArray = prevMessage.filter((m) => { + return m.serial !== message.message.serial; + }); + + // don't change state if deleted message is not in the current list + if (prevMessage.length === updatedArray.length) { + return prevMessage; + } + + return updatedArray; + }); + break; + } + case MessageEvents.Updated: { + handleUpdatedMessage(message.message); + break; + } + default: { + console.error('Unknown message', message); + } + } + }, + onDiscontinuity: (discontinuity) => { + console.log('Discontinuity', discontinuity); + // reset the messages when a discontinuity is detected, + // this will trigger a re-fetch of the messages + setMessages([]); + + // set our state to loading, because we'll need to fetch previous messages again + setLoading(true); + + // Do a message backfill + backfillPreviousMessages(getPreviousMessages); + }, + }); + + const backfillPreviousMessages = (getPreviousMessages: ReturnType['getPreviousMessages']) => { + chatClient.logger.debug('backfilling previous messages'); + if (getPreviousMessages) { + getPreviousMessages({ limit: 50 }) + .then((result: PaginatedResult) => { + chatClient.logger.debug('backfilled messages', result.items); + setMessages(result.items.filter((m) => !m.isDeleted).reverse()); + setLoading(false); + }) + .catch((error: ErrorInfo) => { + chatClient.logger.error(`Error fetching initial messages: ${error.toString()}`, error); + }); + } + }; + + const handleUpdatedMessage = (message: Message) => { + setMessages((prevMessages) => { + const index = prevMessages.findIndex((m) => m.serial === message.serial); + if (index === -1) { + return prevMessages; + } + + // skip update if the received version is not newer + if (!prevMessages[index].versionBefore(message)) { + return prevMessages; + } + + const updatedArray = [...prevMessages]; + updatedArray[index] = message; + return updatedArray; + }); + }; + + const onUpdateMessage = useCallback( + (message: Message) => { + const newText = prompt('Enter new text'); + if (!newText) { + return; + } + update(message, { + text: newText, + metadata: message.metadata, + headers: message.headers, + }) + .then((updatedMessage: Message) => { + handleUpdatedMessage(updatedMessage); + }) + .catch((error: unknown) => { + console.warn('failed to update message', error); + }); + }, + [update], + ); + + const onDeleteMessage = useCallback( + (message: Message) => { + deleteMessage(message, { description: 'deleted by user' }).then((deletedMessage: Message) => { + setMessages((prevMessages) => { + return prevMessages.filter((m) => m.serial !== deletedMessage.serial); + }); + }); + }, + [deleteMessage], + ); + + // Used to anchor the scroll to the bottom of the chat + const messagesEndRef = useRef(null); + + useEffect(() => { + chatClient.logger.debug('updating getPreviousMessages useEffect', { getPreviousMessages }); + backfillPreviousMessages(getPreviousMessages); + }, [getPreviousMessages]); + + const scrollToBottom = () => { + messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' }); + }; + + useEffect(() => { + if (!loading) { + scrollToBottom(); + } + }, [messages, loading]); + + return ( +
+ {loading &&
loading...
} + {!loading && ( +
+ {messages.map((msg) => ( + + ))} +
+
+ )} +
+ ); +}; diff --git a/demo/src/components/ChatBoxComponent/index.ts b/demo/src/components/ChatBoxComponent/index.ts new file mode 100644 index 00000000..e79087b2 --- /dev/null +++ b/demo/src/components/ChatBoxComponent/index.ts @@ -0,0 +1 @@ +export { ChatBoxComponent } from './ChatBoxComponent.tsx'; diff --git a/demo/src/components/MessageInput/MessageInput.tsx b/demo/src/components/MessageInput/MessageInput.tsx index ca37c8b6..427143dd 100644 --- a/demo/src/components/MessageInput/MessageInput.tsx +++ b/demo/src/components/MessageInput/MessageInput.tsx @@ -1,27 +1,41 @@ -import { ChangeEventHandler, FC, FormEventHandler, useRef } from 'react'; -import { Message, SendMessageParams } from '@ably/chat'; +import { ChangeEventHandler, FC, FormEventHandler, useEffect, useRef, useState } from 'react'; +import { useChatConnection, useMessages, useTyping } from '@ably/chat/react'; +import { ConnectionStatus } from '@ably/chat'; -interface MessageInputProps { - disabled: boolean; +interface MessageInputProps {} - onSend(params: SendMessageParams): Promise; +export const MessageInput: FC = ({}) => { + const { send } = useMessages(); + const { start, stop } = useTyping(); + const { currentStatus } = useChatConnection(); + const [shouldDisable, setShouldDisable] = useState(true); - onStartTyping(): void; + useEffect(() => { + // disable the input if the connection is not established + setShouldDisable(currentStatus !== ConnectionStatus.Connected); + }, [currentStatus]); - onStopTyping(): void; -} + const handleStartTyping = () => { + start().catch((error: unknown) => { + console.error('Failed to start typing indicator', error); + }); + }; + const handleStopTyping = () => { + stop().catch((error: unknown) => { + console.error('Failed to stop typing indicator', error); + }); + }; -export const MessageInput: FC = ({ disabled, onSend, onStartTyping, onStopTyping }) => { const handleValueChange: ChangeEventHandler = ({ target }) => { // Typing indicators start method should be called with every keystroke since // they automatically stop if the user stops typing for a certain amount of time. // // The timeout duration can be configured when initializing the room. if (target.value && target.value.length > 0) { - onStartTyping(); + handleStartTyping(); } else { // For good UX we should stop typing indicators as soon as the input field is empty. - onStopTyping(); + handleStopTyping(); } }; @@ -38,51 +52,53 @@ export const MessageInput: FC = ({ disabled, onSend, onStartT } // send the message and reset the input field - onSend({ text: messageInputRef.current.value }) + send({ text: messageInputRef.current.value }) .then(() => { if (messageInputRef.current) { messageInputRef.current.value = ''; } }) - .catch((error) => { + .catch((error: unknown) => { console.error('Failed to send message', error); }); // stop typing indicators - onStopTyping(); + handleStopTyping(); }; return ( -
- -
- -
-
+ Send + + + + +
+ +
); }; diff --git a/demo/src/components/ReactionComponent/ReactionComponent.tsx b/demo/src/components/ReactionComponent/ReactionComponent.tsx new file mode 100644 index 00000000..ddd01ff5 --- /dev/null +++ b/demo/src/components/ReactionComponent/ReactionComponent.tsx @@ -0,0 +1,48 @@ +import { ReactionInput } from '../ReactionInput'; +import { FC, useEffect, useState } from 'react'; +import { ConnectionStatus, Reaction } from '@ably/chat'; +import { useChatConnection, useRoom, useRoomReactions } from '@ably/chat/react'; + +interface ReactionComponentProps {} + +export const ReactionComponent: FC = () => { + const [isConnected, setIsConnected] = useState(true); + const { currentStatus } = useChatConnection(); + const [roomReactions, setRoomReactions] = useState([]); + const { roomId } = useRoom(); + const { send: sendReaction } = useRoomReactions({ + listener: (reaction: Reaction) => { + setRoomReactions([...roomReactions, reaction]); + }, + }); + + useEffect(() => { + // clear reactions when the room changes + if (roomId) { + setRoomReactions([]); + } + }, [roomId]); + + useEffect(() => { + // enable/disable the input based on the connection status + setIsConnected(currentStatus === ConnectionStatus.Connected); + }, [currentStatus]); + + return ( +
+
+ +
+
+ Received reactions:{' '} + {roomReactions.map((r, idx) => ( + {r.type} + ))}{' '} +
+
+ ); +}; diff --git a/demo/src/components/ReactionComponent/index.ts b/demo/src/components/ReactionComponent/index.ts new file mode 100644 index 00000000..c507bb76 --- /dev/null +++ b/demo/src/components/ReactionComponent/index.ts @@ -0,0 +1 @@ +export { ReactionComponent } from './ReactionComponent.tsx'; diff --git a/demo/src/components/TypingIndicatorPanel/TypingIndicatorPanel.tsx b/demo/src/components/TypingIndicatorPanel/TypingIndicatorPanel.tsx new file mode 100644 index 00000000..189675a5 --- /dev/null +++ b/demo/src/components/TypingIndicatorPanel/TypingIndicatorPanel.tsx @@ -0,0 +1,25 @@ +import { FC } from 'react'; +import { useChatClient, useTyping } from '@ably/chat/react'; + +interface TypingIndicatorPanelProps {} + +export const TypingIndicatorPanel: FC = () => { + const chatClient = useChatClient(); + const clientId = chatClient.clientId; + const { currentlyTyping, error } = useTyping(); + + return ( +
+ {error &&
Typing indicator error: {error.message}
} + {!error && ( +
+ {new Array(...currentlyTyping) + .filter((client) => client !== clientId) + .map((client) => ( +

{client} is typing...

+ ))} +
+ )} +
+ ); +}; diff --git a/demo/src/components/TypingIndicatorPanel/index.ts b/demo/src/components/TypingIndicatorPanel/index.ts new file mode 100644 index 00000000..b057c744 --- /dev/null +++ b/demo/src/components/TypingIndicatorPanel/index.ts @@ -0,0 +1 @@ +export { TypingIndicatorPanel } from './TypingIndicatorPanel.tsx'; diff --git a/demo/src/containers/Chat/Chat.tsx b/demo/src/containers/Chat/Chat.tsx index 89fd2b05..dab70830 100644 --- a/demo/src/containers/Chat/Chat.tsx +++ b/demo/src/containers/Chat/Chat.tsx @@ -1,152 +1,21 @@ -import { useCallback, useEffect, useRef, useState } from 'react'; -import { MessageComponent } from '../../components/MessageComponent'; +import { useEffect, useState } from 'react'; import { MessageInput } from '../../components/MessageInput'; -import { useChatClient, useChatConnection, useMessages, useRoomReactions, useTyping } from '@ably/chat/react'; -import { ReactionInput } from '../../components/ReactionInput'; +import { useChatClient, useChatConnection } from '@ably/chat/react'; import { ConnectionStatusComponent } from '../../components/ConnectionStatusComponent'; -import { ConnectionStatus, Message, MessageEventPayload, MessageEvents, PaginatedResult, Reaction } from '@ably/chat'; +import { ConnectionStatus } from '@ably/chat'; +import { TypingIndicatorPanel } from '../../components/TypingIndicatorPanel'; +import { ChatBoxComponent } from '../../components/ChatBoxComponent'; +import { ReactionComponent } from '../../components/ReactionComponent'; export const Chat = (props: { roomId: string; setRoomId: (roomId: string) => void }) => { const chatClient = useChatClient(); const clientId = chatClient.clientId; - const [messages, setMessages] = useState([]); + const [isConnected, setIsConnected] = useState(false); const { currentStatus } = useChatConnection(); - const [loading, setLoading] = useState(true); - - const isConnected: boolean = currentStatus === ConnectionStatus.Connected; - - const backfillPreviousMessages = (getPreviousMessages: ReturnType['getPreviousMessages']) => { - chatClient.logger.debug('backfilling previous messages'); - if (getPreviousMessages) { - getPreviousMessages({ limit: 50 }) - .then((result: PaginatedResult) => { - chatClient.logger.debug('backfilled messages', result.items); - setMessages(result.items.filter((m) => !m.isDeleted).reverse()); - setLoading(false); - }) - .catch((error: unknown) => { - chatClient.logger.error('Error fetching initial messages', error); - }); - } - }; - - const handleUpdatedMessage = (message: Message) => { - setMessages((prevMessages) => { - const index = prevMessages.findIndex((m) => m.serial === message.serial); - if (index === -1) { - return prevMessages; - } - - // skip update if the received version is not newer - if (!prevMessages[index].versionBefore(message)) { - return prevMessages; - } - - const updatedArray = [...prevMessages]; - updatedArray[index] = message; - return updatedArray; - }); - }; - - const { - send: sendMessage, - getPreviousMessages, - deleteMessage, - update, - } = useMessages({ - listener: (message: MessageEventPayload) => { - switch (message.type) { - case MessageEvents.Created: { - setMessages((prevMessages) => { - // if already exists do nothing - const index = prevMessages.findIndex((m) => m.serial === message.message.serial); - if (index !== -1) { - return prevMessages; - } - - // if the message is not in the list, add it - const newArray = [...prevMessages, message.message]; - - // and put it at the right place - for (let i = newArray.length - 1; i > 1; i--) { - if (newArray[i].before(newArray[i - 1])) { - const temp = newArray[i]; - newArray[i] = newArray[i - 1]; - newArray[i - 1] = temp; - } - } - - return newArray; - }); - break; - } - case MessageEvents.Deleted: { - setMessages((prevMessage) => { - const updatedArray = prevMessage.filter((m) => { - return m.serial !== message.message.serial; - }); - - // don't change state if deleted message is not in the current list - if (prevMessage.length === updatedArray.length) { - return prevMessage; - } - - return updatedArray; - }); - break; - } - case MessageEvents.Updated: { - handleUpdatedMessage(message.message); - break; - } - default: { - console.error('Unknown message', message); - } - } - }, - onDiscontinuity: (discontinuity) => { - console.log('Discontinuity', discontinuity); - // reset the messages when a discontinuity is detected, - // this will trigger a re-fetch of the messages - setMessages([]); - - // set our state to loading, because we'll need to fetch previous messages again - setLoading(true); - - // Do a message backfill - backfillPreviousMessages(getPreviousMessages); - }, - }); - - const { start, stop, currentlyTyping, error: typingError } = useTyping(); - const [roomReactions, setRoomReactions] = useState([]); - - const { send: sendReaction } = useRoomReactions({ - listener: (reaction: Reaction) => { - setRoomReactions([...roomReactions, reaction]); - }, - }); - - // Used to anchor the scroll to the bottom of the chat - const messagesEndRef = useRef(null); useEffect(() => { - chatClient.logger.debug('updating getPreviousMessages useEffect', { getPreviousMessages }); - backfillPreviousMessages(getPreviousMessages); - }, [getPreviousMessages]); - - const handleStartTyping = () => { - start().catch((error: unknown) => { - console.error('Failed to start typing indicator', error); - }); - }; - - const handleStopTyping = () => { - stop().catch((error: unknown) => { - console.error('Failed to stop typing indicator', error); - }); - }; - + setIsConnected(currentStatus === ConnectionStatus.Connected); + }, [currentStatus]); /** * In a real app, changing the logged in user typically means navigating to * some sort of login page and then navigating back to the main app. @@ -188,138 +57,49 @@ export const Chat = (props: { roomId: string; setRoomId: (roomId: string) => voi if (!newRoomId) { return; } - - // Clear the room messages - setMessages([]); - setLoading(true); - setRoomReactions([]); props.setRoomId(newRoomId); } - const scrollToBottom = () => { - messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' }); - }; - - useEffect(() => { - if (!loading) { - scrollToBottom(); - } - }, [messages, loading]); - - const onUpdateMessage = useCallback( - (message: Message) => { - const newText = prompt('Enter new text'); - if (!newText) { - return; - } - update(message, { - text: newText, - metadata: message.metadata, - headers: message.headers, - }) - .then((updatedMessage: Message) => { - handleUpdatedMessage(updatedMessage); - }) - .catch((error: unknown) => { - console.warn('failed to update message', error); - }); - }, - [update], - ); - - const onDeleteMessage = useCallback( - (message: Message) => { - deleteMessage(message, { description: 'deleted by user' }).then((deletedMessage: Message) => { - setMessages((prevMessages) => { - return prevMessages.filter((m) => m.serial !== deletedMessage.serial); - }); - }); - }, - [deleteMessage], - ); - return ( -
- -
- You are {clientId}.{' '} - - Change clientId - - . -
-
- You are in room {props.roomId}.{' '} - - Change roomId - - . -
- {loading &&
loading...
} - {!loading && ( -
- {messages.map((msg) => ( - - ))} -
-
- )} - {typingError && ( -
Typing indicator error: {typingError.message}
- )} - {!typingError && ( -
- {new Array(...currentlyTyping) - .filter((client) => client !== clientId) - .map((client) => ( -

{client} is typing...

- ))} +
+ {!isConnected &&
loading...
} + {isConnected && ( +
+ +
+ You are {clientId}.{' '} + + Change clientId + + . +
+
+ You are in room {props.roomId}.{' '} + + Change roomId + + . +
+ + + +
)} -
- -
-
- -
-
- Received reactions:{' '} - {roomReactions.map((r, idx) => ( - {r.type} - ))}{' '} -
); }; diff --git a/demo/src/index.css b/demo/src/index.css index 8a617279..ceb8b743 100644 --- a/demo/src/index.css +++ b/demo/src/index.css @@ -24,6 +24,29 @@ body { min-height: 100vh; } +.chat-box { + display: grid; + grid-template-columns: 1fr; + grid-template-rows: 1fr auto; + height: 100%; +} + +.chat-window { + height: 500px; + min-height: max(100%, 500px); + overflow-y: auto; /* Allows scroll the window when messages are added beyond the max height */ + padding: 1rem; /* Adds padding */ + scrollbar-width: thin; + scrollbar-color: rgba(255, 255, 255, 0.5) rgba(255, 255, 255, 0.1); +} + +.typing-indicator-container { + display: flex; + align-items: center; + justify-content: center; + height: 20px; +} + .reactions-picker > a { display: inline-block; padding: 5px; diff --git a/demo/src/main.tsx b/demo/src/main.tsx index 318ce314..ebc9dae3 100644 --- a/demo/src/main.tsx +++ b/demo/src/main.tsx @@ -60,7 +60,7 @@ const getRealtimeOptions = () => { const realtimeClient = new Ably.Realtime(getRealtimeOptions()); -const chatClient = new ChatClient(realtimeClient, { logLevel: LogLevel.Info }); +const chatClient = new ChatClient(realtimeClient, { logLevel: LogLevel.Debug }); ReactDOM.createRoot(document.getElementById('root')!).render( From b460e38dddf0c9a1dcbb105b34f341a96892260c Mon Sep 17 00:00:00 2001 From: Vlad Velici Date: Tue, 7 Jan 2025 18:44:01 +0000 Subject: [PATCH 2/2] demo: Make the chat window full height with a min 500px --- .../ChatBoxComponent/ChatBoxComponent.tsx | 12 ++++++------ demo/src/containers/Chat/Chat.tsx | 6 +++--- demo/src/index.css | 13 +++++-------- 3 files changed, 14 insertions(+), 17 deletions(-) diff --git a/demo/src/components/ChatBoxComponent/ChatBoxComponent.tsx b/demo/src/components/ChatBoxComponent/ChatBoxComponent.tsx index 173b1b63..37735c51 100644 --- a/demo/src/components/ChatBoxComponent/ChatBoxComponent.tsx +++ b/demo/src/components/ChatBoxComponent/ChatBoxComponent.tsx @@ -155,13 +155,13 @@ export const ChatBoxComponent: FC = () => { }, [messages, loading]); return ( -
+
{loading &&
loading...
} {!loading && ( -
+ <> {messages.map((msg) => ( = () => { > ))}
-
+ )}
); diff --git a/demo/src/containers/Chat/Chat.tsx b/demo/src/containers/Chat/Chat.tsx index dab70830..fdc01dff 100644 --- a/demo/src/containers/Chat/Chat.tsx +++ b/demo/src/containers/Chat/Chat.tsx @@ -61,10 +61,10 @@ export const Chat = (props: { roomId: string; setRoomId: (roomId: string) => voi } return ( -
+ <> {!isConnected &&
loading...
} {isConnected && ( -
+
voi
)} -
+ ); }; diff --git a/demo/src/index.css b/demo/src/index.css index ceb8b743..bbb318a9 100644 --- a/demo/src/index.css +++ b/demo/src/index.css @@ -21,19 +21,16 @@ body { margin: 0 auto; place-items: center; min-width: 320px; - min-height: 100vh; + height: 100vh; } -.chat-box { - display: grid; - grid-template-columns: 1fr; - grid-template-rows: 1fr auto; - height: 100%; +.chat-box-wrapper { + height: 100vh; + min-height: 300px; } .chat-window { - height: 500px; - min-height: max(100%, 500px); + height: 100%; overflow-y: auto; /* Allows scroll the window when messages are added beyond the max height */ padding: 1rem; /* Adds padding */ scrollbar-width: thin;