diff --git a/web/app.react.js b/web/app.react.js index 7b3aa5140..5c842e0bc 100644 --- a/web/app.react.js +++ b/web/app.react.js @@ -1,380 +1,380 @@ // @flow import { config as faConfig } from '@fortawesome/fontawesome-svg-core'; import { faCalendar, faComments } from '@fortawesome/free-solid-svg-icons'; import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; import classNames from 'classnames'; import invariant from 'invariant'; import _isEqual from 'lodash/fp/isEqual'; import * as React from 'react'; import { DndProvider } from 'react-dnd'; import { HTML5Backend } from 'react-dnd-html5-backend'; import { useDispatch } from 'react-redux'; import { fetchEntriesActionTypes, updateCalendarQueryActionTypes, } from 'lib/actions/entry-actions'; import { createLoadingStatusSelector, combineLoadingStatuses, } from 'lib/selectors/loading-selectors'; import { mostRecentReadThreadSelector, unreadCount, } from 'lib/selectors/thread-selectors'; import { isLoggedIn } from 'lib/selectors/user-selectors'; import type { LoadingStatus } from 'lib/types/loading-types'; import type { Dispatch } from 'lib/types/redux-types'; import { verifyField, type ServerVerificationResult, } from 'lib/types/verify-types'; import { registerConfig } from 'lib/utils/config'; import AccountBar from './account-bar.react'; import Calendar from './calendar/calendar.react'; import Chat from './chat/chat.react'; import InputStateContainer from './input/input-state-container.react'; import LoadingIndicator from './loading-indicator.react'; import ResetPasswordModal from './modals/account/reset-password-modal.react'; import VerificationModal from './modals/account/verification-modal.react'; import FocusHandler from './redux/focus-handler.react'; import { type NavInfo, updateNavInfoActionType } from './redux/redux-setup'; import { useSelector } from './redux/redux-utils'; import VisibilityHandler from './redux/visibility-handler.react'; import history from './router-history'; import Splash from './splash/splash.react'; import css from './style.css'; import getTitle from './title/getTitle'; import { canonicalURLFromReduxState, navInfoFromURL } from './url-utils'; // We want Webpack's css-loader and style-loader to handle the Fontawesome CSS, // so we disable the autoAddCss logic and import the CSS file. Otherwise every // icon flashes huge for a second before the CSS is loaded. import '@fortawesome/fontawesome-svg-core/styles.css'; faConfig.autoAddCss = false; registerConfig({ // We can't securely cache credentials on web, so we have no way to recover // from a cookie invalidation resolveInvalidatedCookie: null, // We use httponly cookies on web to protect against XSS attacks, so we have // no access to the cookies from JavaScript setCookieOnRequest: false, setSessionIDOnRequest: true, // Never reset the calendar range calendarRangeInactivityLimit: null, platformDetails: { platform: 'web' }, }); type BaseProps = {| +location: { +pathname: string, ... }, |}; type Props = {| ...BaseProps, // Redux state +navInfo: NavInfo, +serverVerificationResult: ?ServerVerificationResult, +entriesLoadingStatus: LoadingStatus, +loggedIn: boolean, +mostRecentReadThread: ?string, +activeThreadCurrentlyUnread: boolean, +viewerID: ?string, +unreadCount: number, // Redux dispatch functions +dispatch: Dispatch, |}; type State = {| +currentModal: ?React.Node, |}; class App extends React.PureComponent { state: State = { currentModal: null, }; componentDidMount() { const { navInfo, serverVerificationResult } = this.props; if (navInfo.verify && serverVerificationResult) { if (serverVerificationResult.field === verifyField.RESET_PASSWORD) { this.showResetPasswordModal(); } else { const newURL = canonicalURLFromReduxState( { ...navInfo, verify: null }, this.props.location.pathname, ); history.replace(newURL); this.setModal(); } } if (this.props.loggedIn) { const newURL = canonicalURLFromReduxState( navInfo, this.props.location.pathname, ); if (this.props.location.pathname !== newURL) { history.replace(newURL); } } else if (this.props.location.pathname !== '/') { history.replace('/'); } } componentDidUpdate(prevProps: Props) { if (this.props.loggedIn) { if (this.props.location.pathname !== prevProps.location.pathname) { const newNavInfo = navInfoFromURL(this.props.location.pathname, { navInfo: this.props.navInfo, }); if (!_isEqual(newNavInfo)(this.props.navInfo)) { this.props.dispatch({ type: updateNavInfoActionType, payload: newNavInfo, }); } } else if (!_isEqual(this.props.navInfo)(prevProps.navInfo)) { const newURL = canonicalURLFromReduxState( this.props.navInfo, this.props.location.pathname, ); if (newURL !== this.props.location.pathname) { history.push(newURL); } } } const justLoggedIn = this.props.loggedIn && !prevProps.loggedIn; if (justLoggedIn) { const newURL = canonicalURLFromReduxState( this.props.navInfo, this.props.location.pathname, ); if (this.props.location.pathname !== newURL) { history.replace(newURL); } } const justLoggedOut = !this.props.loggedIn && prevProps.loggedIn; if (justLoggedOut && this.props.location.pathname !== '/') { history.replace('/'); } const { navInfo, serverVerificationResult } = this.props; if ( serverVerificationResult && serverVerificationResult.field === verifyField.RESET_PASSWORD ) { if (navInfo.verify && !prevProps.navInfo.verify) { this.showResetPasswordModal(); } else if (!navInfo.verify && prevProps.navInfo.verify) { this.clearModal(); } } } showResetPasswordModal() { const newURL = canonicalURLFromReduxState( { ...this.props.navInfo, verify: null, }, this.props.location.pathname, ); const onClose = () => history.push(newURL); const onSuccess = () => history.replace(newURL); this.setModal( , ); } render() { let content; if (this.props.loggedIn) { content = this.renderMainContent(); } else { content = ( ); } return ( {content} {this.state.currentModal} ); } renderMainContent() { const calendarNavClasses = classNames({ [css['current-tab']]: this.props.navInfo.tab === 'calendar', }); const chatNavClasses = classNames({ [css['current-tab']]: this.props.navInfo.tab === 'chat', }); let mainContent; if (this.props.navInfo.tab === 'calendar') { mainContent = ( ); } else if (this.props.navInfo.tab === 'chat') { mainContent = ; } const { viewerID, unreadCount: curUnreadCount } = this.props; invariant(viewerID, 'should be set'); let chatBadge = null; if (curUnreadCount > 0) { chatBadge =
{curUnreadCount}
; } return (
{mainContent}
); } setModal = (modal: ?React.Node) => { this.setState({ currentModal: modal }); }; clearModal = () => { this.setModal(null); }; onClickCalendar = (event: SyntheticEvent) => { event.preventDefault(); this.props.dispatch({ type: updateNavInfoActionType, payload: { tab: 'calendar' }, }); }; onClickChat = (event: SyntheticEvent) => { event.preventDefault(); this.props.dispatch({ type: updateNavInfoActionType, payload: { tab: 'chat', activeChatThreadID: this.props.activeThreadCurrentlyUnread ? this.props.mostRecentReadThread : this.props.navInfo.activeChatThreadID, }, }); }; } const fetchEntriesLoadingStatusSelector = createLoadingStatusSelector( fetchEntriesActionTypes, ); const updateCalendarQueryLoadingStatusSelector = createLoadingStatusSelector( updateCalendarQueryActionTypes, ); export default React.memo(function ConnectedApp(props: BaseProps) { const activeChatThreadID = useSelector( (state) => state.navInfo.activeChatThreadID, ); const navInfo = useSelector((state) => state.navInfo); const serverVerificationResult = useSelector( (state) => state.serverVerificationResult, ); const fetchEntriesLoadingStatus = useSelector( fetchEntriesLoadingStatusSelector, ); const updateCalendarQueryLoadingStatus = useSelector( updateCalendarQueryLoadingStatusSelector, ); const entriesLoadingStatus = combineLoadingStatuses( fetchEntriesLoadingStatus, updateCalendarQueryLoadingStatus, ); const loggedIn = useSelector(isLoggedIn); const mostRecentReadThread = useSelector(mostRecentReadThreadSelector); const activeThreadCurrentlyUnread = useSelector( (state) => !activeChatThreadID || - !!state.threadStore.threadInfos[activeChatThreadID].currentUser.unread, + !!state.threadStore.threadInfos[activeChatThreadID]?.currentUser.unread, ); const viewerID = useSelector( (state) => state.currentUserInfo && state.currentUserInfo.id, ); const boundUnreadCount = useSelector(unreadCount); React.useEffect(() => { document.title = getTitle(boundUnreadCount); }, [boundUnreadCount]); const dispatch = useDispatch(); return ( ); }); diff --git a/web/chat/chat-message-list.react.js b/web/chat/chat-message-list.react.js index 2d7c9ffe9..8e2389b0b 100644 --- a/web/chat/chat-message-list.react.js +++ b/web/chat/chat-message-list.react.js @@ -1,430 +1,434 @@ // @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, } from 'lib/actions/message-actions'; import { registerFetchKey } from 'lib/reducers/loading-reducer'; import { type ChatMessageItem } from 'lib/selectors/chat-selectors'; import { threadInfoSelector } from 'lib/selectors/thread-selectors'; import { messageKey } from 'lib/shared/message-utils'; import { useWatchThread } 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 { webMessageListData } from '../selectors/chat-selectors'; import ChatInputBar from './chat-input-bar.react'; import css from './chat-message-list.css'; import { MessageListContext } from './message-list-types'; import type { OnMessagePositionInfo, MessagePositionInfo, } from './message-position-types'; import MessageTimestampTooltip from './message-timestamp-tooltip.react'; import Message from './message.react'; type BaseProps = {| +setModal: (modal: ?React.Node) => void, |}; type PassedProps = {| ...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, // withInputState +inputState: ?InputState, |}; type ReactDnDProps = {| isActive: boolean, connectDropTarget: (node: React.Node) => React.Node, |}; type Props = {| ...PassedProps, ...ReactDnDProps, |}; type State = {| +mouseOverMessagePosition: ?OnMessagePositionInfo, |}; 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, setModal } = 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 containerTop = this.messageContainer.getBoundingClientRect().top; const mouseOverMessagePosition = { ...messagePositionInfo, messagePosition: { ...messagePositionInfo.messagePosition, top: messagePositionInfo.messagePosition.top - containerTop, bottom: messagePositionInfo.messagePosition.bottom - containerTop, }, }; this.setState({ mouseOverMessagePosition }); }; render() { const { messageListData, threadInfo, inputState, connectDropTarget, isActive, } = 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, }); const tooltip = ( ); const messageContainerStyle = classNames({ [css.messageContainer]: true, [css.mirroredMessageContainer]: !this.props.supportsReverseFlex, }); return connectDropTarget(
{messages} {tooltip}
, ); } 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; } const oldestMessageServerID = this.oldestMessageServerID(); if (!oldestMessageServerID) { return; } const threadID = this.props.activeChatThreadID; invariant(threadID, 'should be set'); this.loadingFromScroll = true; this.props.dispatchActionPromise( fetchMessagesBeforeCursorActionTypes, this.props.fetchMessagesBeforeCursor(threadID, oldestMessageServerID), ); } 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); export default React.memo(function ConnectedChatMessageList( props: BaseProps, ) { 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 messageListData = useSelector(webMessageListData); const timeZone = useSelector((state) => state.timeZone); const activeChatThreadID = useSelector( (state) => state.navInfo.activeChatThreadID, ); const threadInfo = useSelector((state) => { const activeID = state.navInfo.activeChatThreadID; if (!activeID) { return null; } - return threadInfoSelector(state)[activeID]; + return threadInfoSelector(state)[activeID] ?? state.navInfo.pendingThread; }); const startReached = useSelector((state) => { const activeID = state.navInfo.activeChatThreadID; if (!activeID) { return null; } + if (state.navInfo.pendingThread) { + return true; + } + const threadMessageInfo = state.messageStore.threads[activeID]; if (!threadMessageInfo) { return null; } return threadMessageInfo.startReached; }); const dispatchActionPromise = useDispatchActionPromise(); const callFetchMessagesBeforeCursor = useServerCall( fetchMessagesBeforeCursor, ); 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 messageListContext = React.useMemo(() => { if (!getTextMessageMarkdownRules) { return undefined; } return { getTextMessageMarkdownRules }; }, [getTextMessageMarkdownRules]); useWatchThread(threadInfo); return ( ); }); diff --git a/web/redux/redux-setup.js b/web/redux/redux-setup.js index 31885404d..7cff180d1 100644 --- a/web/redux/redux-setup.js +++ b/web/redux/redux-setup.js @@ -1,213 +1,220 @@ // @flow import invariant from 'invariant'; import PropTypes from 'prop-types'; import { logOutActionTypes, deleteAccountActionTypes, } from 'lib/actions/user-actions'; import baseReducer from 'lib/reducers/master-reducer'; import { mostRecentReadThreadSelector } from 'lib/selectors/thread-selectors'; import { invalidSessionDowngrade } from 'lib/shared/account-utils'; import type { Shape } from 'lib/types/core'; import type { EntryStore } from 'lib/types/entry-types'; import type { CalendarFilter } from 'lib/types/filter-types'; import type { LoadingStatus } from 'lib/types/loading-types'; import type { MessageStore } from 'lib/types/message-types'; import type { BaseNavInfo } from 'lib/types/nav-types'; import type { BaseAction } from 'lib/types/redux-types'; import type { ClientReportCreationRequest } from 'lib/types/report-types'; import type { ConnectionInfo } from 'lib/types/socket-types'; -import type { ThreadStore } from 'lib/types/thread-types'; +import type { ThreadInfo, ThreadStore } from 'lib/types/thread-types'; +import { threadInfoPropType } from 'lib/types/thread-types'; import type { CurrentUserInfo, UserStore } from 'lib/types/user-types'; import type { ServerVerificationResult } from 'lib/types/verify-types'; import { setNewSessionActionType } from 'lib/utils/action-utils'; import { activeThreadSelector } from '../selectors/nav-selectors'; import { updateWindowActiveActionType } from './action-types'; import { getVisibility } from './visibility'; export type NavInfo = {| ...$Exact, +tab: 'calendar' | 'chat', +verify: ?string, +activeChatThreadID: ?string, + +pendingThread?: ThreadInfo, + +sourceMessageID?: string, |}; export const navInfoPropType = PropTypes.shape({ startDate: PropTypes.string.isRequired, endDate: PropTypes.string.isRequired, tab: PropTypes.oneOf(['calendar', 'chat']).isRequired, verify: PropTypes.string, activeChatThreadID: PropTypes.string, + pendingThread: threadInfoPropType, + sourceMessageID: PropTypes.string, }); export type WindowDimensions = {| width: number, height: number |}; export type AppState = {| navInfo: NavInfo, currentUserInfo: ?CurrentUserInfo, sessionID: ?string, serverVerificationResult: ?ServerVerificationResult, entryStore: EntryStore, threadStore: ThreadStore, userStore: UserStore, messageStore: MessageStore, updatesCurrentAsOf: number, loadingStatuses: { [key: string]: { [idx: number]: LoadingStatus } }, calendarFilters: $ReadOnlyArray, urlPrefix: string, windowDimensions: WindowDimensions, cookie?: void, deviceToken?: void, baseHref: string, connection: ConnectionInfo, watchedThreadIDs: $ReadOnlyArray, foreground: boolean, nextLocalID: number, queuedReports: $ReadOnlyArray, timeZone: ?string, userAgent: ?string, dataLoaded: boolean, windowActive: boolean, |}; export const updateNavInfoActionType = 'UPDATE_NAV_INFO'; export const updateWindowDimensions = 'UPDATE_WINDOW_DIMENSIONS'; export type Action = | BaseAction | {| type: 'UPDATE_NAV_INFO', payload: Shape |} | {| type: 'UPDATE_WINDOW_DIMENSIONS', payload: WindowDimensions, |} | {| type: 'UPDATE_WINDOW_ACTIVE', payload: boolean, |}; export function reducer(oldState: AppState | void, action: Action) { invariant(oldState, 'should be set'); let state = oldState; if (action.type === updateNavInfoActionType) { return validateState(oldState, { ...state, navInfo: { ...state.navInfo, ...action.payload, }, }); } else if (action.type === updateWindowDimensions) { return validateState(oldState, { ...state, windowDimensions: action.payload, }); } else if (action.type === updateWindowActiveActionType) { return validateState(oldState, { ...state, windowActive: action.payload, }); } else if (action.type === setNewSessionActionType) { if ( invalidSessionDowngrade( oldState, action.payload.sessionChange.currentUserInfo, action.payload.preRequestUserState, ) ) { return oldState; } state = { ...state, sessionID: action.payload.sessionChange.sessionID, }; } else if ( (action.type === logOutActionTypes.success && invalidSessionDowngrade( oldState, action.payload.currentUserInfo, action.payload.preRequestUserState, )) || (action.type === deleteAccountActionTypes.success && invalidSessionDowngrade( oldState, action.payload.currentUserInfo, action.payload.preRequestUserState, )) ) { return oldState; } return validateState(oldState, baseReducer(state, action)); } function validateState(oldState: AppState, state: AppState): AppState { if ( state.navInfo.activeChatThreadID && + !state.navInfo.pendingThread && !state.threadStore.threadInfos[state.navInfo.activeChatThreadID] ) { // Makes sure the active thread always exists state = { ...state, navInfo: { ...state.navInfo, activeChatThreadID: mostRecentReadThreadSelector(state), }, }; } const activeThread = activeThreadSelector(state); if ( activeThread && !getVisibility().hidden() && typeof document !== 'undefined' && document && document.hasFocus && document.hasFocus() && + !state.navInfo.pendingThread && state.threadStore.threadInfos[activeThread].currentUser.unread ) { // Makes sure a currently focused thread is never unread state = { ...state, threadStore: { ...state.threadStore, threadInfos: { ...state.threadStore.threadInfos, [activeThread]: { ...state.threadStore.threadInfos[activeThread], currentUser: { ...state.threadStore.threadInfos[activeThread].currentUser, unread: false, }, }, }, }, }; } const oldActiveThread = activeThreadSelector(oldState); if ( activeThread && oldActiveThread !== activeThread && state.messageStore.threads[activeThread] ) { // Update messageStore.threads[activeThread].lastNavigatedTo state = { ...state, messageStore: { ...state.messageStore, threads: { ...state.messageStore.threads, [activeThread]: { ...state.messageStore.threads[activeThread], lastNavigatedTo: Date.now(), }, }, }, }; } return state; } diff --git a/web/selectors/nav-selectors.js b/web/selectors/nav-selectors.js index 98e96a6d6..b261e34c8 100644 --- a/web/selectors/nav-selectors.js +++ b/web/selectors/nav-selectors.js @@ -1,150 +1,187 @@ // @flow import invariant from 'invariant'; import * as React from 'react'; import { useDispatch } from 'react-redux'; import { createSelector } from 'reselect'; import { nonThreadCalendarFiltersSelector } from 'lib/selectors/calendar-filter-selectors'; import { currentCalendarQuery } from 'lib/selectors/nav-selectors'; +import { createPendingSidebar } from 'lib/shared/thread-utils'; import type { CalendarQuery } from 'lib/types/entry-types'; import type { CalendarFilter } from 'lib/types/filter-types'; +import type { + ComposableMessageInfo, + RobotextMessageInfo, +} from 'lib/types/message-types'; +import type { ThreadInfo } from 'lib/types/thread-types'; import type { AppState } from '../redux/redux-setup'; import { updateNavInfoActionType } from '../redux/redux-setup'; import { useSelector } from '../redux/redux-utils'; const dateExtractionRegex = /^([0-9]{4})-([0-9]{2})-[0-9]{2}$/; function yearExtractor(startDate: string, endDate: string): ?number { const startDateResults = dateExtractionRegex.exec(startDate); const endDateResults = dateExtractionRegex.exec(endDate); if ( !startDateResults || !startDateResults[1] || !endDateResults || !endDateResults[1] || startDateResults[1] !== endDateResults[1] ) { return null; } return parseInt(startDateResults[1], 10); } function yearAssertingExtractor(startDate: string, endDate: string): number { const result = yearExtractor(startDate, endDate); invariant( result !== null && result !== undefined, `${startDate} and ${endDate} aren't in the same year`, ); return result; } const yearAssertingSelector: (state: AppState) => number = createSelector( (state: AppState) => state.navInfo.startDate, (state: AppState) => state.navInfo.endDate, yearAssertingExtractor, ); // 1-indexed function monthExtractor(startDate: string, endDate: string): ?number { const startDateResults = dateExtractionRegex.exec(startDate); const endDateResults = dateExtractionRegex.exec(endDate); if ( !startDateResults || !startDateResults[1] || !startDateResults[2] || !endDateResults || !endDateResults[1] || !endDateResults[2] || startDateResults[1] !== endDateResults[1] || startDateResults[2] !== endDateResults[2] ) { return null; } return parseInt(startDateResults[2], 10); } // 1-indexed function monthAssertingExtractor(startDate: string, endDate: string): number { const result = monthExtractor(startDate, endDate); invariant( result !== null && result !== undefined, `${startDate} and ${endDate} aren't in the same month`, ); return result; } // 1-indexed const monthAssertingSelector: (state: AppState) => number = createSelector( (state: AppState) => state.navInfo.startDate, (state: AppState) => state.navInfo.endDate, monthAssertingExtractor, ); function activeThreadSelector(state: AppState): ?string { return state.navInfo.tab === 'chat' ? state.navInfo.activeChatThreadID : null; } const webCalendarQuery: ( state: AppState, ) => () => CalendarQuery = createSelector( currentCalendarQuery, (state: AppState) => state.navInfo.tab === 'calendar', ( calendarQuery: (calendarActive: boolean) => CalendarQuery, calendarActive: boolean, ) => () => calendarQuery(calendarActive), ); const nonThreadCalendarQuery: ( state: AppState, ) => () => CalendarQuery = createSelector( webCalendarQuery, nonThreadCalendarFiltersSelector, ( calendarQuery: () => CalendarQuery, filters: $ReadOnlyArray, ) => { return (): CalendarQuery => { const query = calendarQuery(); return { startDate: query.startDate, endDate: query.endDate, filters, }; }; }, ); function useOnClickThread(threadID: string) { const dispatch = useDispatch(); return React.useCallback( (event: SyntheticEvent) => { event.preventDefault(); dispatch({ type: updateNavInfoActionType, payload: { activeChatThreadID: threadID, }, }); }, [dispatch, threadID], ); } function useThreadIsActive(threadID: string) { return useSelector((state) => threadID === state.navInfo.activeChatThreadID); } +function useOnClickPendingSidebar( + messageInfo: ComposableMessageInfo | RobotextMessageInfo, + threadInfo: ThreadInfo, +) { + const dispatch = useDispatch(); + const viewerID = useSelector((state) => state.currentUserInfo?.id); + return React.useCallback( + (event: SyntheticEvent) => { + event.preventDefault(); + if (!viewerID) { + return; + } + const pendingSidebarInfo = createPendingSidebar( + messageInfo, + threadInfo, + viewerID, + ); + dispatch({ + type: updateNavInfoActionType, + payload: { + activeChatThreadID: pendingSidebarInfo.id, + pendingThread: pendingSidebarInfo, + sourceMessageID: messageInfo.id, + }, + }); + }, + [viewerID, messageInfo, threadInfo, dispatch], + ); +} + export { yearExtractor, yearAssertingSelector, monthExtractor, monthAssertingSelector, activeThreadSelector, webCalendarQuery, nonThreadCalendarQuery, useOnClickThread, useThreadIsActive, + useOnClickPendingSidebar, };