diff --git a/web/chat/chat-constants.js b/web/chat/chat-constants.js --- a/web/chat/chat-constants.js +++ b/web/chat/chat-constants.js @@ -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, @@ -25,3 +30,26 @@ 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; diff --git a/web/chat/chat-input-bar.css b/web/chat/chat-input-bar.css --- a/web/chat/chat-input-bar.css +++ b/web/chat/chat-input-bar.css @@ -13,6 +13,7 @@ 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; diff --git a/web/chat/chat-input-text-area.react.js b/web/chat/chat-input-text-area.react.js --- a/web/chat/chat-input-text-area.react.js +++ b/web/chat/chat-input-text-area.react.js @@ -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 = { @@ -12,6 +13,7 @@ +currentText: string, +setCurrentText: (text: string) => void, +onChangePosition: () => void, + +maxHeight?: number, }; const ChatInputTextArea: React.ComponentType = React.memo( @@ -23,6 +25,7 @@ send, setCurrentText, onChangePosition, + maxHeight = defaultMaxTextAreaHeight, } = props; const textareaRef = React.useRef(null); @@ -53,11 +56,11 @@ 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(); diff --git a/web/chat/chat-message-list.css b/web/chat/chat-message-list.css --- a/web/chat/chat-message-list.css +++ b/web/chat/chat-message-list.css @@ -18,6 +18,9 @@ div.mirroredMessageContainer > div { transform: scaleY(-1); } +div.disableAnchor { + overflow-anchor: none; +} div.message { display: flex; diff --git a/web/chat/chat-message-list.react.js b/web/chat/chat-message-list.react.js --- a/web/chat/chat-message-list.react.js +++ b/web/chat/chat-message-list.react.js @@ -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 { @@ -28,7 +29,9 @@ 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'; @@ -43,6 +46,10 @@ 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, }; @@ -64,18 +71,37 @@ +clearTooltip: () => mixed, +oldestMessageServerID: ?string, +isEditState: boolean, + +addScrollToMessageListener: ScrollToMessageCallback => mixed, + +removeScrollToMessageListener: ScrollToMessageCallback => mixed, }; type Snapshot = { +scrollTop: number, +scrollHeight: number, }; -class ChatMessageList extends React.PureComponent { + +type State = { + +scrollingEndCallback: ?() => mixed, +}; + +class ChatMessageList extends React.PureComponent { 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) { @@ -178,8 +204,106 @@ ); }; + 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
; } @@ -192,6 +316,8 @@ } const messageContainerStyle = classNames({ + [css.disableAnchor]: + this.state.scrollingEndCallback !== null || isEditState, [css.messageContainer]: true, [css.mirroredMessageContainer]: !supportsReverseFlex, }); @@ -221,8 +347,16 @@ } 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; @@ -313,7 +447,11 @@ const oldestMessageServerID = useOldestMessageServerID(threadInfo.id); - const { editState } = useEditModalContext(); + const { + editState, + addScrollToMessageListener, + removeScrollToMessageListener, + } = useEditModalContext(); const isEditState = editState !== null; return ( @@ -330,6 +468,8 @@ clearTooltip={clearTooltip} oldestMessageServerID={oldestMessageServerID} isEditState={isEditState} + addScrollToMessageListener={addScrollToMessageListener} + removeScrollToMessageListener={removeScrollToMessageListener} /> ); diff --git a/web/chat/composed-message.react.js b/web/chat/composed-message.react.js --- a/web/chat/composed-message.react.js +++ b/web/chat/composed-message.react.js @@ -14,6 +14,7 @@ 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'; @@ -23,6 +24,8 @@ 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, @@ -188,7 +191,11 @@ onMouseLeave={this.props.onMouseLeave} > {pinIcon} -
+
{this.props.children}
diff --git a/web/chat/edit-message-provider.js b/web/chat/edit-message-provider.js --- a/web/chat/edit-message-provider.js +++ b/web/chat/edit-message-provider.js @@ -22,8 +22,14 @@ +editedMessageDraft: ?string, +isError: boolean, +position?: ModalPosition, + +maxHeight: number, }; +export type ScrollToMessageCallback = ( + composedMessageID: string, + callback: (maxHeight: number) => void, +) => void; + type EditModalContextType = { +renderEditModal: (params: EditState) => void, +clearEditModal: () => void, @@ -31,6 +37,9 @@ +setDraft: string => void, +setError: boolean => void, +updatePosition: ModalPosition => void, + +scrollToMessage: ScrollToMessageCallback, + +addScrollToMessageListener: ScrollToMessageCallback => void, + +removeScrollToMessageListener: ScrollToMessageCallback => void, }; const EditModalContext: React.Context = @@ -41,6 +50,9 @@ setDraft: () => {}, setError: () => {}, updatePosition: () => {}, + scrollToMessage: () => {}, + addScrollToMessageListener: () => {}, + removeScrollToMessageListener: () => {}, }); type Props = { @@ -51,6 +63,9 @@ const [editState, setEditState] = React.useState(null); + const [scrollToMessageCallbacks, setScrollToMessageCallbacks] = + React.useState>([]); + const clearEditModal = React.useCallback(() => { setEditState(null); }, []); @@ -118,6 +133,36 @@ [editState, setEditState], ); + const scrollToMessage: ScrollToMessageCallback = React.useCallback( + (messageKey: string, callback: (maxHeight: number) => void) => { + scrollToMessageCallbacks.forEach((callback2: ScrollToMessageCallback) => + callback2(messageKey, callback), + ); + }, + [scrollToMessageCallbacks], + ); + + const addScrollToMessageListener = React.useCallback( + (callback: ScrollToMessageCallback): void => { + setScrollToMessageCallbacks(prevScrollToMessageCallbacks => [ + ...prevScrollToMessageCallbacks, + callback, + ]); + }, + [], + ); + + const removeScrollToMessageListener = React.useCallback( + (callback: ScrollToMessageCallback) => { + setScrollToMessageCallbacks(prevScrollToMessageCallbacks => + prevScrollToMessageCallbacks.filter( + candidate => candidate !== callback, + ), + ); + }, + [], + ); + const value = React.useMemo( () => ({ renderEditModal, @@ -126,6 +171,9 @@ setDraft, setError, updatePosition, + scrollToMessage, + addScrollToMessageListener, + removeScrollToMessageListener, }), [ renderEditModal, @@ -134,6 +182,9 @@ setDraft, setError, updatePosition, + scrollToMessage, + addScrollToMessageListener, + removeScrollToMessageListener, ], ); diff --git a/web/chat/edit-text-message.css b/web/chat/edit-text-message.css --- a/web/chat/edit-text-message.css +++ b/web/chat/edit-text-message.css @@ -1,4 +1,5 @@ .editMessage { + /* Related to editBoxHeight in the `edit-text-message` component */ padding: 10px; background-color: var(--modal-bg); border-radius: 8px; @@ -9,6 +10,7 @@ } .bottomRow { + /* Related to editBoxHeight in the `edit-text-message` component */ padding-top: 10px; display: flex; align-items: center; diff --git a/web/chat/edit-text-message.react.js b/web/chat/edit-text-message.react.js --- a/web/chat/edit-text-message.react.js +++ b/web/chat/edit-text-message.react.js @@ -10,7 +10,7 @@ import { trimMessage } from 'lib/shared/message-utils.js'; import { type ThreadInfo } from 'lib/types/thread-types.js'; -import cssInputBar from './chat-input-bar.css'; +import { editBoxBottomRowHeight } from './chat-constants.js'; import ChatInputTextArea from './chat-input-text-area.react.js'; import ComposedMessage from './composed-message.react.js'; import { useEditModalContext } from './edit-message-provider.js'; @@ -28,6 +28,8 @@ backgroundColor: 'transparent', }; +const bottomRowStyle = { height: editBoxBottomRowHeight }; + function EditTextMessage(props: Props): React.Node { const { background, threadInfo, item } = props; const { editState, clearEditModal, setDraft, setError, updatePosition } = @@ -138,18 +140,19 @@ [css.backgroundEditMessage]: background, }); + const maxTextAreaHeight = editState?.maxHeight; + return (
-
- -
-
+ +
{editFailed}