diff --git a/web/chat/chat-message-list-container.css b/web/chat/chat-message-list-container.css new file mode 100644 index 000000000..aa2e34c71 --- /dev/null +++ b/web/chat/chat-message-list-container.css @@ -0,0 +1,12 @@ +div.container { + margin-left: 400px; + height: 100%; + background-color: var(--bg); + display: flex; + flex-direction: column; + box-sizing: border-box; +} +div.activeContainer { + border: 2px solid #5989d6; + margin-left: 402px; +} diff --git a/web/chat/chat-message-list-container.react.js b/web/chat/chat-message-list-container.react.js new file mode 100644 index 000000000..65fdb5e8f --- /dev/null +++ b/web/chat/chat-message-list-container.react.js @@ -0,0 +1,109 @@ +// @flow + +import classNames from 'classnames'; +import invariant from 'invariant'; +import * as React from 'react'; +import { useDrop } from 'react-dnd'; +import { NativeTypes } from 'react-dnd-html5-backend'; + +import { threadInfoSelector } from 'lib/selectors/thread-selectors'; +import { + useWatchThread, + useExistingThreadInfoFinder, +} from 'lib/shared/thread-utils'; + +import { InputStateContext } from '../input/input-state'; +import { useSelector } from '../redux/redux-utils'; +import ChatInputBar from './chat-input-bar.react'; +import css from './chat-message-list-container.css'; +import ChatMessageList from './chat-message-list.react'; +import ThreadTopBar from './thread-top-bar.react'; + +function ChatMessageListContainer(): React.Node { + const activeChatThreadID = useSelector( + state => state.navInfo.activeChatThreadID, + ); + const baseThreadInfo = useSelector(state => { + if (!activeChatThreadID) { + return null; + } + return ( + threadInfoSelector(state)[activeChatThreadID] ?? + state.navInfo.pendingThread + ); + }); + const existingThreadInfoFinder = useExistingThreadInfoFinder(baseThreadInfo); + const threadInfo = React.useMemo( + () => + existingThreadInfoFinder({ + searching: false, + userInfoInputArray: [], + }), + [existingThreadInfoFinder], + ); + invariant(threadInfo, 'ThreadInfo should be set'); + + const inputState = React.useContext(InputStateContext); + invariant(inputState, 'InputState should be set'); + const [{ isActive }, connectDropTarget] = useDrop({ + accept: NativeTypes.FILE, + drop: item => { + const { files } = item; + if (inputState && files.length > 0) { + inputState.appendFiles(files); + } + }, + collect: monitor => ({ + isActive: monitor.isOver() && monitor.canDrop(), + }), + }); + + useWatchThread(threadInfo); + + const containerStyle = classNames({ + [css.container]: true, + [css.activeContainer]: isActive, + }); + + const containerRef = React.useRef(); + + const onPaste = React.useCallback( + (e: ClipboardEvent) => { + if (!inputState) { + return; + } + const { clipboardData } = e; + if (!clipboardData) { + return; + } + const { files } = clipboardData; + if (files.length === 0) { + return; + } + e.preventDefault(); + inputState.appendFiles([...files]); + }, + [inputState], + ); + + React.useEffect(() => { + const currentContainerRef = containerRef.current; + if (!currentContainerRef) { + return; + } + currentContainerRef.addEventListener('paste', onPaste); + return () => { + currentContainerRef.removeEventListener('paste', onPaste); + }; + }, [onPaste]); + + return connectDropTarget( +
+ + + +
, + ); +} + +export default ChatMessageListContainer; diff --git a/web/chat/chat-message-list.css b/web/chat/chat-message-list.css index 454fac7dc..381bf4d86 100644 --- a/web/chat/chat-message-list.css +++ b/web/chat/chat-message-list.css @@ -1,224 +1,212 @@ -div.container { - margin-left: 400px; - height: 100%; - background-color: var(--bg); - display: flex; - flex-direction: column; - box-sizing: border-box; -} -div.activeContainer { - border: 2px solid #5989d6; - margin-left: 402px; -} div.outerMessageContainer { position: relative; height: calc(100vh - 128px); min-height: 0; display: flex; flex-direction: column; } div.messageContainer { height: 100%; overflow-y: auto; display: flex; flex-direction: column-reverse; } div.mirroredMessageContainer { flex-direction: column !important; transform: scaleY(-1); } div.mirroredMessageContainer > div { transform: scaleY(-1); } div.message { display: flex; flex-direction: column; flex-shrink: 0; } div.loading { text-align: center; padding: 12px; } div.conversationHeader { color: var(--chat-timestamp-color); font-size: var(--xs-font-12); line-height: var(--line-height-text); text-align: center; } div.conversationHeader:last-child { padding-top: 6px; } div.messageTooltipActiveArea { position: absolute; display: flex; top: 0; bottom: 0; align-items: center; padding: 0 12px; } div.viewerMessageTooltipActiveArea { right: 100%; } div.nonViewerMessageActiveArea { left: 100%; } div.messageTooltipActiveArea > div + div { margin-left: 4px; } div.messageTooltipLinkIcon:hover { cursor: pointer; } div.textMessage { padding: 6px 12px; white-space: pre-wrap; word-wrap: break-word; width: 100%; box-sizing: border-box; } div.textMessageDefaultBackground { background-color: var(--text-message-default-background); } div.normalTextMessage { font-size: 16px; } div.emojiOnlyTextMessage { font-size: 32px; font-family: emoji; } span.authorName { color: #777777; font-size: 14px; padding: 4px 24px; } div.darkTextMessage { color: white; } div.lightTextMessage { color: black; } div.content { display: flex; flex-shrink: 0; align-items: center; margin-bottom: 5px; box-sizing: border-box; width: 100%; } div.nonViewerContent { align-self: flex-start; justify-content: flex-start; padding-right: 8px; } div.viewerContent { align-self: flex-end; justify-content: flex-end; padding-right: 4px; } div.iconContainer { margin-right: 1px; } div.iconContainer > svg { height: 16px; } div.messageBoxContainer { position: relative; display: flex; max-width: calc(min(68%, 1000px)); margin: 0 4px 0 12px; } div.fixedWidthMessageBoxContainer { width: 68%; } div.messageBox { overflow: hidden; display: flex; flex-wrap: wrap; justify-content: space-between; flex-shrink: 0; max-width: 100%; } div.fixedWidthMessageBox { width: 100%; } div.failedSend { text-transform: uppercase; display: flex; justify-content: flex-end; flex-shrink: 0; font-size: 14px; margin-right: 30px; padding-bottom: 6px; } span.deliveryFailed { padding: 0 3px; color: #555555; } a.retrySend { padding: 0 3px; cursor: pointer; } div.messageBox > div.imageGrid { display: grid; width: 100%; grid-template-columns: repeat(6, 1fr); grid-gap: 5px; } div.messageBox span.multimedia > span.multimediaImage { min-height: initial; min-width: initial; } div.messageBox span.multimedia > span.multimediaImage > img { max-height: 600px; } div.imageGrid > span.multimedia { grid-column-end: span 3; } div.imageGrid > span.multimedia:first-child { margin-top: 0; } div.imageGrid > span.multimedia > span.multimediaImage { flex: 1; } div.imageGrid > span.multimedia > span.multimediaImage:after { content: ''; display: block; padding-bottom: calc(min(600px, 100%)); } div.imageGrid > span.multimedia > span.multimediaImage > img { position: absolute; width: 100%; height: 100%; object-fit: cover; } div.imageGrid > span.multimedia:nth-last-child(n + 3):first-child, div.imageGrid > span.multimedia:nth-last-child(n + 3):first-child ~ * { grid-column-end: span 2; } div.imageGrid > span.multimedia:nth-last-child(n + 4):first-child, div.imageGrid > span.multimedia:nth-last-child(n + 4):first-child ~ * { grid-column-end: span 3; } div.imageGrid > span.multimedia:nth-last-child(n + 5):first-child, div.imageGrid > span.multimedia:nth-last-child(n + 5):first-child ~ * { grid-column-end: span 2; } div.sidebarMarginBottom { margin-bottom: 8px; } svg.inlineSidebarIcon { color: #666666; } diff --git a/web/chat/chat-message-list.react.js b/web/chat/chat-message-list.react.js index 1ec5e62f3..cc68636ee 100644 --- a/web/chat/chat-message-list.react.js +++ b/web/chat/chat-message-list.react.js @@ -1,492 +1,401 @@ // @flow import classNames from 'classnames'; import { detect as detectBrowser } from 'detect-browser'; import invariant from 'invariant'; import * as React from 'react'; -import { useDrop } from 'react-dnd'; -import { NativeTypes } from 'react-dnd-html5-backend'; import { fetchMessagesBeforeCursorActionTypes, fetchMessagesBeforeCursor, fetchMostRecentMessagesActionTypes, fetchMostRecentMessages, } from 'lib/actions/message-actions'; import { registerFetchKey } from 'lib/reducers/loading-reducer'; import { type ChatMessageItem, useMessageListData, } from 'lib/selectors/chat-selectors'; -import { threadInfoSelector } from 'lib/selectors/thread-selectors'; import { messageKey } from 'lib/shared/message-utils'; -import { - useWatchThread, - useExistingThreadInfoFinder, - threadIsPending, -} from 'lib/shared/thread-utils'; +import { threadIsPending } from 'lib/shared/thread-utils'; import type { FetchMessageInfosPayload } from 'lib/types/message-types'; import { type ThreadInfo } from 'lib/types/thread-types'; import { type DispatchActionPromise, useServerCall, useDispatchActionPromise, } from 'lib/utils/action-utils'; import { type InputState, InputStateContext } from '../input/input-state'; import LoadingIndicator from '../loading-indicator.react'; import { useTextMessageRulesFunc } from '../markdown/rules.react'; import { useSelector } from '../redux/redux-utils'; -import ChatInputBar from './chat-input-bar.react'; import css from './chat-message-list.css'; import { MessageListContext } from './message-list-types'; import Message from './message.react'; import type { OnMessagePositionWithContainerInfo, MessagePositionInfo, } from './position-types'; import RelationshipPrompt from './relationship-prompt/relationship-prompt'; -import ThreadTopBar from './thread-top-bar.react'; -type PassedProps = { +type BaseProps = { + +threadInfo: ThreadInfo, +}; + +type Props = { + ...BaseProps, // Redux state +activeChatThreadID: ?string, - +threadInfo: ?ThreadInfo, +messageListData: ?$ReadOnlyArray, +startReached: boolean, +timeZone: ?string, +supportsReverseFlex: boolean, // Redux dispatch functions +dispatchActionPromise: DispatchActionPromise, // async functions that hit server APIs +fetchMessagesBeforeCursor: ( threadID: string, beforeMessageID: string, ) => Promise, +fetchMostRecentMessages: ( threadID: string, ) => Promise, // withInputState +inputState: ?InputState, }; -type ReactDnDProps = { - +isActive: boolean, - +connectDropTarget: (node: React.Node) => React.Node, -}; -type Props = { - ...PassedProps, - ...ReactDnDProps, -}; type State = { +mouseOverMessagePosition: ?OnMessagePositionWithContainerInfo, }; type Snapshot = { +scrollTop: number, +scrollHeight: number, }; class ChatMessageList extends React.PureComponent { state: State = { mouseOverMessagePosition: null, }; container: ?HTMLDivElement; messageContainer: ?HTMLDivElement; loadingFromScroll = false; componentDidMount() { this.scrollToBottom(); } getSnapshotBeforeUpdate(prevProps: Props) { if ( ChatMessageList.hasNewMessage(this.props, prevProps) && this.messageContainer ) { const { scrollTop, scrollHeight } = this.messageContainer; return { scrollTop, scrollHeight }; } return null; } static hasNewMessage(props: Props, prevProps: Props) { const { messageListData } = props; if (!messageListData || messageListData.length === 0) { return false; } const prevMessageListData = prevProps.messageListData; if (!prevMessageListData || prevMessageListData.length === 0) { return true; } return ( ChatMessageList.keyExtractor(prevMessageListData[0]) !== ChatMessageList.keyExtractor(messageListData[0]) ); } componentDidUpdate(prevProps: Props, prevState: State, snapshot: ?Snapshot) { const { messageListData } = this.props; const prevMessageListData = prevProps.messageListData; if ( this.loadingFromScroll && messageListData && (!prevMessageListData || messageListData.length > prevMessageListData.length || this.props.startReached) ) { this.loadingFromScroll = false; } const { messageContainer } = this; if (messageContainer && prevMessageListData !== messageListData) { this.onScroll(); } // We'll scroll to the bottom if the user was already scrolled to the bottom // before the new message, or if the new message was composed locally const hasNewMessage = ChatMessageList.hasNewMessage(this.props, prevProps); if ( this.props.activeChatThreadID !== prevProps.activeChatThreadID || (hasNewMessage && messageListData && messageListData[0].itemType === 'message' && messageListData[0].messageInfo.localID) || (hasNewMessage && snapshot && Math.abs(snapshot.scrollTop) <= 1) ) { this.scrollToBottom(); } else if (hasNewMessage && messageContainer && snapshot) { const { scrollTop, scrollHeight } = messageContainer; if ( scrollHeight > snapshot.scrollHeight && scrollTop === snapshot.scrollTop ) { const newHeight = scrollHeight - snapshot.scrollHeight; const newScrollTop = Math.abs(scrollTop) + newHeight; if (this.props.supportsReverseFlex) { messageContainer.scrollTop = -1 * newScrollTop; } else { messageContainer.scrollTop = newScrollTop; } } } } scrollToBottom() { if (this.messageContainer) { this.messageContainer.scrollTop = 0; } } static keyExtractor(item: ChatMessageItem) { if (item.itemType === 'loader') { return 'loader'; } return messageKey(item.messageInfo); } renderItem = item => { if (item.itemType === 'loader') { return (
); } const { threadInfo } = this.props; invariant(threadInfo, 'ThreadInfo should be set if messageListData is'); return ( ); }; setMouseOverMessagePosition = (messagePositionInfo: MessagePositionInfo) => { if (!this.messageContainer) { return; } if (messagePositionInfo.type === 'off') { this.setState({ mouseOverMessagePosition: null }); return; } const { top: containerTop, bottom: containerBottom, left: containerLeft, right: containerRight, height: containerHeight, width: containerWidth, } = this.messageContainer.getBoundingClientRect(); const mouseOverMessagePosition = { ...messagePositionInfo, messagePosition: { ...messagePositionInfo.messagePosition, top: messagePositionInfo.messagePosition.top - containerTop, bottom: messagePositionInfo.messagePosition.bottom - containerTop, left: messagePositionInfo.messagePosition.left - containerLeft, right: messagePositionInfo.messagePosition.right - containerLeft, }, containerPosition: { top: containerTop, bottom: containerBottom, left: containerLeft, right: containerRight, height: containerHeight, width: containerWidth, }, }; this.setState({ mouseOverMessagePosition }); }; render() { - const { - messageListData, - threadInfo, - inputState, - connectDropTarget, - isActive, - } = this.props; + const { messageListData, threadInfo, inputState } = this.props; if (!messageListData) { return
; } - invariant(threadInfo, 'ThreadInfo should be set if messageListData is'); invariant(inputState, 'InputState should be set'); const messages = messageListData.map(this.renderItem); - const containerStyle = classNames({ - [css.container]: true, - [css.activeContainer]: isActive, - }); let relationshipPrompt; - if (this.props.threadInfo) { - relationshipPrompt = ( - - ); + if (threadInfo) { + relationshipPrompt = ; } const messageContainerStyle = classNames({ [css.messageContainer]: true, [css.mirroredMessageContainer]: !this.props.supportsReverseFlex, }); - return connectDropTarget( -
- -
- {relationshipPrompt} -
- {messages} -
+ return ( +
+ {relationshipPrompt} +
+ {messages}
- -
, +
); } - containerRef = (container: ?HTMLDivElement) => { - if (container) { - container.addEventListener('paste', this.onPaste); - } - this.container = container; - }; - - onPaste = (e: ClipboardEvent) => { - const { inputState } = this.props; - if (!inputState) { - return; - } - const { clipboardData } = e; - if (!clipboardData) { - return; - } - const { files } = clipboardData; - if (files.length === 0) { - return; - } - e.preventDefault(); - inputState.appendFiles([...files]); - }; - messageContainerRef = (messageContainer: ?HTMLDivElement) => { this.messageContainer = messageContainer; // In case we already have all the most recent messages, // but they're not enough this.possiblyLoadMoreMessages(); if (messageContainer) { messageContainer.addEventListener('scroll', this.onScroll); } }; onScroll = () => { if (!this.messageContainer) { return; } if (this.state.mouseOverMessagePosition) { this.setState({ mouseOverMessagePosition: null }); } this.possiblyLoadMoreMessages(); }; possiblyLoadMoreMessages() { if (!this.messageContainer) { return; } const { scrollTop, scrollHeight, clientHeight } = this.messageContainer; if ( this.props.startReached || Math.abs(scrollTop) + clientHeight + 55 < scrollHeight ) { return; } if (this.loadingFromScroll) { return; } this.loadingFromScroll = true; const threadID = this.props.activeChatThreadID; invariant(threadID, 'should be set'); const oldestMessageServerID = this.oldestMessageServerID(); if (oldestMessageServerID) { this.props.dispatchActionPromise( fetchMessagesBeforeCursorActionTypes, this.props.fetchMessagesBeforeCursor(threadID, oldestMessageServerID), ); } else { this.props.dispatchActionPromise( fetchMostRecentMessagesActionTypes, this.props.fetchMostRecentMessages(threadID), ); } } oldestMessageServerID(): ?string { const data = this.props.messageListData; invariant(data, 'should be set'); for (let i = data.length - 1; i >= 0; i--) { if (data[i].itemType === 'message' && data[i].messageInfo.id) { return data[i].messageInfo.id; } } return null; } } registerFetchKey(fetchMessagesBeforeCursorActionTypes); registerFetchKey(fetchMostRecentMessagesActionTypes); -const ConnectedChatMessageList: React.ComponentType<{}> = React.memo<{}>( - function ConnectedChatMessageList(): React.Node { +const ConnectedChatMessageList: React.ComponentType = React.memo( + function ConnectedChatMessageList(props: BaseProps): React.Node { + const { threadInfo } = props; const userAgent = useSelector(state => state.userAgent); const supportsReverseFlex = React.useMemo(() => { const browser = detectBrowser(userAgent); return ( !browser || browser.name !== 'firefox' || parseInt(browser.version) >= 81 ); }, [userAgent]); const timeZone = useSelector(state => state.timeZone); - const activeChatThreadID = useSelector( - state => state.navInfo.activeChatThreadID, - ); - const baseThreadInfo = useSelector(state => { - const activeID = state.navInfo.activeChatThreadID; - if (!activeID) { - return null; - } - return threadInfoSelector(state)[activeID] ?? state.navInfo.pendingThread; - }); - const existingThreadInfoFinder = useExistingThreadInfoFinder( - baseThreadInfo, - ); - const threadInfo = React.useMemo( - () => - existingThreadInfoFinder({ - searching: false, - userInfoInputArray: [], - }), - [existingThreadInfoFinder], - ); - const messageListData = useMessageListData({ threadInfo, searching: false, userInfoInputArray: [], }); - const startReached = useSelector(state => { - const activeID = state.navInfo.activeChatThreadID; + const startReached = !!useSelector(state => { + const activeID = threadInfo.id; if (!activeID) { return null; } - if (threadIsPending(threadInfo?.id)) { + if (threadIsPending(activeID)) { return true; } const threadMessageInfo = state.messageStore.threads[activeID]; if (!threadMessageInfo) { return null; } return threadMessageInfo.startReached; }); const dispatchActionPromise = useDispatchActionPromise(); const callFetchMessagesBeforeCursor = useServerCall( fetchMessagesBeforeCursor, ); const callFetchMostRecentMessages = useServerCall(fetchMostRecentMessages); const inputState = React.useContext(InputStateContext); - const [dndProps, connectDropTarget] = useDrop({ - accept: NativeTypes.FILE, - drop: item => { - const { files } = item; - if (inputState && files.length > 0) { - inputState.appendFiles(files); - } - }, - collect: monitor => ({ - isActive: monitor.isOver() && monitor.canDrop(), - }), - }); - const getTextMessageMarkdownRules = useTextMessageRulesFunc(threadInfo?.id); + const getTextMessageMarkdownRules = useTextMessageRulesFunc(threadInfo.id); const messageListContext = React.useMemo(() => { if (!getTextMessageMarkdownRules) { return undefined; } return { getTextMessageMarkdownRules }; }, [getTextMessageMarkdownRules]); - useWatchThread(threadInfo); - return ( ); }, ); export default ConnectedChatMessageList; diff --git a/web/chat/chat.react.js b/web/chat/chat.react.js index 022dcd643..e064ab097 100644 --- a/web/chat/chat.react.js +++ b/web/chat/chat.react.js @@ -1,22 +1,22 @@ // @flow import * as React from 'react'; -import ChatMessageList from './chat-message-list.react'; +import ChatMessageListContainer from './chat-message-list-container.react'; import ChatTabs from './chat-tabs.react'; import { ThreadListProvider } from './thread-list-provider'; function Chat(): React.Node { return ( <> - + ); } const MemoizedChat: React.ComponentType<{}> = React.memo<{}>(Chat); export default MemoizedChat;