diff --git a/web/chat/chat-message-list.react.js b/web/chat/chat-message-list.react.js index 5a10707cd..4070301a4 100644 --- a/web/chat/chat-message-list.react.js +++ b/web/chat/chat-message-list.react.js @@ -1,486 +1,512 @@ // @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 type { FetchMessagesBeforeCursorInput, FetchMostRecentMessagesInput, } from 'lib/actions/message-actions.js'; import { fetchMessagesBeforeCursorActionTypes, fetchMostRecentMessagesActionTypes, useFetchMessagesBeforeCursor, useFetchMostRecentMessages, } from 'lib/actions/message-actions.js'; import { useThreadChatMentionCandidates } from 'lib/hooks/chat-mention-hooks.js'; import { useOldestMessageServerID } from 'lib/hooks/message-hooks.js'; import { registerFetchKey } from 'lib/reducers/loading-reducer.js'; import { type ChatMessageItem, useMessageListData, } from 'lib/selectors/chat-selectors.js'; import { messageKey } from 'lib/shared/message-utils.js'; -import { threadIsPending } from 'lib/shared/thread-utils.js'; +import { + threadIsPending, + threadOtherMembers, +} from 'lib/shared/thread-utils.js'; import type { FetchMessageInfosPayload } from 'lib/types/message-types.js'; import type { ThreadInfo } from 'lib/types/minimally-encoded-thread-permissions-types.js'; import { threadTypes } from 'lib/types/thread-types-enum.js'; import { type DispatchActionPromise, useDispatchActionPromise, } from 'lib/utils/redux-promise-utils.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, +dispatchActionPromise: DispatchActionPromise, +fetchMessagesBeforeCursor: ( input: FetchMessagesBeforeCursorInput, ) => Promise, +fetchMostRecentMessages: ( input: FetchMostRecentMessagesInput, ) => Promise, +inputState: ?InputState, +clearTooltip: () => mixed, +oldestMessageServerID: ?string, +isEditState: boolean, +addScrollToMessageListener: ScrollToMessageCallback => mixed, +removeScrollToMessageListener: ScrollToMessageCallback => mixed, + +viewerID: ?string, }; 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.PERSONAL) { - relationshipPrompt = ; + 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; const threadID = this.props.activeChatThreadID; invariant(threadID, 'should be set'); try { const { oldestMessageServerID } = this.props; if (oldestMessageServerID) { await this.props.dispatchActionPromise( fetchMessagesBeforeCursorActionTypes, this.props.fetchMessagesBeforeCursor({ threadID, beforeMessageID: oldestMessageServerID, }), ); } else { await this.props.dispatchActionPromise( fetchMostRecentMessagesActionTypes, this.props.fetchMostRecentMessages({ threadID }), ); } } finally { this.loadingFromScroll = false; } } } registerFetchKey(fetchMessagesBeforeCursorActionTypes); registerFetchKey(fetchMostRecentMessagesActionTypes); 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 dispatchActionPromise = useDispatchActionPromise(); const callFetchMessagesBeforeCursor = useFetchMessagesBeforeCursor(); const callFetchMostRecentMessages = useFetchMostRecentMessages(); 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 oldestMessageServerID = useOldestMessageServerID(threadInfo.id); const { editState, addScrollToMessageListener, removeScrollToMessageListener, } = useEditModalContext(); const isEditState = editState !== null; + const viewerID = useSelector(state => state.currentUserInfo?.id); + return ( ); }); export default ConnectedChatMessageList; diff --git a/web/chat/relationship-prompt/relationship-prompt.js b/web/chat/relationship-prompt/relationship-prompt.js index a67482ce0..3b20204c4 100644 --- a/web/chat/relationship-prompt/relationship-prompt.js +++ b/web/chat/relationship-prompt/relationship-prompt.js @@ -1,110 +1,117 @@ // @flow import { faUserMinus, faUserPlus, faUserShield, faUserSlash, } from '@fortawesome/free-solid-svg-icons'; import * as React from 'react'; import { useRelationshipPrompt } from 'lib/hooks/relationship-prompt.js'; import type { ThreadInfo } from 'lib/types/minimally-encoded-thread-permissions-types.js'; import { userRelationshipStatus } from 'lib/types/relationship-types.js'; +import type { UserInfo } from 'lib/types/user-types.js'; import RelationshipPromptButtonContainer from './relationship-prompt-button-container.js'; import RelationshipPromptButton from './relationship-prompt-button.js'; import { buttonThemes } from '../../components/button.react.js'; -type Props = { +threadInfo: ThreadInfo }; - +type Props = { + +pendingPersonalThreadUserInfo: ?UserInfo, + +threadInfo: ThreadInfo, +}; function RelationshipPrompt(props: Props) { const { threadInfo } = props; const { otherUserInfo, callbacks: { blockUser, unblockUser, friendUser, unfriendUser }, - } = useRelationshipPrompt(threadInfo); + } = useRelationshipPrompt( + threadInfo, + undefined, + props.pendingPersonalThreadUserInfo, + ); if (!otherUserInfo?.username) { return null; } const relationshipStatus = otherUserInfo.relationshipStatus; if (relationshipStatus === userRelationshipStatus.FRIEND) { return null; } else if (relationshipStatus === userRelationshipStatus.BLOCKED_VIEWER) { return ( ); } else if ( relationshipStatus === userRelationshipStatus.BOTH_BLOCKED || relationshipStatus === userRelationshipStatus.BLOCKED_BY_VIEWER ) { return ( ); } else if (relationshipStatus === userRelationshipStatus.REQUEST_RECEIVED) { return ( ); } else if (relationshipStatus === userRelationshipStatus.REQUEST_SENT) { return ( ); } else { return ( ); } } const MemoizedRelationshipPrompt: React.ComponentType = React.memo(RelationshipPrompt); export default MemoizedRelationshipPrompt;