Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Demo: Refactor demo app to reduce single component complexity. #433

Open
wants to merge 2 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion demo/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -55,7 +55,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
179 changes: 179 additions & 0 deletions demo/src/components/ChatBoxComponent/ChatBoxComponent.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,179 @@
import { MessageComponent } from '../MessageComponent';
import { useChatClient, useMessages } from '@ably/chat/react';
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']) => {
chatClient.logger.debug('backfilling previous messages');
if (getPreviousMessages) {
getPreviousMessages({ limit: 50 })
.then((result: PaginatedResult<Message>) => {
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<HTMLDivElement | null>(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 (
<div
id="messages"
className="chat-window"
>
{loading && <div className="text-center m-auto">loading...</div>}
{!loading && (
<>
{messages.map((msg) => (
<MessageComponent
key={msg.serial}
self={msg.clientId === clientId}
message={msg}
onMessageDelete={onDeleteMessage}
onMessageUpdate={onUpdateMessage}
></MessageComponent>
))}
<div ref={messagesEndRef} />
</>
)}
</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/react';
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/react';

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
Loading