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 @@ -12,8 +12,11 @@ +currentText: string, +setCurrentText: (text: string) => void, +onChangePosition: () => void, + +maxHeight?: number, }; +export const defaultMaxHeight = 150; + const ChatInputTextArea: React.ComponentType = React.memo( function ChatInputTextArea(props: Props) { const { @@ -23,6 +26,7 @@ send, setCurrentText, onChangePosition, + maxHeight = defaultMaxHeight, } = props; const textareaRef = React.useRef(null); @@ -53,11 +57,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.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 @@ -28,8 +28,11 @@ useDispatchActionPromise, } from 'lib/utils/action-utils.js'; +import { defaultMaxHeight } from './chat-input-text-area.react.js'; import css from './chat-message-list.css'; +import type { ScrollToMessageCallback } from './edit-message-provider.js'; import { useEditModalContext } from './edit-message-provider.js'; +import { editBoxHeight, editBoxTopMargin } from './edit-text-message.react.js'; import { MessageListContext } from './message-list-types.js'; import Message from './message.react.js'; import RelationshipPrompt from './relationship-prompt/relationship-prompt.js'; @@ -64,18 +67,39 @@ +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 = { + +scrollTimeoutID: ?TimeoutID, + +scrollingEndCallback: ?() => mixed, +}; + +class ChatMessageList extends React.PureComponent { container: ?HTMLDivElement; messageContainer: ?HTMLDivElement; loadingFromScroll = false; + constructor(props: Props) { + super(props); + this.state = { + scrollTimeoutID: null, + scrollingEndCallback: null, + }; + } + componentDidMount() { this.scrollToBottom(); + this.props.addScrollToMessageListener(this.scrollToMessage); + } + + componentWillUnmount() { + this.props.removeScrollToMessageListener(this.scrollToMessage); } getSnapshotBeforeUpdate(prevProps: Props) { @@ -178,6 +202,98 @@ ); }; + scrollingEndCallbackWrapper = ( + messageID: string, + callback: (maxHeight: number) => mixed, + ): (() => mixed) => { + return () => { + const maxHeight = this.getMaxEditTextAreaHeight(messageID); + callback(maxHeight); + }; + }; + + scrollToMessage = ( + messageID: string, + callback: (maxHeight: number) => mixed, + ) => { + const element = document.getElementById(messageID); + if (!element) { + return; + } + const scrollingEndCallback = this.scrollingEndCallbackWrapper( + messageID, + callback, + ); + if (!this.willMessageEditWindowOverflow(messageID)) { + 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.handleScrollEnd(); + }, + ); + }; + + getMaxEditTextAreaHeight = (messageID: string): number => { + const { messageContainer } = this; + if (!messageContainer) { + return defaultMaxHeight; + } + const messageElement = document.getElementById(messageID); + if (!messageElement) { + console.log(`couldn't find the message element`); + return defaultMaxHeight; + } + + 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(messageID: string) { + const { messageContainer } = this; + if (!messageContainer) { + return false; + } + const messageElement = document.getElementById(messageID); + if (!messageElement) { + console.log(`couldn't find the message element`); + return false; + } + + const msgPos = messageElement.getBoundingClientRect(); + const containerPos = messageContainer.getBoundingClientRect(); + + const messageHeight = msgPos.height; + const offset = Math.max( + 0, + defaultMaxHeight + editBoxHeight - messageHeight, + ); + + const messageTop = msgPos.top - offset; + const messageBottom = msgPos.bottom; + const containerTop = containerPos.top; + const containerBottom = containerPos.bottom; + + return messageBottom > containerBottom || messageTop < containerTop; + } + render() { const { messageListData, threadInfo, inputState } = this.props; if (!messageListData) { @@ -221,6 +337,20 @@ } this.props.clearTooltip(); this.possiblyLoadMoreMessages(); + this.handleScrollEnd(); + }; + + handleScrollEnd = () => { + if (this.state.scrollTimeoutID) { + clearTimeout(this.state.scrollTimeoutID); + } + const scrollTimeoutID = setTimeout(() => { + if (this.state.scrollingEndCallback) { + this.state.scrollingEndCallback(); + } + this.setState({ scrollingEndCallback: null }); + }, 100); + this.setState({ scrollTimeoutID }); }; async possiblyLoadMoreMessages() { @@ -313,7 +443,11 @@ const oldestMessageServerID = useOldestMessageServerID(threadInfo.id); - const { editState } = useEditModalContext(); + const { + editState, + addScrollToMessageListener, + removeScrollToMessageListener, + } = useEditModalContext(); const isEditState = editState !== null; return ( @@ -330,6 +464,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 @@ -11,6 +11,7 @@ import { useStringForUser } from 'lib/hooks/ens-cache.js'; import { type ChatMessageInfoItem } from 'lib/selectors/chat-selectors.js'; import { getMessageLabel } from 'lib/shared/edit-messages-utils.js'; +import { messageKey } from 'lib/shared/message-utils.js'; import { assertComposableMessageType } from 'lib/types/message-types.js'; import { type ThreadInfo } from 'lib/types/thread-types.js'; @@ -188,7 +189,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 = ( + messageKey: 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,31 @@ [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([...scrollToMessageCallbacks, callback]); + }, + [scrollToMessageCallbacks], + ); + + const removeScrollToMessageListener = React.useCallback( + (callback: ScrollToMessageCallback) => { + setScrollToMessageCallbacks( + scrollToMessageCallbacks.filter(candidate => candidate !== callback), + ); + }, + [scrollToMessageCallbacks], + ); + const value = React.useMemo( () => ({ renderEditModal, @@ -126,6 +166,9 @@ setDraft, setError, updatePosition, + scrollToMessage, + addScrollToMessageListener, + removeScrollToMessageListener, }), [ renderEditModal, @@ -134,6 +177,9 @@ setDraft, setError, updatePosition, + scrollToMessage, + addScrollToMessageListener, + removeScrollToMessageListener, ], ); 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 @@ -28,6 +28,9 @@ backgroundColor: 'transparent', }; +export const editBoxTopMargin = 10; +export const editBoxHeight = 84; + function EditTextMessage(props: Props): React.Node { const { background, threadInfo, item } = props; const { editState, clearEditModal, setDraft, setError, updatePosition } = @@ -138,6 +141,8 @@ [css.backgroundEditMessage]: background, }); + const maxTextAreaHeight = editState?.maxHeight; + return (
@@ -147,6 +152,7 @@ setCurrentText={setDraft} onChangePosition={updateDimensions} send={checkAndEdit} + maxHeight={maxTextAreaHeight} />
diff --git a/web/utils/tooltip-action-utils.js b/web/utils/tooltip-action-utils.js --- a/web/utils/tooltip-action-utils.js +++ b/web/utils/tooltip-action-utils.js @@ -7,7 +7,7 @@ import { useModalContext } from 'lib/components/modal-provider.react.js'; import type { ChatMessageInfoItem } from 'lib/selectors/chat-selectors.js'; import { useCanEditMessage } from 'lib/shared/edit-messages-utils.js'; -import { createMessageReply } from 'lib/shared/message-utils.js'; +import { createMessageReply, messageKey } from 'lib/shared/message-utils.js'; import { useCanCreateReactionFromMessage } from 'lib/shared/reaction-utils.js'; import { threadHasPermission, @@ -233,7 +233,7 @@ const { messageInfo } = item; const canEditMessage = useCanEditMessage(threadInfo, messageInfo); - const { renderEditModal } = useEditModalContext(); + const { renderEditModal, scrollToMessage } = useEditModalContext(); const { clearTooltip } = useTooltipContext(); return React.useMemo(() => { @@ -242,13 +242,16 @@ } const buttonContent = ; const onClickEdit = () => { + const callback = (maxHeight: number) => + renderEditModal({ + messageInfo: item, + threadInfo, + isError: false, + editedMessageDraft: messageInfo.text, + maxHeight: maxHeight, + }); clearTooltip(); - renderEditModal({ - messageInfo: item, - threadInfo, - isError: false, - editedMessageDraft: messageInfo.text, - }); + scrollToMessage(messageKey(messageInfo), callback); }; return { actionButtonContent: buttonContent, @@ -259,8 +262,9 @@ canEditMessage, clearTooltip, item, - messageInfo.text, + messageInfo, renderEditModal, + scrollToMessage, threadInfo, ]); }