Skip to content

Commit

Permalink
Demo: Refactor demo app to reduce single component complexity.
Browse files Browse the repository at this point in the history
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.
  • Loading branch information
splindsay-92 committed Jan 21, 2025
1 parent 87ab7e1 commit bcf112c
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 bcf112c

Please sign in to comment.