Skip to content

Commit

Permalink
[web] Scrolling to the edited message when it overflows
Browse files Browse the repository at this point in the history
Summary:
Currently, if the user wants to edit a message which overflows (near the top or bottom of the `ChatMessageList`), the edit box may also overflow.
This diff addresses that, if the message edit box may overflow, we first scroll so that the edited message is in the center, and then open the edit box.

Test Plan:
Run the app on Chrome, and checked:
- entered edit mode for a message which overflows, checked if the browser scrolled to it, and opened the edit box,
- entered edit mode for a message in the middle, checked if the edit box was opened instantly and without scrolling,
- added new lines for the edited message, and checked if it still fits in the container.

Videos:
{F564723}

{F564724}

Reviewers: michal, inka, ashoat

Reviewed By: inka, ashoat

Subscribers: kamil, ashoat, tomek, atul

Differential Revision: https://phab.comm.dev/D8027
  • Loading branch information
kosmydel committed Jul 13, 2023
1 parent 98ea283 commit 1862947
Show file tree
Hide file tree
Showing 11 changed files with 273 additions and 25 deletions.
28 changes: 28 additions & 0 deletions web/chat/chat-constants.js
Original file line number Diff line number Diff line change
@@ -1,5 +1,10 @@
// @flow

import { messageKey } from 'lib/shared/message-utils.js';
import type { MessageInfo } from 'lib/types/message-types.js';

import type { ComposedMessageID } from './composed-message.react.js';

export const tooltipStyle = {
paddingLeft: 5,
paddingRight: 5,
Expand All @@ -25,3 +30,26 @@ export const typeaheadStyle = {
tooltipTopOffset: 4,
rowHeight: 40,
};

export const getComposedMessageID = (
messageInfo: MessageInfo,
): ComposedMessageID => {
return `ComposedMessageBox-${messageKey(messageInfo)}`;
};

export const defaultMaxTextAreaHeight = 150;

// The editBoxBottomRowHeight is the height of the bottom row in the edit box
// which is the height of the buttons in the bottom row.
export const editBoxBottomRowHeight = 22;

// The editBoxHeight is a height of the all elements of the edit box
// except for the textarea.
// It consists of:
// - 2 * 10px: .editMessage padding (edit-text-message.css)
// - 10px: .bottomRow padding between the bottom row buttons
// and the textarea (edit-text-message.css)
// - 2 * 8px: .inputBarTextInput padding (chat-input-bar.css)
// - 22px: height of the bottom row in the edit box (explained above)
// - textarea height which is NOT included here
export const editBoxHeight: number = 3 * 10 + 2 * 8 + editBoxBottomRowHeight;
1 change: 1 addition & 0 deletions web/chat/chat-input-bar.css
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ div.inputBarTextInput {
display: flex;
background: var(--text-input-bg);
border-radius: 8px;
/* Related to editBoxHeight in the `edit-text-message` component */
padding: 8px;
align-items: center;
flex-grow: 1;
Expand Down
7 changes: 5 additions & 2 deletions web/chat/chat-input-text-area.react.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
import invariant from 'invariant';
import * as React from 'react';

import { defaultMaxTextAreaHeight } from './chat-constants.js';
import css from './chat-input-bar.css';

type Props = {
Expand All @@ -12,6 +13,7 @@ type Props = {
+currentText: string,
+setCurrentText: (text: string) => void,
+onChangePosition: () => void,
+maxHeight?: number,
};

const ChatInputTextArea: React.ComponentType<Props> = React.memo<Props>(
Expand All @@ -23,6 +25,7 @@ const ChatInputTextArea: React.ComponentType<Props> = React.memo<Props>(
send,
setCurrentText,
onChangePosition,
maxHeight = defaultMaxTextAreaHeight,
} = props;
const textareaRef = React.useRef(null);

Expand Down Expand Up @@ -53,11 +56,11 @@ const ChatInputTextArea: React.ComponentType<Props> = React.memo<Props>(
const textarea = textareaRef.current;
if (textarea) {
textarea.style.height = 'auto';
const newHeight = Math.min(textarea.scrollHeight, 150);
const newHeight = Math.min(textarea.scrollHeight, maxHeight);
textarea.style.height = `${newHeight}px`;
}
onChangePosition();
}, [onChangePosition]);
}, [maxHeight, onChangePosition]);

React.useEffect(() => {
focusAndUpdateText();
Expand Down
3 changes: 3 additions & 0 deletions web/chat/chat-message-list.css
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,9 @@ div.mirroredMessageContainer {
div.mirroredMessageContainer > div {
transform: scaleY(-1);
}
div.disableAnchor {
overflow-anchor: none;
}

div.message {
display: flex;
Expand Down
146 changes: 143 additions & 3 deletions web/chat/chat-message-list.react.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
import classNames from 'classnames';
import { detect as detectBrowser } from 'detect-browser';
import invariant from 'invariant';
import _debounce from 'lodash/debounce.js';
import * as React from 'react';

import {
Expand All @@ -28,7 +29,9 @@ import {
useDispatchActionPromise,
} from 'lib/utils/action-utils.js';

import { editBoxHeight, defaultMaxTextAreaHeight } from './chat-constants.js';
import css from './chat-message-list.css';
import type { ScrollToMessageCallback } from './edit-message-provider.js';
import { useEditModalContext } from './edit-message-provider.js';
import { MessageListContext } from './message-list-types.js';
import Message from './message.react.js';
Expand All @@ -43,6 +46,10 @@ const browser = detectBrowser();
const supportsReverseFlex =
!browser || browser.name !== 'firefox' || parseInt(browser.version) >= 81;

// Margin between the top of the maximum height edit box
// and the top of the container
const editBoxTopMargin = 10;

type BaseProps = {
+threadInfo: ThreadInfo,
};
Expand All @@ -64,18 +71,37 @@ type Props = {
+clearTooltip: () => mixed,
+oldestMessageServerID: ?string,
+isEditState: boolean,
+addScrollToMessageListener: ScrollToMessageCallback => mixed,
+removeScrollToMessageListener: ScrollToMessageCallback => mixed,
};
type Snapshot = {
+scrollTop: number,
+scrollHeight: number,
};
class ChatMessageList extends React.PureComponent<Props> {

type State = {
+scrollingEndCallback: ?() => mixed,
};

class ChatMessageList extends React.PureComponent<Props, State> {
container: ?HTMLDivElement;
messageContainer: ?HTMLDivElement;
loadingFromScroll = false;

constructor(props: Props) {
super(props);
this.state = {
scrollingEndCallback: null,
};
}

componentDidMount() {
this.scrollToBottom();
this.props.addScrollToMessageListener(this.scrollToMessage);
}

componentWillUnmount() {
this.props.removeScrollToMessageListener(this.scrollToMessage);
}

getSnapshotBeforeUpdate(prevProps: Props) {
Expand Down Expand Up @@ -178,8 +204,106 @@ class ChatMessageList extends React.PureComponent<Props> {
);
};

scrollingEndCallbackWrapper = (
composedMessageID: string,
callback: (maxHeight: number) => mixed,
): (() => mixed) => {
return () => {
const maxHeight = this.getMaxEditTextAreaHeight(composedMessageID);
callback(maxHeight);
};
};

scrollToMessage = (
composedMessageID: string,
callback: (maxHeight: number) => mixed,
) => {
const element = document.getElementById(composedMessageID);
if (!element) {
return;
}
const scrollingEndCallback = this.scrollingEndCallbackWrapper(
composedMessageID,
callback,
);
if (!this.willMessageEditWindowOverflow(composedMessageID)) {
scrollingEndCallback();
return;
}
this.setState(
{
scrollingEndCallback,
},
() => {
element.scrollIntoView({ behavior: 'smooth', block: 'center' });
// It covers the case when browser decide not to scroll to the message
// because it's already in the view.
// In this case, the 'scroll' event won't be triggered,
// so we need to call the callback manually.
this.debounceEditModeAfterScrollToMessage();
},
);
};

getMaxEditTextAreaHeight = (composedMessageID: string): number => {
const { messageContainer } = this;
if (!messageContainer) {
return defaultMaxTextAreaHeight;
}
const messageElement = document.getElementById(composedMessageID);
if (!messageElement) {
console.log(`couldn't find the message element`);
return defaultMaxTextAreaHeight;
}

const msgPos = messageElement.getBoundingClientRect();
const containerPos = messageContainer.getBoundingClientRect();

const messageBottom = msgPos.bottom;
const containerTop = containerPos.top;

const maxHeight =
messageBottom - containerTop - editBoxHeight - editBoxTopMargin;

return maxHeight;
};

willMessageEditWindowOverflow(composedMessageID: string) {
const { messageContainer } = this;
if (!messageContainer) {
return false;
}
const messageElement = document.getElementById(composedMessageID);
if (!messageElement) {
console.log(`couldn't find the message element`);
return false;
}

const msgPos = messageElement.getBoundingClientRect();
const containerPos = messageContainer.getBoundingClientRect();
const containerTop = containerPos.top;
const containerBottom = containerPos.bottom;

const availableTextAreaHeight =
(containerBottom - containerTop) / 2 - editBoxHeight;
const messageHeight = msgPos.height;
const expectedMinimumHeight = Math.min(
defaultMaxTextAreaHeight,
availableTextAreaHeight,
);
const offset = Math.max(
0,
expectedMinimumHeight + editBoxHeight + editBoxTopMargin - messageHeight,
);

const messageTop = msgPos.top - offset;
const messageBottom = msgPos.bottom;

return messageBottom > containerBottom || messageTop < containerTop;
}

render() {
const { messageListData, threadInfo, inputState } = this.props;
const { messageListData, threadInfo, inputState, isEditState } = this.props;
if (!messageListData) {
return <div className={css.container} />;
}
Expand All @@ -192,6 +316,8 @@ class ChatMessageList extends React.PureComponent<Props> {
}

const messageContainerStyle = classNames({
[css.disableAnchor]:
this.state.scrollingEndCallback !== null || isEditState,
[css.messageContainer]: true,
[css.mirroredMessageContainer]: !supportsReverseFlex,
});
Expand Down Expand Up @@ -221,8 +347,16 @@ class ChatMessageList extends React.PureComponent<Props> {
}
this.props.clearTooltip();
this.possiblyLoadMoreMessages();
this.debounceEditModeAfterScrollToMessage();
};

debounceEditModeAfterScrollToMessage = _debounce(() => {
if (this.state.scrollingEndCallback) {
this.state.scrollingEndCallback();
}
this.setState({ scrollingEndCallback: null });
}, 100);

async possiblyLoadMoreMessages() {
if (!this.messageContainer) {
return;
Expand Down Expand Up @@ -313,7 +447,11 @@ const ConnectedChatMessageList: React.ComponentType<BaseProps> =

const oldestMessageServerID = useOldestMessageServerID(threadInfo.id);

const { editState } = useEditModalContext();
const {
editState,
addScrollToMessageListener,
removeScrollToMessageListener,
} = useEditModalContext();
const isEditState = editState !== null;

return (
Expand All @@ -330,6 +468,8 @@ const ConnectedChatMessageList: React.ComponentType<BaseProps> =
clearTooltip={clearTooltip}
oldestMessageServerID={oldestMessageServerID}
isEditState={isEditState}
addScrollToMessageListener={addScrollToMessageListener}
removeScrollToMessageListener={removeScrollToMessageListener}
/>
</MessageListContext.Provider>
);
Expand Down
9 changes: 8 additions & 1 deletion web/chat/composed-message.react.js
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import { getMessageLabel } from 'lib/shared/edit-messages-utils.js';
import { assertComposableMessageType } from 'lib/types/message-types.js';
import { type ThreadInfo } from 'lib/types/thread-types.js';

import { getComposedMessageID } from './chat-constants.js';
import css from './chat-message-list.css';
import FailedSend from './failed-send.react.js';
import InlineEngagement from './inline-engagement.react.js';
Expand All @@ -23,6 +24,8 @@ import { type InputState, InputStateContext } from '../input/input-state.js';
import { useMessageTooltip } from '../utils/tooltip-action-utils.js';
import { tooltipPositions } from '../utils/tooltip-utils.js';

export type ComposedMessageID = string;

const availableTooltipPositionsForViewerMessage = [
tooltipPositions.LEFT,
tooltipPositions.LEFT_BOTTOM,
Expand Down Expand Up @@ -188,7 +191,11 @@ class ComposedMessage extends React.PureComponent<Props> {
onMouseLeave={this.props.onMouseLeave}
>
{pinIcon}
<div className={messageBoxClassName} style={messageBoxStyle}>
<div
className={messageBoxClassName}
style={messageBoxStyle}
id={getComposedMessageID(item.messageInfo)}
>
{this.props.children}
</div>
</div>
Expand Down
Loading

0 comments on commit 1862947

Please sign in to comment.