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..fdc01dff 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..bbb318a9 100644 --- a/demo/src/index.css +++ b/demo/src/index.css @@ -21,7 +21,27 @@ body { margin: 0 auto; place-items: center; min-width: 320px; - min-height: 100vh; + height: 100vh; +} + +.chat-box-wrapper { + height: 100vh; + min-height: 300px; +} + +.chat-window { + height: 100%; + 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 { 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(