Changeset View
Changeset View
Standalone View
Standalone View
web/chat/chat-message-list.react.js
// @flow | // @flow | ||||
import classNames from 'classnames'; | import classNames from 'classnames'; | ||||
import { detect as detectBrowser } from 'detect-browser'; | import { detect as detectBrowser } from 'detect-browser'; | ||||
import invariant from 'invariant'; | import invariant from 'invariant'; | ||||
import _debounce from 'lodash/debounce.js'; | |||||
import * as React from 'react'; | import * as React from 'react'; | ||||
import { | import { | ||||
fetchMessagesBeforeCursorActionTypes, | fetchMessagesBeforeCursorActionTypes, | ||||
fetchMessagesBeforeCursor, | fetchMessagesBeforeCursor, | ||||
fetchMostRecentMessagesActionTypes, | fetchMostRecentMessagesActionTypes, | ||||
fetchMostRecentMessages, | fetchMostRecentMessages, | ||||
} from 'lib/actions/message-actions.js'; | } from 'lib/actions/message-actions.js'; | ||||
Show All 9 Lines | |||||
import { threadTypes } from 'lib/types/thread-types-enum.js'; | import { threadTypes } from 'lib/types/thread-types-enum.js'; | ||||
import { type ThreadInfo } from 'lib/types/thread-types.js'; | import { type ThreadInfo } from 'lib/types/thread-types.js'; | ||||
import { | import { | ||||
type DispatchActionPromise, | type DispatchActionPromise, | ||||
useServerCall, | useServerCall, | ||||
useDispatchActionPromise, | useDispatchActionPromise, | ||||
} from 'lib/utils/action-utils.js'; | } from 'lib/utils/action-utils.js'; | ||||
import { editBoxHeight, defaultMaxTextAreaHeight } from './chat-constants.js'; | |||||
import css from './chat-message-list.css'; | import css from './chat-message-list.css'; | ||||
import type { ScrollToMessageCallback } from './edit-message-provider.js'; | |||||
import { useEditModalContext } from './edit-message-provider.js'; | import { useEditModalContext } from './edit-message-provider.js'; | ||||
import { MessageListContext } from './message-list-types.js'; | import { MessageListContext } from './message-list-types.js'; | ||||
import Message from './message.react.js'; | import Message from './message.react.js'; | ||||
import RelationshipPrompt from './relationship-prompt/relationship-prompt.js'; | import RelationshipPrompt from './relationship-prompt/relationship-prompt.js'; | ||||
import { useTooltipContext } from './tooltip-provider.js'; | import { useTooltipContext } from './tooltip-provider.js'; | ||||
import { type InputState, InputStateContext } from '../input/input-state.js'; | import { type InputState, InputStateContext } from '../input/input-state.js'; | ||||
import LoadingIndicator from '../loading-indicator.react.js'; | import LoadingIndicator from '../loading-indicator.react.js'; | ||||
import { useTextMessageRulesFunc } from '../markdown/rules.react.js'; | import { useTextMessageRulesFunc } from '../markdown/rules.react.js'; | ||||
import { useSelector } from '../redux/redux-utils.js'; | import { useSelector } from '../redux/redux-utils.js'; | ||||
const browser = detectBrowser(); | const browser = detectBrowser(); | ||||
const supportsReverseFlex = | const supportsReverseFlex = | ||||
!browser || browser.name !== 'firefox' || parseInt(browser.version) >= 81; | !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 = { | type BaseProps = { | ||||
+threadInfo: ThreadInfo, | +threadInfo: ThreadInfo, | ||||
}; | }; | ||||
type Props = { | type Props = { | ||||
...BaseProps, | ...BaseProps, | ||||
+activeChatThreadID: ?string, | +activeChatThreadID: ?string, | ||||
+messageListData: ?$ReadOnlyArray<ChatMessageItem>, | +messageListData: ?$ReadOnlyArray<ChatMessageItem>, | ||||
+startReached: boolean, | +startReached: boolean, | ||||
+dispatchActionPromise: DispatchActionPromise, | +dispatchActionPromise: DispatchActionPromise, | ||||
+fetchMessagesBeforeCursor: ( | +fetchMessagesBeforeCursor: ( | ||||
threadID: string, | threadID: string, | ||||
beforeMessageID: string, | beforeMessageID: string, | ||||
) => Promise<FetchMessageInfosPayload>, | ) => Promise<FetchMessageInfosPayload>, | ||||
+fetchMostRecentMessages: ( | +fetchMostRecentMessages: ( | ||||
threadID: string, | threadID: string, | ||||
) => Promise<FetchMessageInfosPayload>, | ) => Promise<FetchMessageInfosPayload>, | ||||
+inputState: ?InputState, | +inputState: ?InputState, | ||||
+clearTooltip: () => mixed, | +clearTooltip: () => mixed, | ||||
+oldestMessageServerID: ?string, | +oldestMessageServerID: ?string, | ||||
+isEditState: boolean, | +isEditState: boolean, | ||||
+addScrollToMessageListener: ScrollToMessageCallback => mixed, | |||||
+removeScrollToMessageListener: ScrollToMessageCallback => mixed, | |||||
}; | }; | ||||
type Snapshot = { | type Snapshot = { | ||||
+scrollTop: number, | +scrollTop: number, | ||||
+scrollHeight: number, | +scrollHeight: number, | ||||
}; | }; | ||||
class ChatMessageList extends React.PureComponent<Props> { | |||||
type State = { | |||||
+scrollingEndCallback: ?() => mixed, | |||||
}; | |||||
class ChatMessageList extends React.PureComponent<Props, State> { | |||||
container: ?HTMLDivElement; | container: ?HTMLDivElement; | ||||
messageContainer: ?HTMLDivElement; | messageContainer: ?HTMLDivElement; | ||||
loadingFromScroll = false; | loadingFromScroll = false; | ||||
constructor(props: Props) { | |||||
super(props); | |||||
this.state = { | |||||
scrollingEndCallback: null, | |||||
}; | |||||
} | |||||
componentDidMount() { | componentDidMount() { | ||||
this.scrollToBottom(); | this.scrollToBottom(); | ||||
this.props.addScrollToMessageListener(this.scrollToMessage); | |||||
} | |||||
componentWillUnmount() { | |||||
this.props.removeScrollToMessageListener(this.scrollToMessage); | |||||
} | } | ||||
getSnapshotBeforeUpdate(prevProps: Props) { | getSnapshotBeforeUpdate(prevProps: Props) { | ||||
if ( | if ( | ||||
ChatMessageList.hasNewMessage(this.props, prevProps) && | ChatMessageList.hasNewMessage(this.props, prevProps) && | ||||
this.messageContainer | this.messageContainer | ||||
) { | ) { | ||||
const { scrollTop, scrollHeight } = this.messageContainer; | const { scrollTop, scrollHeight } = this.messageContainer; | ||||
▲ Show 20 Lines • Show All 86 Lines • ▼ Show 20 Lines | return ( | ||||
item={item} | item={item} | ||||
threadInfo={threadInfo} | threadInfo={threadInfo} | ||||
shouldDisplayPinIndicator={true} | shouldDisplayPinIndicator={true} | ||||
key={ChatMessageList.keyExtractor(item)} | key={ChatMessageList.keyExtractor(item)} | ||||
/> | /> | ||||
); | ); | ||||
}; | }; | ||||
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() { | render() { | ||||
const { messageListData, threadInfo, inputState } = this.props; | const { messageListData, threadInfo, inputState, isEditState } = this.props; | ||||
if (!messageListData) { | if (!messageListData) { | ||||
return <div className={css.container} />; | return <div className={css.container} />; | ||||
} | } | ||||
invariant(inputState, 'InputState should be set'); | invariant(inputState, 'InputState should be set'); | ||||
const messages = messageListData.map(this.renderItem); | const messages = messageListData.map(this.renderItem); | ||||
let relationshipPrompt = null; | let relationshipPrompt = null; | ||||
if (threadInfo.type === threadTypes.PERSONAL) { | if (threadInfo.type === threadTypes.PERSONAL) { | ||||
relationshipPrompt = <RelationshipPrompt threadInfo={threadInfo} />; | relationshipPrompt = <RelationshipPrompt threadInfo={threadInfo} />; | ||||
} | } | ||||
const messageContainerStyle = classNames({ | const messageContainerStyle = classNames({ | ||||
[css.disableAnchor]: | |||||
this.state.scrollingEndCallback !== null || isEditState, | |||||
[css.messageContainer]: true, | [css.messageContainer]: true, | ||||
[css.mirroredMessageContainer]: !supportsReverseFlex, | [css.mirroredMessageContainer]: !supportsReverseFlex, | ||||
}); | }); | ||||
return ( | return ( | ||||
<div className={css.outerMessageContainer}> | <div className={css.outerMessageContainer}> | ||||
{relationshipPrompt} | {relationshipPrompt} | ||||
<div className={messageContainerStyle} ref={this.messageContainerRef}> | <div className={messageContainerStyle} ref={this.messageContainerRef}> | ||||
{messages} | {messages} | ||||
Show All 13 Lines | class ChatMessageList extends React.PureComponent<Props, State> { | ||||
}; | }; | ||||
onScroll = () => { | onScroll = () => { | ||||
if (!this.messageContainer) { | if (!this.messageContainer) { | ||||
return; | return; | ||||
} | } | ||||
this.props.clearTooltip(); | this.props.clearTooltip(); | ||||
this.possiblyLoadMoreMessages(); | this.possiblyLoadMoreMessages(); | ||||
this.debounceEditModeAfterScrollToMessage(); | |||||
}; | }; | ||||
debounceEditModeAfterScrollToMessage = _debounce(() => { | |||||
if (this.state.scrollingEndCallback) { | |||||
this.state.scrollingEndCallback(); | |||||
} | |||||
this.setState({ scrollingEndCallback: null }); | |||||
}, 100); | |||||
async possiblyLoadMoreMessages() { | async possiblyLoadMoreMessages() { | ||||
if (!this.messageContainer) { | if (!this.messageContainer) { | ||||
return; | return; | ||||
} | } | ||||
const { scrollTop, scrollHeight, clientHeight } = this.messageContainer; | const { scrollTop, scrollHeight, clientHeight } = this.messageContainer; | ||||
if ( | if ( | ||||
this.props.startReached || | this.props.startReached || | ||||
▲ Show 20 Lines • Show All 74 Lines • ▼ Show 20 Lines | const messageListContext = React.useMemo(() => { | ||||
if (!getTextMessageMarkdownRules) { | if (!getTextMessageMarkdownRules) { | ||||
return undefined; | return undefined; | ||||
} | } | ||||
return { getTextMessageMarkdownRules }; | return { getTextMessageMarkdownRules }; | ||||
}, [getTextMessageMarkdownRules]); | }, [getTextMessageMarkdownRules]); | ||||
const oldestMessageServerID = useOldestMessageServerID(threadInfo.id); | const oldestMessageServerID = useOldestMessageServerID(threadInfo.id); | ||||
const { editState } = useEditModalContext(); | const { | ||||
editState, | |||||
addScrollToMessageListener, | |||||
removeScrollToMessageListener, | |||||
} = useEditModalContext(); | |||||
const isEditState = editState !== null; | const isEditState = editState !== null; | ||||
return ( | return ( | ||||
<MessageListContext.Provider value={messageListContext}> | <MessageListContext.Provider value={messageListContext}> | ||||
<ChatMessageList | <ChatMessageList | ||||
activeChatThreadID={threadInfo.id} | activeChatThreadID={threadInfo.id} | ||||
threadInfo={threadInfo} | threadInfo={threadInfo} | ||||
messageListData={messageListData} | messageListData={messageListData} | ||||
startReached={startReached} | startReached={startReached} | ||||
inputState={inputState} | inputState={inputState} | ||||
dispatchActionPromise={dispatchActionPromise} | dispatchActionPromise={dispatchActionPromise} | ||||
fetchMessagesBeforeCursor={callFetchMessagesBeforeCursor} | fetchMessagesBeforeCursor={callFetchMessagesBeforeCursor} | ||||
fetchMostRecentMessages={callFetchMostRecentMessages} | fetchMostRecentMessages={callFetchMostRecentMessages} | ||||
clearTooltip={clearTooltip} | clearTooltip={clearTooltip} | ||||
oldestMessageServerID={oldestMessageServerID} | oldestMessageServerID={oldestMessageServerID} | ||||
isEditState={isEditState} | isEditState={isEditState} | ||||
addScrollToMessageListener={addScrollToMessageListener} | |||||
removeScrollToMessageListener={removeScrollToMessageListener} | |||||
/> | /> | ||||
</MessageListContext.Provider> | </MessageListContext.Provider> | ||||
); | ); | ||||
}); | }); | ||||
export default ConnectedChatMessageList; | export default ConnectedChatMessageList; |