Skip to content

Commit

Permalink
Merge pull request #433 from ably/demo/refactor-chat-container
Browse files Browse the repository at this point in the history
Demo: Refactor demo app to reduce single component complexity.
  • Loading branch information
splindsay-92 authored Jan 21, 2025
2 parents 87ab7e1 + bcf112c commit 6a9b850
Show file tree
Hide file tree
Showing 10 changed files with 384 additions and 319 deletions.
4 changes: 3 additions & 1 deletion demo/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -54,7 +54,9 @@ const App: FC<AppProps> = () => {
attach={true}
options={RoomOptionsDefaults}
>
<div style={{ display: 'flex', justifyContent: 'space-between', width: '800px', margin: 'auto' }}>
<div
style={{ display: 'flex', justifyContent: 'space-between', width: '800px', margin: 'auto', height: '650px' }}
>
<Chat
setRoomId={updateRoomId}
roomId={roomIdState}
Expand Down
177 changes: 177 additions & 0 deletions demo/src/components/ChatBoxComponent/ChatBoxComponent.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,177 @@
import { MessageComponent } from '../MessageComponent';
import { useChatClient, useMessages } from '@ably/chat';
import { FC, useCallback, useEffect, useRef, useState } from 'react';
import { Message, MessageEventPayload, MessageEvents, PaginatedResult } from '@ably/chat';
import { ErrorInfo } from 'ably';

interface ChatBoxComponentProps {}

export const ChatBoxComponent: FC<ChatBoxComponentProps> = () => {
const [loading, setLoading] = useState(true);
const [messages, setMessages] = useState<Message[]>([]);
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<typeof useMessages>['getPreviousMessages']) => {
if (getPreviousMessages) {
getPreviousMessages({ limit: 50 })
.then((result: PaginatedResult<Message>) => {
setMessages(result.items.filter((m) => !m.isDeleted).reverse());
setLoading(false);
})
.catch((error: ErrorInfo) => {
console.error(`Failed to backfill previous 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<HTMLDivElement | null>(null);

useEffect(() => {
console.debug('updating getPreviousMessages useEffect', { getPreviousMessages });
backfillPreviousMessages(getPreviousMessages);
}, [getPreviousMessages]);

const scrollToBottom = () => {
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' });
};

useEffect(() => {
if (!loading) {
scrollToBottom();
}
}, [messages, loading]);

return (
<div className="chat-box">
{loading && <div className="text-center m-auto">loading...</div>}
{!loading && (
<div
id="messages"
className="chat-window"
>
{messages.map((msg) => (
<MessageComponent
key={msg.serial}
self={msg.clientId === clientId}
message={msg}
onMessageDelete={onDeleteMessage}
onMessageUpdate={onUpdateMessage}
></MessageComponent>
))}
<div ref={messagesEndRef} />
</div>
)}
</div>
);
};
1 change: 1 addition & 0 deletions demo/src/components/ChatBoxComponent/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export { ChatBoxComponent } from './ChatBoxComponent.tsx';
104 changes: 60 additions & 44 deletions demo/src/components/MessageInput/MessageInput.tsx
Original file line number Diff line number Diff line change
@@ -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';
import { ConnectionStatus } from '@ably/chat';

interface MessageInputProps {
disabled: boolean;
interface MessageInputProps {}

onSend(params: SendMessageParams): Promise<Message>;
export const MessageInput: FC<MessageInputProps> = ({}) => {
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<MessageInputProps> = ({ disabled, onSend, onStartTyping, onStopTyping }) => {
const handleValueChange: ChangeEventHandler<HTMLInputElement> = ({ 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();
}
};

Expand All @@ -38,51 +52,53 @@ export const MessageInput: FC<MessageInputProps> = ({ 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 (
<form
onSubmit={handleFormSubmit}
className="flex"
>
<input
type="text"
onChange={handleValueChange}
disabled={disabled}
placeholder="Say something"
className="w-full focus:outline-none focus:placeholder-gray-400 text-gray-600 placeholder-gray-600 pl-2 pr-2 bg-gray-200 rounded-l-md py-1"
ref={messageInputRef}
autoFocus
/>
<div className="items-center inset-y-0 flex">
<button
disabled={disabled}
type="submit"
className="inline-flex items-center justify-center rounded-r-md px-3 py-1 transition duration-500 ease-in-out text-white bg-blue-500 hover:bg-blue-400 focus:outline-none disabled:bg-gray-400 disabled:cursor-not-allowed"
>
Send
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 20 20"
fill="currentColor"
className="h-6 w-6 ml-2 transform rotate-90"
<div className="border-t-2 border-gray-200 px-4 pt-4 mb-2 sm:mb-0">
<form
onSubmit={handleFormSubmit}
className="flex"
>
<input
type="text"
onChange={handleValueChange}
disabled={shouldDisable}
placeholder="Say something"
className="w-full focus:outline-none focus:placeholder-gray-400 text-gray-600 placeholder-gray-600 pl-2 pr-2 bg-gray-200 rounded-l-md py-1"
ref={messageInputRef}
autoFocus
/>
<div className="items-center inset-y-0 flex">
<button
disabled={shouldDisable}
type="submit"
className="inline-flex items-center justify-center rounded-r-md px-3 py-1 transition duration-500 ease-in-out text-white bg-blue-500 hover:bg-blue-400 focus:outline-none disabled:bg-gray-400 disabled:cursor-not-allowed"
>
<path d="M10.894 2.553a1 1 0 00-1.788 0l-7 14a1 1 0 001.169 1.409l5-1.429A1 1 0 009 15.571V11a1 1 0 112 0v4.571a1 1 0 00.725.962l5 1.428a1 1 0 001.17-1.408l-7-14z"></path>
</svg>
</button>
</div>
</form>
Send
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 20 20"
fill="currentColor"
className="h-6 w-6 ml-2 transform rotate-90"
>
<path d="M10.894 2.553a1 1 0 00-1.788 0l-7 14a1 1 0 001.169 1.409l5-1.429A1 1 0 009 15.571V11a1 1 0 112 0v4.571a1 1 0 00.725.962l5 1.428a1 1 0 001.17-1.408l-7-14z"></path>
</svg>
</button>
</div>
</form>
</div>
);
};
48 changes: 48 additions & 0 deletions demo/src/components/ReactionComponent/ReactionComponent.tsx
Original file line number Diff line number Diff line change
@@ -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';

interface ReactionComponentProps {}

export const ReactionComponent: FC<ReactionComponentProps> = () => {
const [isConnected, setIsConnected] = useState(true);
const { currentStatus } = useChatConnection();
const [roomReactions, setRoomReactions] = useState<Reaction[]>([]);
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 (
<div>
<div>
<ReactionInput
reactions={[]}
onSend={sendReaction}
disabled={!isConnected}
></ReactionInput>
</div>
<div>
Received reactions:{' '}
{roomReactions.map((r, idx) => (
<span key={idx}>{r.type}</span>
))}{' '}
</div>
</div>
);
};
1 change: 1 addition & 0 deletions demo/src/components/ReactionComponent/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export { ReactionComponent } from './ReactionComponent.tsx';
Loading

0 comments on commit 6a9b850

Please sign in to comment.