diff --git a/native/chat/message-list.react.js b/native/chat/message-list.react.js index 3d9c3968d..829e81961 100644 --- a/native/chat/message-list.react.js +++ b/native/chat/message-list.react.js @@ -1,326 +1,329 @@ // @flow import invariant from 'invariant'; import _find from 'lodash/fp/find.js'; import * as React from 'react'; import { TouchableWithoutFeedback, View } from 'react-native'; import { createSelector } from 'reselect'; import { messageKey, useFetchMessages } from 'lib/shared/message-utils.js'; import { useWatchThread } from 'lib/shared/watch-thread-utils.js'; import type { ThreadInfo } from 'lib/types/minimally-encoded-thread-permissions-types.js'; import { threadTypes } from 'lib/types/thread-types-enum.js'; import ChatList from './chat-list.react.js'; import type { ChatNavigationProp } from './chat.react.js'; import Message from './message.react.js'; import RelationshipPrompt from './relationship-prompt.react.js'; import ListLoadingIndicator from '../components/list-loading-indicator.react.js'; import { KeyboardContext, type KeyboardState, } from '../keyboard/keyboard-state.js'; import { defaultStackScreenOptions } from '../navigation/options.js'; import { OverlayContext, type OverlayContextType, } from '../navigation/overlay-context.js'; import type { NavigationRoute } from '../navigation/route-names.js'; import { useSelector } from '../redux/redux-utils.js'; import { type IndicatorStyle, useIndicatorStyle, useStyles, } from '../themes/colors.js'; import type { ChatMessageInfoItemWithHeight, ChatMessageItemWithHeight, } from '../types/chat-types.js'; import type { VerticalBounds } from '../types/layout-types.js'; import type { ViewableItemsChange } from '../types/react-native.js'; const unboundStyles = { container: { backgroundColor: 'listBackground', flex: 1, }, header: { height: 12, }, listLoadingIndicator: { flex: 1, }, }; type BaseProps = { +threadInfo: ThreadInfo, +messageListData: $ReadOnlyArray, +navigation: ChatNavigationProp<'MessageList'>, +route: NavigationRoute<'MessageList'>, }; type Props = { ...BaseProps, +startReached: boolean, +styles: $ReadOnly, +indicatorStyle: IndicatorStyle, +overlayContext: ?OverlayContextType, +keyboardState: ?KeyboardState, +fetchMessages: () => Promise, }; type State = { +focusedMessageKey: ?string, +messageListVerticalBounds: ?VerticalBounds, +loadingFromScroll: boolean, }; type PropsAndState = { ...Props, ...State, }; type FlatListExtraData = { messageListVerticalBounds: ?VerticalBounds, focusedMessageKey: ?string, navigation: ChatNavigationProp<'MessageList'>, route: NavigationRoute<'MessageList'>, }; class MessageList extends React.PureComponent { state: State = { focusedMessageKey: null, messageListVerticalBounds: null, loadingFromScroll: false, }; flatListContainer: ?React.ElementRef; flatListExtraDataSelector: PropsAndState => FlatListExtraData = createSelector( (propsAndState: PropsAndState) => propsAndState.messageListVerticalBounds, (propsAndState: PropsAndState) => propsAndState.focusedMessageKey, (propsAndState: PropsAndState) => propsAndState.navigation, (propsAndState: PropsAndState) => propsAndState.route, ( messageListVerticalBounds: ?VerticalBounds, focusedMessageKey: ?string, navigation: ChatNavigationProp<'MessageList'>, route: NavigationRoute<'MessageList'>, ) => ({ messageListVerticalBounds, focusedMessageKey, navigation, route, }), ); get flatListExtraData(): FlatListExtraData { return this.flatListExtraDataSelector({ ...this.props, ...this.state }); } static getOverlayContext(props: Props): OverlayContextType { const { overlayContext } = props; invariant(overlayContext, 'MessageList should have OverlayContext'); return overlayContext; } static scrollDisabled(props: Props): boolean { const overlayContext = MessageList.getOverlayContext(props); return overlayContext.scrollBlockingModalStatus !== 'closed'; } static modalOpen(props: Props): boolean { const overlayContext = MessageList.getOverlayContext(props); return overlayContext.scrollBlockingModalStatus === 'open'; } componentDidUpdate(prevProps: Props) { const modalIsOpen = MessageList.modalOpen(this.props); const modalWasOpen = MessageList.modalOpen(prevProps); if (!modalIsOpen && modalWasOpen) { this.setState({ focusedMessageKey: null }); } if (defaultStackScreenOptions.gestureEnabled) { const scrollIsDisabled = MessageList.scrollDisabled(this.props); const scrollWasDisabled = MessageList.scrollDisabled(prevProps); if (!scrollWasDisabled && scrollIsDisabled) { this.props.navigation.setOptions({ gestureEnabled: false }); } else if (scrollWasDisabled && !scrollIsDisabled) { this.props.navigation.setOptions({ gestureEnabled: true }); } } } dismissKeyboard = () => { const { keyboardState } = this.props; keyboardState && keyboardState.dismissKeyboard(); }; renderItem = (row: { item: ChatMessageItemWithHeight, ... }): React.Node => { if (row.item.itemType === 'loader') { return ( ); } const messageInfoItem: ChatMessageInfoItemWithHeight = row.item; const { messageListVerticalBounds, focusedMessageKey, navigation, route } = this.flatListExtraData; const focused = messageKey(messageInfoItem.messageInfo) === focusedMessageKey; return ( ); }; toggleMessageFocus = (inMessageKey: string) => { if (this.state.focusedMessageKey === inMessageKey) { this.setState({ focusedMessageKey: null }); } else { this.setState({ focusedMessageKey: inMessageKey }); } }; // Actually header, it's just that our FlatList is inverted ListFooterComponent = (): React.Node => ( ); render(): React.Node { const { messageListData, startReached } = this.props; const footer = startReached ? this.ListFooterComponent : undefined; let relationshipPrompt = null; - if (this.props.threadInfo.type === threadTypes.GENESIS_PERSONAL) { + if ( + this.props.threadInfo.type === threadTypes.GENESIS_PERSONAL || + this.props.threadInfo.type === threadTypes.PERSONAL + ) { relationshipPrompt = ( ); } return ( {relationshipPrompt} ); } flatListContainerRef = ( flatListContainer: ?React.ElementRef, ) => { this.flatListContainer = flatListContainer; }; onFlatListContainerLayout = () => { const { flatListContainer } = this; if (!flatListContainer) { return; } flatListContainer.measure((x, y, width, height, pageX, pageY) => { if ( height === null || height === undefined || pageY === null || pageY === undefined ) { return; } this.setState({ messageListVerticalBounds: { height, y: pageY } }); }); }; onViewableItemsChanged = (info: ViewableItemsChange) => { if (this.state.focusedMessageKey) { let focusedMessageVisible = false; for (const token of info.viewableItems) { if ( token.item.itemType === 'message' && messageKey(token.item.messageInfo) === this.state.focusedMessageKey ) { focusedMessageVisible = true; break; } } if (!focusedMessageVisible) { this.setState({ focusedMessageKey: null }); } } const loader = _find({ key: 'loader' })(info.viewableItems); if (!loader || this.state.loadingFromScroll) { return; } this.setState({ loadingFromScroll: true }); void (async () => { try { await this.props.fetchMessages(); } finally { this.setState({ loadingFromScroll: false }); } })(); }; } const ConnectedMessageList: React.ComponentType = React.memo(function ConnectedMessageList(props: BaseProps) { const keyboardState = React.useContext(KeyboardContext); const overlayContext = React.useContext(OverlayContext); const threadID = props.threadInfo.id; const startReached = useSelector( state => !!( state.messageStore.threads[threadID] && state.messageStore.threads[threadID].startReached ), ); const styles = useStyles(unboundStyles); const indicatorStyle = useIndicatorStyle(); const fetchMessages = useFetchMessages(props.threadInfo); useWatchThread(props.threadInfo); return ( ); }); export default ConnectedMessageList; diff --git a/web/chat/chat-message-list.react.js b/web/chat/chat-message-list.react.js index 81a34f41c..bd44bfbbd 100644 --- a/web/chat/chat-message-list.react.js +++ b/web/chat/chat-message-list.react.js @@ -1,462 +1,465 @@ // @flow 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 { useThreadChatMentionCandidates } from 'lib/hooks/chat-mention-hooks.js'; import { type ChatMessageItem, useMessageListData, } from 'lib/selectors/chat-selectors.js'; import { messageKey, useFetchMessages } from 'lib/shared/message-utils.js'; import { threadIsPending, threadOtherMembers, } from 'lib/shared/thread-utils.js'; import type { ThreadInfo } from 'lib/types/minimally-encoded-thread-permissions-types.js'; import { threadTypes } from 'lib/types/thread-types-enum.js'; import { defaultMaxTextAreaHeight, editBoxHeight } 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'; import RelationshipPrompt from './relationship-prompt/relationship-prompt.js'; import { type InputState, InputStateContext } from '../input/input-state.js'; import LoadingIndicator from '../loading-indicator.react.js'; import { useTextMessageRulesFunc } from '../markdown/rules.react.js'; import { useSelector } from '../redux/redux-utils.js'; import { useTooltipContext } from '../tooltips/tooltip-provider.js'; const browser = detectBrowser(); 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, }; type Props = { ...BaseProps, +activeChatThreadID: ?string, +messageListData: ?$ReadOnlyArray, +startReached: boolean, +inputState: ?InputState, +clearTooltip: () => mixed, +isEditState: boolean, +addScrollToMessageListener: ScrollToMessageCallback => mixed, +removeScrollToMessageListener: ScrollToMessageCallback => mixed, +viewerID: ?string, +fetchMessages: () => Promise, }; type Snapshot = { +scrollTop: number, +scrollHeight: number, }; 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): ?Snapshot { if ( ChatMessageList.hasNewMessage(this.props, prevProps) && this.messageContainer ) { const { scrollTop, scrollHeight } = this.messageContainer; return { scrollTop, scrollHeight }; } return null; } static hasNewMessage(props: Props, prevProps: Props): boolean { 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; 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.props.isEditState) ) { 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 (supportsReverseFlex) { messageContainer.scrollTop = -1 * newScrollTop; } else { messageContainer.scrollTop = newScrollTop; } } } } scrollToBottom() { if (this.messageContainer) { this.messageContainer.scrollTop = 0; } } static keyExtractor(item: ChatMessageItem): string { if (item.itemType === 'loader') { return 'loader'; } return messageKey(item.messageInfo); } renderItem = (item: ChatMessageItem): React.Node => { if (item.itemType === 'loader') { return (
); } const { threadInfo } = this.props; invariant(threadInfo, 'ThreadInfo should be set if messageListData is'); return ( ); }; 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): boolean { 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(): React.Node { const { messageListData, threadInfo, inputState, isEditState } = this.props; if (!messageListData) { return
; } invariant(inputState, 'InputState should be set'); const messages = messageListData.map(this.renderItem); let relationshipPrompt = null; - if (threadInfo.type === threadTypes.GENESIS_PERSONAL) { + if ( + threadInfo.type === threadTypes.GENESIS_PERSONAL || + threadInfo.type === threadTypes.PERSONAL + ) { const otherMembers = threadOtherMembers( threadInfo.members, this.props.viewerID, ); let pendingPersonalThreadUserInfo; if (otherMembers.length === 1) { const otherMember = otherMembers[0]; pendingPersonalThreadUserInfo = { id: otherMember.id, username: otherMember.username, }; } relationshipPrompt = ( ); } const messageContainerStyle = classNames({ [css.disableAnchor]: this.state.scrollingEndCallback !== null || isEditState, [css.messageContainer]: true, [css.mirroredMessageContainer]: !supportsReverseFlex, }); return (
{relationshipPrompt}
{messages}
); } messageContainerRef = (messageContainer: ?HTMLDivElement) => { this.messageContainer = messageContainer; // In case we already have all the most recent messages, // but they're not enough void this.possiblyLoadMoreMessages(); if (messageContainer) { messageContainer.addEventListener('scroll', this.onScroll); } }; onScroll = () => { if (!this.messageContainer) { return; } this.props.clearTooltip(); void this.possiblyLoadMoreMessages(); this.debounceEditModeAfterScrollToMessage(); }; debounceEditModeAfterScrollToMessage: () => void = _debounce(() => { if (this.state.scrollingEndCallback) { this.state.scrollingEndCallback(); } this.setState({ scrollingEndCallback: null }); }, 100); async 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; try { await this.props.fetchMessages(); } finally { this.loadingFromScroll = false; } } } const ConnectedChatMessageList: React.ComponentType = React.memo(function ConnectedChatMessageList( props: BaseProps, ): React.Node { const { threadInfo } = props; const messageListData = useMessageListData({ threadInfo, searching: false, userInfoInputArray: [], }); const startReached = !!useSelector(state => { const activeID = threadInfo.id; if (!activeID) { return null; } if (threadIsPending(activeID)) { return true; } const threadMessageInfo = state.messageStore.threads[activeID]; if (!threadMessageInfo) { return null; } return threadMessageInfo.startReached; }); const fetchMessages = useFetchMessages(threadInfo); const inputState = React.useContext(InputStateContext); const { clearTooltip } = useTooltipContext(); const chatMentionCandidates = useThreadChatMentionCandidates(threadInfo); const getTextMessageMarkdownRules = useTextMessageRulesFunc( threadInfo, chatMentionCandidates, ); const messageListContext = React.useMemo(() => { if (!getTextMessageMarkdownRules) { return undefined; } return { getTextMessageMarkdownRules }; }, [getTextMessageMarkdownRules]); const { editState, addScrollToMessageListener, removeScrollToMessageListener, } = useEditModalContext(); const isEditState = editState !== null; const viewerID = useSelector(state => state.currentUserInfo?.id); return ( ); }); export default ConnectedChatMessageList;