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;