diff --git a/lib/types/nav-types.js b/lib/types/nav-types.js index 8774f91b1..ae392a925 100644 --- a/lib/types/nav-types.js +++ b/lib/types/nav-types.js @@ -1,6 +1,7 @@ // @flow export type BaseNavInfo = { - startDate: string, - endDate: string, + +startDate: string, + +endDate: string, + ... }; diff --git a/web/app.react.js b/web/app.react.js index 79fae9076..af9b2d918 100644 --- a/web/app.react.js +++ b/web/app.react.js @@ -1,356 +1,354 @@ // @flow import type { LoadingStatus } from 'lib/types/loading-types'; import type { DispatchActionPayload } from 'lib/utils/action-utils'; import { verifyField, type ServerVerificationResult, serverVerificationResultPropType, } from 'lib/types/verify-types'; import * as React from 'react'; import invariant from 'invariant'; import _isEqual from 'lodash/fp/isEqual'; import PropTypes from 'prop-types'; import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; import { faCalendar, faComments } from '@fortawesome/free-solid-svg-icons'; import { config as faConfig } from '@fortawesome/fontawesome-svg-core'; import classNames from 'classnames'; import { HTML5Backend } from 'react-dnd-html5-backend'; import { DndProvider } from 'react-dnd'; import { fetchEntriesActionTypes, updateCalendarQueryActionTypes, } from 'lib/actions/entry-actions'; import { createLoadingStatusSelector, combineLoadingStatuses, } from 'lib/selectors/loading-selectors'; import { connect } from 'lib/utils/redux-utils'; import { registerConfig } from 'lib/utils/config'; import { mostRecentReadThreadSelector, unreadCount, } from 'lib/selectors/thread-selectors'; import { isLoggedIn } from 'lib/selectors/user-selectors'; import { canonicalURLFromReduxState, navInfoFromURL } from './url-utils'; import css from './style.css'; import AccountBar from './account-bar.react'; import Calendar from './calendar/calendar.react'; import ResetPasswordModal from './modals/account/reset-password-modal.react'; import VerificationModal from './modals/account/verification-modal.react'; import LoadingIndicator from './loading-indicator.react'; import history from './router-history'; import { type AppState, type NavInfo, navInfoPropType, updateNavInfoActionType, } from './redux/redux-setup'; import Splash from './splash/splash.react'; import Chat from './chat/chat.react'; import VisibilityHandler from './redux/visibility-handler.react'; import FocusHandler from './redux/focus-handler.react'; // 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 Props = { location: { pathname: string, }, // Redux state navInfo: NavInfo, serverVerificationResult: ?ServerVerificationResult, entriesLoadingStatus: LoadingStatus, loggedIn: boolean, mostRecentReadThread: ?string, activeThreadCurrentlyUnread: boolean, viewerID: ?string, unreadCount: number, // Redux dispatch functions dispatchActionPayload: DispatchActionPayload, }; type State = {| currentModal: ?React.Node, |}; class App extends React.PureComponent { static propTypes = { location: PropTypes.shape({ pathname: PropTypes.string.isRequired, }).isRequired, navInfo: navInfoPropType.isRequired, serverVerificationResult: serverVerificationResultPropType, entriesLoadingStatus: PropTypes.string.isRequired, loggedIn: PropTypes.bool.isRequired, mostRecentReadThread: PropTypes.string, activeThreadCurrentlyUnread: PropTypes.bool.isRequired, viewerID: PropTypes.string, unreadCount: PropTypes.number.isRequired, dispatchActionPayload: PropTypes.func.isRequired, }; 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.dispatchActionPayload(updateNavInfoActionType, 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.dispatchActionPayload(updateNavInfoActionType, { - ...this.props.navInfo, tab: 'calendar', }); }; onClickChat = (event: SyntheticEvent) => { event.preventDefault(); this.props.dispatchActionPayload(updateNavInfoActionType, { - ...this.props.navInfo, tab: 'chat', activeChatThreadID: this.props.activeThreadCurrentlyUnread ? this.props.mostRecentReadThread : this.props.navInfo.activeChatThreadID, }); }; } const fetchEntriesLoadingStatusSelector = createLoadingStatusSelector( fetchEntriesActionTypes, ); const updateCalendarQueryLoadingStatusSelector = createLoadingStatusSelector( updateCalendarQueryActionTypes, ); export default connect( (state: AppState) => { const activeChatThreadID = state.navInfo.activeChatThreadID; return { navInfo: state.navInfo, serverVerificationResult: state.serverVerificationResult, entriesLoadingStatus: combineLoadingStatuses( fetchEntriesLoadingStatusSelector(state), updateCalendarQueryLoadingStatusSelector(state), ), loggedIn: isLoggedIn(state), mostRecentReadThread: mostRecentReadThreadSelector(state), activeThreadCurrentlyUnread: !activeChatThreadID || state.threadStore.threadInfos[activeChatThreadID].currentUser.unread, viewerID: state.currentUserInfo && state.currentUserInfo.id, unreadCount: unreadCount(state), }; }, null, true, )(App); diff --git a/web/chat/chat-thread-list-item.react.js b/web/chat/chat-thread-list-item.react.js index 7d007c2d6..2c23db12c 100644 --- a/web/chat/chat-thread-list-item.react.js +++ b/web/chat/chat-thread-list-item.react.js @@ -1,75 +1,75 @@ // @flow import type { ChatThreadItem } from 'lib/selectors/chat-selectors'; import { updateNavInfoActionType } from '../redux/redux-setup'; import * as React from 'react'; import classNames from 'classnames'; import { useDispatch } from 'react-redux'; import { shortAbsoluteDate } from 'lib/utils/date-utils'; import css from './chat-thread-list.css'; import MessagePreview from './message-preview.react'; import ChatThreadListItemMenu from './chat-thread-list-item-menu.react'; import { useSelector } from '../redux/redux-utils'; type Props = {| +item: ChatThreadItem, |}; function ChatThreadListItem(props: Props) { const { item } = props; const threadID = item.threadInfo.id; - const navInfo = useSelector((state) => state.navInfo); const dispatch = useDispatch(); const onClick = React.useCallback( (event: SyntheticEvent) => { event.preventDefault(); dispatch({ type: updateNavInfoActionType, payload: { - ...navInfo, activeChatThreadID: threadID, }, }); }, - [dispatch, navInfo, threadID], + [dispatch, threadID], ); const timeZone = useSelector((state) => state.timeZone); const lastActivity = shortAbsoluteDate(item.lastUpdatedTime, timeZone); - const active = threadID === navInfo.activeChatThreadID; + const active = useSelector( + (state) => threadID === state.navInfo.activeChatThreadID, + ); const activeStyle = active ? css.activeThread : null; const colorSplotchStyle = { backgroundColor: `#${item.threadInfo.color}` }; const unread = item.threadInfo.currentUser.unread; return (
); } export default ChatThreadListItem; diff --git a/web/chat/robotext-message.react.js b/web/chat/robotext-message.react.js index 928e8efe9..03db19967 100644 --- a/web/chat/robotext-message.react.js +++ b/web/chat/robotext-message.react.js @@ -1,147 +1,138 @@ // @flow import { type RobotextChatMessageInfoItem, chatMessageItemPropType, } from 'lib/selectors/chat-selectors'; import type { DispatchActionPayload } from 'lib/utils/action-utils'; -import { - type AppState, - type NavInfo, - navInfoPropType, - updateNavInfoActionType, -} from '../redux/redux-setup'; +import { type AppState, updateNavInfoActionType } from '../redux/redux-setup'; import { type ThreadInfo, threadInfoPropType } from 'lib/types/thread-types'; import type { MessagePositionInfo } from './message-position-types'; import * as React from 'react'; import PropTypes from 'prop-types'; import { splitRobotext, parseRobotextEntity } from 'lib/shared/message-utils'; import { threadInfoSelector } from 'lib/selectors/thread-selectors'; import { connect } from 'lib/utils/redux-utils'; import css from './chat-message-list.css'; import Markdown from '../markdown/markdown.react'; import { linkRules } from '../markdown/rules.react'; type Props = {| item: RobotextChatMessageInfoItem, setMouseOverMessagePosition: ( messagePositionInfo: MessagePositionInfo, ) => void, |}; class RobotextMessage extends React.PureComponent { static propTypes = { item: chatMessageItemPropType.isRequired, setMouseOverMessagePosition: PropTypes.func.isRequired, }; render() { return (
{this.linkedRobotext()}
); } linkedRobotext() { const { item } = this.props; const { robotext } = item; const robotextParts = splitRobotext(robotext); const textParts = []; let keyIndex = 0; for (let splitPart of robotextParts) { if (splitPart === '') { continue; } if (splitPart.charAt(0) !== '<') { const key = `text${keyIndex++}`; textParts.push( {decodeURI(splitPart)} , ); continue; } const { rawText, entityType, id } = parseRobotextEntity(splitPart); if (entityType === 't' && id !== item.messageInfo.threadID) { textParts.push(); } else if (entityType === 'c') { textParts.push(); } else { textParts.push(rawText); } } return textParts; } onMouseEnter = (event: SyntheticEvent) => { const { item } = this.props; const rect = event.currentTarget.getBoundingClientRect(); const { top, bottom, left, right, height, width } = rect; const messagePosition = { top, bottom, left, right, height, width }; this.props.setMouseOverMessagePosition({ type: 'on', item, messagePosition, }); }; onMouseLeave = () => { const { item } = this.props; this.props.setMouseOverMessagePosition({ type: 'off', item }); }; } type InnerThreadEntityProps = { id: string, name: string, // Redux state threadInfo: ThreadInfo, - navInfo: NavInfo, // Redux dispatch functions dispatchActionPayload: DispatchActionPayload, }; class InnerThreadEntity extends React.PureComponent { static propTypes = { id: PropTypes.string.isRequired, name: PropTypes.string.isRequired, threadInfo: threadInfoPropType.isRequired, - navInfo: navInfoPropType.isRequired, dispatchActionPayload: PropTypes.func.isRequired, }; render() { return {this.props.name}; } onClickThread = (event: SyntheticEvent) => { event.preventDefault(); const id = this.props.id; this.props.dispatchActionPayload(updateNavInfoActionType, { - ...this.props.navInfo, activeChatThreadID: id, }); }; } const ThreadEntity = connect( (state: AppState, ownProps: { id: string }) => ({ threadInfo: threadInfoSelector(state)[ownProps.id], - navInfo: state.navInfo, }), null, true, )(InnerThreadEntity); function ColorEntity(props: {| color: string |}) { const colorStyle = { color: props.color }; return {props.color}; } export default RobotextMessage; diff --git a/web/redux/redux-setup.js b/web/redux/redux-setup.js index d1a2b9d86..f2d0290f2 100644 --- a/web/redux/redux-setup.js +++ b/web/redux/redux-setup.js @@ -1,210 +1,213 @@ // @flow import type { BaseNavInfo } from 'lib/types/nav-types'; import type { ThreadStore } from 'lib/types/thread-types'; import type { EntryStore } from 'lib/types/entry-types'; import type { BaseAction } from 'lib/types/redux-types'; import type { LoadingStatus } from 'lib/types/loading-types'; import type { CurrentUserInfo, UserStore } from 'lib/types/user-types'; import type { ServerVerificationResult } from 'lib/types/verify-types'; import type { MessageStore } from 'lib/types/message-types'; import type { CalendarFilter } from 'lib/types/filter-types'; import { setNewSessionActionType } from 'lib/utils/action-utils'; import type { ConnectionInfo } from 'lib/types/socket-types'; import type { ClientReportCreationRequest } from 'lib/types/report-types'; import PropTypes from 'prop-types'; import invariant from 'invariant'; import baseReducer from 'lib/reducers/master-reducer'; import { mostRecentReadThreadSelector } from 'lib/selectors/thread-selectors'; import { invalidSessionDowngrade } from 'lib/shared/account-utils'; import { logOutActionTypes, deleteAccountActionTypes, } from 'lib/actions/user-actions'; 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, + +tab: 'calendar' | 'chat', + +verify: ?string, + +activeChatThreadID: ?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, }); 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: NavInfo |} + | {| 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: action.payload, + 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.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.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; }