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

feat: Update to scroll behavior and UX tweaks #77

Merged
merged 4 commits into from
Jan 2, 2025
Merged
Show file tree
Hide file tree
Changes from 3 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
59 changes: 41 additions & 18 deletions src/components/ChatBox/ChatBox.scss
Original file line number Diff line number Diff line change
@@ -1,30 +1,53 @@
.scroller {
overflow-y: scroll;
scrollbar-width: thin;
width: 100%;
opacity: 1;
margin-right: 0;
.xpert-chat-scroller {
position: relative;
flex: 1;

.messages-list {
overflow-y: scroll;
scrollbar-width: thin;
position: absolute;
inset: 0;
padding: 1rem 0;
}

&:after {
content: ""; /* Add an empty content area after the chat messages */
display: block;
height: 0;
clear: both;
}
}

.loading {
font-size: 13px;
padding-left: 10px;
.loading {
font-size: 13px;
padding-left: 10px;

&:after {
overflow: hidden;
display: inline-block;
vertical-align: bottom;
-webkit-animation: ellipsis steps(4,end) 900ms infinite;
animation: ellipsis steps(4,end) 900ms infinite;
content: "\2026"; /* ascii code for the ellipsis character */
width: 0px;
&:after {
overflow: hidden;
display: inline-block;
vertical-align: bottom;
-webkit-animation: ellipsis steps(4,end) 900ms infinite;
animation: ellipsis steps(4,end) 900ms infinite;
content: "\2026"; /* ascii code for the ellipsis character */
width: 0px;
}
}

.separator {
position: absolute;
z-index: 100;
height: 5px;
padding: 5px;
opacity: 0.3;

&--top {
inset: 0 0 auto 0;
background: linear-gradient(180deg, rgba(0, 0, 0, 0.35), transparent);
}

&--bottom {
inset: auto 0 0 0;
background: linear-gradient(0, rgba(0, 0, 0, 0.35), transparent);
}
}
}

Expand Down
52 changes: 31 additions & 21 deletions src/components/ChatBox/index.jsx
Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@
import React from 'react';
import React, { useEffect, useRef } from 'react';
import { useSelector } from 'react-redux';
import PropTypes from 'prop-types';
import Message from '../Message';
import './ChatBox.scss';
import MessageDivider from '../MessageDivider';
import { scrollToBottom } from '../../utils/scroll';

function isToday(date) {
const today = new Date();
Expand All @@ -15,33 +15,43 @@ function isToday(date) {
}

// container for all of the messages
const ChatBox = ({ chatboxContainerRef }) => {
const ChatBox = () => {
const firstRender = useRef(true);
const messageContainerRef = useRef();

const { messageList, apiIsLoading } = useSelector(state => state.learningAssistant);
const messagesBeforeToday = messageList.filter((m) => (!isToday(new Date(m.timestamp))));
const messagesToday = messageList.filter((m) => (isToday(new Date(m.timestamp))));

// message divider should not display if no messages or if all messages sent today.
useEffect(() => {
if (firstRender.current) {
scrollToBottom(messageContainerRef);
firstRender.current = false;
return;
}

scrollToBottom(messageContainerRef, true);
}, [messageList.length]);

return (
<div ref={chatboxContainerRef} className="flex-grow-1 scroller d-flex flex-column pb-4">
{messagesBeforeToday.map(({ role, content, timestamp }) => (
<Message key={timestamp} variant={role} message={content} />
))}
{(messageList.length !== 0 && messagesBeforeToday.length !== 0) && (<MessageDivider text="Today" />)}
{messagesToday.map(({ role, content, timestamp }) => (
<Message key={timestamp} variant={role} message={content} />
))}
{apiIsLoading && (
<div className="xpert-chat-scroller">
<div className="messages-list d-flex flex-column" ref={messageContainerRef} data-testid="messages-container">
{messagesBeforeToday.map(({ role, content, timestamp }) => (
<Message key={timestamp} variant={role} message={content} />
))}
{/* Message divider should not display if no messages or if all messages sent today. */}
{(messageList.length !== 0 && messagesBeforeToday.length !== 0) && (<MessageDivider text="Today" />)}
{messagesToday.map(({ role, content, timestamp }) => (
<Message key={timestamp} variant={role} message={content} />
))}
{apiIsLoading && (
<div className="loading">Xpert is thinking</div>
)}
)}
</div>
<span className="separator separator--top" />
<span className="separator separator--bottom" />
</div>
);
};

ChatBox.propTypes = {
chatboxContainerRef: PropTypes.oneOfType([
PropTypes.func,
PropTypes.shape({ current: PropTypes.instanceOf(Element) }),
]).isRequired,
};

export default ChatBox;
88 changes: 73 additions & 15 deletions src/components/ChatBox/index.test.jsx
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
import React from 'react';
import { screen, act } from '@testing-library/react';

import { render as renderComponent } from '../../utils/utils.test';
import { initialState } from '../../data/slice';
import { initialState, setMessageList } from '../../data/slice';
import { scrollToBottom } from '../../utils/scroll';

import ChatBox from '.';

Expand All @@ -16,6 +16,8 @@ const defaultProps = {
chatboxContainerRef: jest.fn(),
};

jest.mock('../../utils/scroll');

const render = async (props = {}, sliceState = {}) => {
const componentProps = {
...defaultProps,
Expand All @@ -30,27 +32,34 @@ const render = async (props = {}, sliceState = {}) => {
},
},
};
return act(async () => renderComponent(
<ChatBox {...componentProps} />,
initState,
));

let handlers;
await act(async () => {
handlers = renderComponent(
<ChatBox {...componentProps} />,
initState,
);
});

return { ...handlers, initState, componentProps };
};

describe('<ChatBox />', () => {
beforeEach(() => {
afterEach(() => {
jest.resetAllMocks();
});
it('message divider does not appear when no messages', () => {

it('message divider does not appear when no messages', async () => {
const messageList = [];
const sliceState = {
messageList,
};
render(undefined, sliceState);
await render(undefined, sliceState);

expect(screen.queryByText('Today')).not.toBeInTheDocument();
});

it('message divider does not appear when all messages from today', () => {
it('message divider does not appear when all messages from today', async () => {
const date = new Date();
const messageList = [
{ role: 'user', content: 'hi', timestamp: date - 60 },
Expand All @@ -59,14 +68,14 @@ describe('<ChatBox />', () => {
const sliceState = {
messageList,
};
render(undefined, sliceState);
await render(undefined, sliceState);

expect(screen.queryByText('hi')).toBeInTheDocument();
expect(screen.queryByText('hello')).toBeInTheDocument();
expect(screen.queryByText('Today')).not.toBeInTheDocument();
});

it('message divider shows when all messages from before today', () => {
it('message divider shows when all messages from before today', async () => {
const date = new Date();
const messageList = [
{ role: 'user', content: 'hi', timestamp: date.setDate(date.getDate() - 1) },
Expand All @@ -75,14 +84,14 @@ describe('<ChatBox />', () => {
const sliceState = {
messageList,
};
render(undefined, sliceState);
await render(undefined, sliceState);

expect(screen.queryByText('hi')).toBeInTheDocument();
expect(screen.queryByText('hello')).toBeInTheDocument();
expect(screen.queryByText('Today')).toBeInTheDocument();
});

it('correctly divides old and new messages', () => {
it('correctly divides old and new messages', async () => {
const today = new Date();
const messageList = [
{ role: 'user', content: 'Today yesterday', timestamp: today.setDate(today.getDate() - 1) },
Expand All @@ -91,12 +100,61 @@ describe('<ChatBox />', () => {
const sliceState = {
messageList,
};
render(undefined, sliceState);
await render(undefined, sliceState);

const results = screen.getAllByText('Today', { exact: false });
expect(results.length).toBe(3);
expect(results[0]).toHaveTextContent('Today yesterday');
expect(results[1]).toHaveTextContent('Today');
expect(results[2]).toHaveTextContent('Today today');
});

it('scrolls to the last comment immediately when rendered', async () => {
const date = new Date();
const messageList = [
{ role: 'user', content: 'hi', timestamp: date - 60 },
{ role: 'user', content: 'hello', timestamp: date },
];
const sliceState = {
messageList,
};

await act(() => render(undefined, sliceState));

const messagesContainer = screen.getByTestId('messages-container');

expect(scrollToBottom).toHaveBeenCalledWith({ current: messagesContainer });
});

it('scrolls to the last comment smoothly when adding messages', async () => {
const date = new Date();
const messageList = [
{ role: 'user', content: 'hi', timestamp: date - 60 },
{ role: 'user', content: 'hello', timestamp: date - 30 },
];
const sliceState = {
messageList,
};

let store;

await act(async () => {
({ store } = await render(undefined, sliceState));
});

const messagesContainer = screen.getByTestId('messages-container');

expect(scrollToBottom).toHaveBeenCalledWith({ current: messagesContainer });

act(() => {
store.dispatch(setMessageList({
messageList: [
...messageList,
{ role: 'user', content: 'New message', timestamp: +date },
],
}));
});

expect(scrollToBottom).toHaveBeenCalledWith({ current: messagesContainer }, true);
});
});
2 changes: 1 addition & 1 deletion src/components/Message/index.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import PropTypes from 'prop-types';

const Message = ({ variant, message }) => (
<div
className={`message ${variant} ${variant === 'user' ? 'align-self-end' : ''} text-left my-1 mx-2 py-2 px-3`}
className={`message ${variant} ${variant === 'user' ? 'align-self-end' : ''} text-left my-1 mx-4 py-2 px-3`}
data-hj-suppress
>
<ReactMarkdown>
Expand Down
11 changes: 11 additions & 0 deletions src/components/MessageForm/MessageForm.scss
Original file line number Diff line number Diff line change
@@ -1,11 +1,22 @@
.message-form {
padding: 0.75rem 1.5rem;

.send-message-input {
.pgn__form-control-floating-label {
color: #ADADAD;
}

input {
border-radius: 1rem;
border: 1px solid #C8C8CC;
}
}

.pgn__form-control-decorator-group {
margin-inline-end: 0;
}

button {
color: #8F8F8F;
rijuma marked this conversation as resolved.
Show resolved Hide resolved
}
}
22 changes: 10 additions & 12 deletions src/components/MessageForm/index.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -61,18 +61,16 @@ const MessageForm = ({ courseId, shouldAutofocus, unitId }) => {

return (
<Form className="message-form w-100" onSubmit={handleSubmitMessage} data-testid="message-form">
<Form.Group>
<Form.Control
data-hj-suppress
disabled={apiIsLoading}
floatingLabel="Write a message"
onChange={handleUpdateCurrentMessage}
trailingElement={getSubmitButton()}
value={currentMessage}
ref={inputRef}
className="send-message-input"
/>
</Form.Group>
<Form.Control
data-hj-suppress
disabled={apiIsLoading}
floatingLabel="Write a message"
onChange={handleUpdateCurrentMessage}
trailingElement={getSubmitButton()}
value={currentMessage}
ref={inputRef}
className="send-message-input"
/>
</Form>
);
};
Expand Down
Loading
Loading