diff --git a/web/app.react.js b/web/app.react.js index 29e38efea..d445ffdf3 100644 --- a/web/app.react.js +++ b/web/app.react.js @@ -1,283 +1,284 @@ // @flow import 'basscss/css/basscss.min.css'; import './theme.css'; import { config as faConfig } from '@fortawesome/fontawesome-svg-core'; 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 { 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 { registerConfig } from 'lib/utils/config'; import AppsDirectory from './apps/apps-directory.react'; import Calendar from './calendar/calendar.react'; import Chat from './chat/chat.react'; import NavigationArrows from './components/navigation-arrows.react'; import InputStateContainer from './input/input-state-container.react'; import LoadingIndicator from './loading-indicator.react'; import { MenuProvider } from './menu-provider.react'; import { ModalProvider, useModalContext } from './modals/modal-provider.react'; +import { updateNavInfoActionType } from './redux/action-types'; import DeviceIDUpdater from './redux/device-id-updater'; import DisconnectedBar from './redux/disconnected-bar'; import DisconnectedBarVisibilityHandler from './redux/disconnected-bar-visibility-handler'; import FocusHandler from './redux/focus-handler.react'; import { useSelector } from './redux/redux-utils'; import VisibilityHandler from './redux/visibility-handler.react'; import history from './router-history'; import AccountSettings from './settings/account-settings.react'; import DangerZone from './settings/danger-zone.react'; import LeftLayoutAside from './sidebar/left-layout-aside.react'; import Splash from './splash/splash.react'; import './typography.css'; import css from './style.css'; import getTitle from './title/getTitle'; -import { type NavInfo, updateNavInfoActionType } from './types/nav-types'; +import { type NavInfo } from './types/nav-types'; 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, +entriesLoadingStatus: LoadingStatus, +loggedIn: boolean, +activeThreadCurrentlyUnread: boolean, // Redux dispatch functions +dispatch: Dispatch, +modals: $ReadOnlyArray, }; class App extends React.PureComponent { componentDidMount() { const { navInfo, location: { pathname }, loggedIn, } = this.props; const newURL = canonicalURLFromReduxState(navInfo, pathname, loggedIn); if (pathname !== newURL) { history.replace(newURL); } } componentDidUpdate(prevProps: Props) { const { navInfo, location: { pathname }, loggedIn, } = this.props; if (!_isEqual(navInfo)(prevProps.navInfo)) { const newURL = canonicalURLFromReduxState(navInfo, pathname, loggedIn); if (newURL !== pathname) { history.push(newURL); } } else if (pathname !== prevProps.location.pathname) { const newNavInfo = navInfoFromURL(pathname, { navInfo }); if (!_isEqual(newNavInfo)(navInfo)) { this.props.dispatch({ type: updateNavInfoActionType, payload: newNavInfo, }); } } else if (loggedIn !== prevProps.loggedIn) { const newURL = canonicalURLFromReduxState(navInfo, pathname, loggedIn); if (newURL !== pathname) { history.replace(newURL); } } } onWordmarkClicked = () => { this.props.dispatch({ type: updateNavInfoActionType, payload: { tab: 'chat' }, }); }; render() { let content; if (this.props.loggedIn) { content = this.renderMainContent(); } else { content = ; } return ( {content} {this.props.modals} ); } renderMainContent() { let mainContent; const { tab, settingsSection } = this.props.navInfo; if (tab === 'calendar') { mainContent = ; } else if (tab === 'chat') { mainContent = ; } else if (tab === 'apps') { mainContent = ; } else if (tab === 'settings') { if (settingsSection === 'account') { mainContent = ; } else if (settingsSection === 'danger-zone') { mainContent = ; } } const shouldShowNavigationArrows = false; let navigationArrows = null; if (shouldShowNavigationArrows) { navigationArrows = ; } return (

Comm

{navigationArrows}
{mainContent}
); } } const fetchEntriesLoadingStatusSelector = createLoadingStatusSelector( fetchEntriesActionTypes, ); const updateCalendarQueryLoadingStatusSelector = createLoadingStatusSelector( updateCalendarQueryActionTypes, ); const ConnectedApp: React.ComponentType = React.memo( function ConnectedApp(props) { const activeChatThreadID = useSelector( state => state.navInfo.activeChatThreadID, ); const navInfo = useSelector(state => state.navInfo); const fetchEntriesLoadingStatus = useSelector( fetchEntriesLoadingStatusSelector, ); const updateCalendarQueryLoadingStatus = useSelector( updateCalendarQueryLoadingStatusSelector, ); const entriesLoadingStatus = combineLoadingStatuses( fetchEntriesLoadingStatus, updateCalendarQueryLoadingStatus, ); const loggedIn = useSelector(isLoggedIn); const activeThreadCurrentlyUnread = useSelector( state => !activeChatThreadID || !!state.threadStore.threadInfos[activeChatThreadID]?.currentUser.unread, ); const boundUnreadCount = useSelector(unreadCount); React.useEffect(() => { document.title = getTitle(boundUnreadCount); }, [boundUnreadCount]); const dispatch = useDispatch(); const modalContext = useModalContext(); const modals = React.useMemo( () => modalContext.modals.map(([modal, key]) => ( {modal} )), [modalContext.modals], ); return ( ); }, ); function AppWithProvider(props: BaseProps): React.Node { return ( ); } export default AppWithProvider; diff --git a/web/chat/chat-message-list-container.react.js b/web/chat/chat-message-list-container.react.js index c999abcb7..adf100b08 100644 --- a/web/chat/chat-message-list-container.react.js +++ b/web/chat/chat-message-list-container.react.js @@ -1,241 +1,241 @@ // @flow import classNames from 'classnames'; import invariant from 'invariant'; import _isEqual from 'lodash/fp/isEqual'; import * as React from 'react'; import { useDrop } from 'react-dnd'; import { NativeTypes } from 'react-dnd-html5-backend'; import { useDispatch } from 'react-redux'; import { threadInfoSelector } from 'lib/selectors/thread-selectors'; import { userInfoSelectorForPotentialMembers } from 'lib/selectors/user-selectors'; import { useWatchThread, useExistingThreadInfoFinder, createPendingThread, threadIsPending, } from 'lib/shared/thread-utils'; import { threadTypes } from 'lib/types/thread-types'; import type { AccountUserInfo } from 'lib/types/user-types'; import { InputStateContext } from '../input/input-state'; +import { updateNavInfoActionType } from '../redux/action-types'; import { useSelector } from '../redux/redux-utils'; -import { updateNavInfoActionType } from '../types/nav-types'; import ChatInputBar from './chat-input-bar.react'; import css from './chat-message-list-container.css'; import ChatMessageList from './chat-message-list.react'; import ChatThreadComposer from './chat-thread-composer.react'; import ThreadTopBar from './thread-top-bar.react'; type Props = { +activeChatThreadID: string, }; function ChatMessageListContainer(props: Props): React.Node { const { activeChatThreadID } = props; const isChatCreation = useSelector(state => state.navInfo.chatMode) === 'create'; const selectedUserIDs = useSelector(state => state.navInfo.selectedUserList); const otherUserInfos = useSelector(userInfoSelectorForPotentialMembers); const userInfoInputArray: $ReadOnlyArray = React.useMemo( () => selectedUserIDs?.map(id => otherUserInfos[id]).filter(Boolean) ?? [], [otherUserInfos, selectedUserIDs], ); const viewerID = useSelector(state => state.currentUserInfo?.id); invariant(viewerID, 'should be set'); const pendingPrivateThread = React.useRef( createPendingThread({ viewerID, threadType: threadTypes.PRIVATE, }), ); const existingThreadInfoFinderForCreatingThread = useExistingThreadInfoFinder( pendingPrivateThread.current, ); const baseThreadInfo = useSelector(state => { if (!activeChatThreadID) { return null; } return ( threadInfoSelector(state)[activeChatThreadID] ?? state.navInfo.pendingThread ); }); const existingThreadInfoFinder = useExistingThreadInfoFinder(baseThreadInfo); const threadInfo = React.useMemo(() => { if (isChatCreation) { return existingThreadInfoFinderForCreatingThread({ searching: true, userInfoInputArray, }); } return existingThreadInfoFinder({ searching: false, userInfoInputArray: [], }); }, [ existingThreadInfoFinder, existingThreadInfoFinderForCreatingThread, isChatCreation, userInfoInputArray, ]); invariant(threadInfo, 'ThreadInfo should be set'); const dispatch = useDispatch(); // The effect removes members from list in navInfo // if some of the user IDs don't exist in redux store React.useEffect(() => { if (!isChatCreation) { return; } const existingSelectedUsersSet = new Set( userInfoInputArray.map(userInfo => userInfo.id), ); if ( selectedUserIDs?.length !== existingSelectedUsersSet.size || !_isEqual(new Set(selectedUserIDs), existingSelectedUsersSet) ) { dispatch({ type: updateNavInfoActionType, payload: { selectedUserList: Array.from(existingSelectedUsersSet), }, }); } }, [ dispatch, isChatCreation, otherUserInfos, selectedUserIDs, userInfoInputArray, ]); React.useEffect(() => { if (isChatCreation && activeChatThreadID !== threadInfo?.id) { let payload = { activeChatThreadID: threadInfo?.id, }; if (threadIsPending(threadInfo?.id)) { payload = { ...payload, pendingThread: threadInfo, }; } dispatch({ type: updateNavInfoActionType, payload, }); } }, [activeChatThreadID, dispatch, isChatCreation, threadInfo]); const inputState = React.useContext(InputStateContext); invariant(inputState, 'InputState should be set'); const [{ isActive }, 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(), }), }); useWatchThread(threadInfo); const containerStyle = classNames({ [css.container]: true, [css.activeContainer]: isActive, }); const containerRef = React.useRef(); const onPaste = React.useCallback( (e: ClipboardEvent) => { if (!inputState) { return; } const { clipboardData } = e; if (!clipboardData) { return; } const { files } = clipboardData; if (files.length === 0) { return; } e.preventDefault(); inputState.appendFiles([...files]); }, [inputState], ); React.useEffect(() => { const currentContainerRef = containerRef.current; if (!currentContainerRef) { return; } currentContainerRef.addEventListener('paste', onPaste); return () => { currentContainerRef.removeEventListener('paste', onPaste); }; }, [onPaste]); const content = React.useMemo(() => { const topBar = ; const messageListAndInput = ( <> ); if (!isChatCreation) { return ( <> {topBar} {messageListAndInput} ); } const chatUserSelection = ( ); if (!userInfoInputArray.length) { return chatUserSelection; } return ( <> {topBar} {chatUserSelection} {messageListAndInput} ); }, [ inputState, isChatCreation, otherUserInfos, threadInfo, userInfoInputArray, ]); return connectDropTarget(
{content}
, ); } export default ChatMessageListContainer; diff --git a/web/chat/chat-thread-composer.react.js b/web/chat/chat-thread-composer.react.js index 7c505b96f..68a417100 100644 --- a/web/chat/chat-thread-composer.react.js +++ b/web/chat/chat-thread-composer.react.js @@ -1,168 +1,168 @@ // @flow import classNames from 'classnames'; import * as React from 'react'; import { useDispatch } from 'react-redux'; import { userSearchIndexForPotentialMembers } from 'lib/selectors/user-selectors'; import { getPotentialMemberItems } from 'lib/shared/search-utils'; import type { AccountUserInfo, UserListItem } from 'lib/types/user-types'; import Label from '../components/label.react'; import Search from '../components/search.react'; import type { InputState } from '../input/input-state'; +import { updateNavInfoActionType } from '../redux/action-types'; import { useSelector } from '../redux/redux-utils'; import SWMansionIcon from '../SWMansionIcon.react'; -import { updateNavInfoActionType } from '../types/nav-types'; import css from './chat-thread-composer.css'; type Props = { +userInfoInputArray: $ReadOnlyArray, +otherUserInfos: { [id: string]: AccountUserInfo }, +threadID: string, +inputState: InputState, }; function ChatThreadComposer(props: Props): React.Node { const { userInfoInputArray, otherUserInfos, threadID, inputState } = props; const [usernameInputText, setUsernameInputText] = React.useState(''); const dispatch = useDispatch(); const userSearchIndex = useSelector(userSearchIndexForPotentialMembers); const userInfoInputIDs = React.useMemo( () => userInfoInputArray.map(userInfo => userInfo.id), [userInfoInputArray], ); const userListItems = React.useMemo( () => getPotentialMemberItems( usernameInputText, otherUserInfos, userSearchIndex, userInfoInputIDs, ), [usernameInputText, otherUserInfos, userSearchIndex, userInfoInputIDs], ); const onSelectUserFromSearch = React.useCallback( (id: string) => { const selectedUserIDs = userInfoInputArray.map(user => user.id); dispatch({ type: updateNavInfoActionType, payload: { selectedUserList: [...selectedUserIDs, id], }, }); setUsernameInputText(''); }, [dispatch, userInfoInputArray], ); const onRemoveUserFromSelected = React.useCallback( (id: string) => { const selectedUserIDs = userInfoInputArray.map(user => user.id); if (!selectedUserIDs.includes(id)) { return; } dispatch({ type: updateNavInfoActionType, payload: { selectedUserList: selectedUserIDs.filter(userID => userID !== id), }, }); }, [dispatch, userInfoInputArray], ); const userSearchResultList = React.useMemo(() => { if ( !userListItems.length || (!usernameInputText && userInfoInputArray.length) ) { return null; } return (
    {userListItems.map((userSearchResult: UserListItem) => (
  • onSelectUserFromSearch(userSearchResult.id)} >
    {userSearchResult.username}
    {userSearchResult.alertTitle}
  • ))}
); }, [ onSelectUserFromSearch, userInfoInputArray.length, userListItems, usernameInputText, ]); const hideSearch = React.useCallback(() => { dispatch({ type: updateNavInfoActionType, payload: { chatMode: 'view', activeChatThreadID: threadID, }, }); }, [dispatch, threadID]); const tagsList = React.useMemo(() => { if (!userInfoInputArray?.length) { return null; } const labels = userInfoInputArray.map(user => { return ( ); }); return
{labels}
; }, [userInfoInputArray, onRemoveUserFromSelected]); React.useEffect(() => { if (!inputState) { return; } inputState.registerSendCallback(hideSearch); return () => inputState.unregisterSendCallback(hideSearch); }, [hideSearch, inputState]); const threadSearchContainerStyles = React.useMemo( () => classNames(css.threadSearchContainer, { [css.fullHeight]: !userInfoInputArray.length, }), [userInfoInputArray.length], ); return (
{tagsList} {userSearchResultList}
); } export default ChatThreadComposer; diff --git a/web/chat/robotext-message.react.js b/web/chat/robotext-message.react.js index 1544a4b2e..ec9cfde40 100644 --- a/web/chat/robotext-message.react.js +++ b/web/chat/robotext-message.react.js @@ -1,220 +1,220 @@ // @flow import classNames from 'classnames'; import * as React from 'react'; import { useDispatch } from 'react-redux'; import { type RobotextChatMessageInfoItem } from 'lib/selectors/chat-selectors'; import { threadInfoSelector } from 'lib/selectors/thread-selectors'; import { splitRobotext, parseRobotextEntity } from 'lib/shared/message-utils'; import { useSidebarExistsOrCanBeCreated } from 'lib/shared/thread-utils'; import type { Dispatch } from 'lib/types/redux-types'; import { type ThreadInfo } from 'lib/types/thread-types'; import Markdown from '../markdown/markdown.react'; import { linkRules } from '../markdown/rules.react'; +import { updateNavInfoActionType } from '../redux/action-types'; import { useSelector } from '../redux/redux-utils'; -import { updateNavInfoActionType } from '../types/nav-types'; import { InlineSidebar } from './inline-sidebar.react'; import MessageTooltip from './message-tooltip.react'; import type { MessagePositionInfo, OnMessagePositionWithContainerInfo, } from './position-types'; import css from './robotext-message.css'; import { tooltipPositions } from './tooltip-utils'; const availableTooltipPositionsForRobotext = [ tooltipPositions.TOP_RIGHT, tooltipPositions.RIGHT, tooltipPositions.LEFT, ]; type BaseProps = { +item: RobotextChatMessageInfoItem, +threadInfo: ThreadInfo, +setMouseOverMessagePosition: ( messagePositionInfo: MessagePositionInfo, ) => void, +mouseOverMessagePosition: ?OnMessagePositionWithContainerInfo, }; type Props = { ...BaseProps, // Redux state +sidebarExistsOrCanBeCreated: boolean, }; class RobotextMessage extends React.PureComponent { render() { let inlineSidebar; if (this.props.item.threadCreatedFromMessage) { inlineSidebar = (
); } const { item, threadInfo, sidebarExistsOrCanBeCreated } = this.props; const { id } = item.messageInfo; let messageTooltip; if ( this.props.mouseOverMessagePosition && this.props.mouseOverMessagePosition.item.messageInfo.id === id && sidebarExistsOrCanBeCreated ) { messageTooltip = ( ); } let messageTooltipLinks; if (messageTooltip) { messageTooltipLinks = (
{messageTooltip}
); } return (
{this.linkedRobotext()} {messageTooltipLinks}
{inlineSidebar}
); } linkedRobotext() { const { item } = this.props; const { robotext } = item; const robotextParts = splitRobotext(robotext); const textParts = []; let keyIndex = 0; for (const 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 BaseInnerThreadEntityProps = { +id: string, +name: string, }; type InnerThreadEntityProps = { ...BaseInnerThreadEntityProps, +threadInfo: ThreadInfo, +dispatch: Dispatch, }; class InnerThreadEntity extends React.PureComponent { render() { return {this.props.name}; } onClickThread = (event: SyntheticEvent) => { event.preventDefault(); const id = this.props.id; this.props.dispatch({ type: updateNavInfoActionType, payload: { activeChatThreadID: id, }, }); }; } const ThreadEntity = React.memo( function ConnectedInnerThreadEntity(props: BaseInnerThreadEntityProps) { const { id } = props; const threadInfo = useSelector(state => threadInfoSelector(state)[id]); const dispatch = useDispatch(); return ( ); }, ); function ColorEntity(props: { color: string }) { const colorStyle = { color: props.color }; return {props.color}; } const ConnectedRobotextMessage: React.ComponentType = React.memo( function ConnectedRobotextMessage(props) { const sidebarExistsOrCanBeCreated = useSidebarExistsOrCanBeCreated( props.threadInfo, props.item, ); return ( ); }, ); export default ConnectedRobotextMessage; diff --git a/web/redux/action-types.js b/web/redux/action-types.js index 1b67c2d6d..c967ab335 100644 --- a/web/redux/action-types.js +++ b/web/redux/action-types.js @@ -1,4 +1,6 @@ // @flow +export const updateNavInfoActionType = 'UPDATE_NAV_INFO'; +export const updateWindowDimensionsActionType = 'UPDATE_WINDOW_DIMENSIONS'; export const updateWindowActiveActionType = 'UPDATE_WINDOW_ACTIVE'; export const setDeviceIDActionType = 'SET_DEVICE_ID'; diff --git a/web/redux/nav-reducer.js b/web/redux/nav-reducer.js index 80fd83cba..a9deb8a78 100644 --- a/web/redux/nav-reducer.js +++ b/web/redux/nav-reducer.js @@ -1,43 +1,44 @@ // @flow import { pendingToRealizedThreadIDsSelector } from 'lib/selectors/thread-selectors'; import { threadIsPending } from 'lib/shared/thread-utils'; import type { RawThreadInfo } from 'lib/types/thread-types'; +import { updateNavInfoActionType } from '../redux/action-types'; import type { Action } from '../redux/redux-setup'; -import { type NavInfo, updateNavInfoActionType } from '../types/nav-types'; +import { type NavInfo } from '../types/nav-types'; export default function reduceNavInfo( oldState: NavInfo, action: Action, newThreadInfos: { +[id: string]: RawThreadInfo }, ): NavInfo { let state = oldState; if (action.type === updateNavInfoActionType) { state = { ...state, ...action.payload, }; } const { activeChatThreadID } = state; if (activeChatThreadID) { const pendingToRealizedThreadIDs = pendingToRealizedThreadIDsSelector( newThreadInfos, ); const realizedThreadID = pendingToRealizedThreadIDs.get(activeChatThreadID); if (realizedThreadID) { state = { ...state, activeChatThreadID: realizedThreadID, }; } } if (state.pendingThread && !threadIsPending(state.activeChatThreadID)) { const { pendingThread, ...stateWithoutPendingThread } = state; state = stateWithoutPendingThread; } return state; } diff --git a/web/redux/redux-setup.js b/web/redux/redux-setup.js index f02f93e61..5394ae78f 100644 --- a/web/redux/redux-setup.js +++ b/web/redux/redux-setup.js @@ -1,247 +1,247 @@ // @flow import invariant from 'invariant'; import type { PersistState } from 'redux-persist/src/types'; import { logOutActionTypes, deleteAccountActionTypes, } from 'lib/actions/user-actions'; import baseReducer from 'lib/reducers/master-reducer'; import { mostRecentlyReadThreadSelector } from 'lib/selectors/thread-selectors'; import { isLoggedIn } from 'lib/selectors/user-selectors'; import { invalidSessionDowngrade } from 'lib/shared/account-utils'; import type { Shape } from 'lib/types/core'; import type { EnabledApps } from 'lib/types/enabled-apps'; import type { EntryStore } from 'lib/types/entry-types'; import type { CalendarFilter } from 'lib/types/filter-types'; import type { LifecycleState } from 'lib/types/lifecycle-state-types'; import type { LoadingStatus } from 'lib/types/loading-types'; import type { MessageStore } from 'lib/types/message-types'; import type { BaseAction } from 'lib/types/redux-types'; import type { ReportStore } from 'lib/types/report-types'; import type { ConnectionInfo } from 'lib/types/socket-types'; import type { ThreadStore } from 'lib/types/thread-types'; import type { CurrentUserInfo, UserStore } from 'lib/types/user-types'; import { setNewSessionActionType } from 'lib/utils/action-utils'; import { activeThreadSelector } from '../selectors/nav-selectors'; -import { type NavInfo, updateNavInfoActionType } from '../types/nav-types'; +import { type NavInfo } from '../types/nav-types'; import { updateWindowActiveActionType, setDeviceIDActionType, + updateNavInfoActionType, + updateWindowDimensionsActionType, } from './action-types'; import { reduceDeviceID } from './device-id-reducer'; import reduceNavInfo from './nav-reducer'; import { getVisibility } from './visibility'; export type WindowDimensions = { width: number, height: number }; export type AppState = { navInfo: NavInfo, deviceID: ?string, currentUserInfo: ?CurrentUserInfo, sessionID: ?string, 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, lifecycleState: LifecycleState, enabledApps: EnabledApps, reportStore: ReportStore, nextLocalID: number, timeZone: ?string, userAgent: ?string, dataLoaded: boolean, windowActive: boolean, _persist: ?PersistState, }; -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, } | { type: 'SET_DEVICE_ID', payload: string, }; export function reducer(oldState: AppState | void, action: Action): AppState { invariant(oldState, 'should be set'); let state = oldState; - if (action.type === updateWindowDimensions) { + if (action.type === updateWindowDimensionsActionType) { 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; } if ( action.type !== updateNavInfoActionType && action.type !== setDeviceIDActionType ) { state = baseReducer(state, action).state; } state = { ...state, navInfo: reduceNavInfo( state.navInfo, action, state.threadStore.threadInfos, ), deviceID: reduceDeviceID(state.deviceID, action), }; return validateState(oldState, state); } function validateState(oldState: AppState, state: AppState): AppState { if ( (state.navInfo.activeChatThreadID && !state.navInfo.pendingThread && !state.threadStore.threadInfos[state.navInfo.activeChatThreadID]) || (!state.navInfo.activeChatThreadID && isLoggedIn(state)) ) { // Makes sure the active thread always exists state = { ...state, navInfo: { ...state.navInfo, activeChatThreadID: mostRecentlyReadThreadSelector(state), }, }; } const activeThread = activeThreadSelector(state); if ( activeThread && !state.navInfo.pendingThread && state.threadStore.threadInfos[activeThread].currentUser.unread && getVisibility().hidden() ) { console.warn( `thread ${activeThread} is active and unread, ` + 'but visibilityjs reports the window is not visible', ); } if ( activeThread && !state.navInfo.pendingThread && state.threadStore.threadInfos[activeThread].currentUser.unread && typeof document !== 'undefined' && document && 'hasFocus' in document && !document.hasFocus() ) { console.warn( `thread ${activeThread} is active and unread, ` + 'but document.hasFocus() is false', ); } if ( activeThread && !getVisibility().hidden() && typeof document !== 'undefined' && document && 'hasFocus' in document && 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 00d6584d4..48493bfc5 100644 --- a/web/selectors/nav-selectors.js +++ b/web/selectors/nav-selectors.js @@ -1,241 +1,241 @@ // @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 { getDefaultTextMessageRules } from '../markdown/rules.react'; +import { updateNavInfoActionType } from '../redux/action-types'; import type { AppState } from '../redux/redux-setup'; import { useSelector } from '../redux/redux-utils'; import { type NavigationTab, type NavigationSettingsSection, - updateNavInfoActionType, } from '../types/nav-types'; 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( thread: ?ThreadInfo, ): (event: SyntheticEvent) => void { const dispatch = useDispatch(); return React.useCallback( (event: SyntheticEvent) => { invariant( thread?.id, 'useOnClickThread should be called with threadID set', ); event.preventDefault(); const { id: threadID } = thread; if (threadID.includes('pending')) { dispatch({ type: updateNavInfoActionType, payload: { chatMode: 'view', activeChatThreadID: threadID, pendingThread: thread, }, }); } else { dispatch({ type: updateNavInfoActionType, payload: { chatMode: 'view', activeChatThreadID: threadID, }, }); } }, [dispatch, thread], ); } function useThreadIsActive(threadID: string): boolean { return useSelector(state => threadID === state.navInfo.activeChatThreadID); } function useOnClickPendingSidebar( messageInfo: ComposableMessageInfo | RobotextMessageInfo, threadInfo: ThreadInfo, ): (event: SyntheticEvent) => void { 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, getDefaultTextMessageRules().simpleMarkdownRules, ); dispatch({ type: updateNavInfoActionType, payload: { activeChatThreadID: pendingSidebarInfo.id, pendingThread: pendingSidebarInfo, }, }); }, [viewerID, messageInfo, threadInfo, dispatch], ); } function useOnClickNewThread(): (event: SyntheticEvent) => void { const dispatch = useDispatch(); return React.useCallback( (event: SyntheticEvent) => { event.preventDefault(); dispatch({ type: updateNavInfoActionType, payload: { chatMode: 'create', selectedUserList: [], }, }); }, [dispatch], ); } function navTabSelector(state: AppState): NavigationTab { return state.navInfo.tab; } function navSettingsSectionSelector( state: AppState, ): ?NavigationSettingsSection { return state.navInfo.settingsSection; } export { yearExtractor, yearAssertingSelector, monthExtractor, monthAssertingSelector, activeThreadSelector, webCalendarQuery, nonThreadCalendarQuery, useOnClickThread, useThreadIsActive, useOnClickPendingSidebar, useOnClickNewThread, navTabSelector, navSettingsSectionSelector, }; diff --git a/web/sidebar/app-switcher.react.js b/web/sidebar/app-switcher.react.js index ca3889b89..b60352643 100644 --- a/web/sidebar/app-switcher.react.js +++ b/web/sidebar/app-switcher.react.js @@ -1,134 +1,134 @@ // @flow import * as React from 'react'; import { useDispatch } from 'react-redux'; import { mostRecentlyReadThreadSelector, unreadCount, } from 'lib/selectors/thread-selectors'; +import { updateNavInfoActionType } from '../redux/action-types'; import { useSelector } from '../redux/redux-utils'; import { navTabSelector } from '../selectors/nav-selectors.js'; import SWMansionIcon from '../SWMansionIcon.react'; -import { updateNavInfoActionType } from '../types/nav-types'; import css from './left-layout-aside.css'; import NavigationPanel from './navigation-panel.react'; function AppSwitcher(): React.Node { const activeChatThreadID = useSelector( state => state.navInfo.activeChatThreadID, ); const mostRecentlyReadThread = useSelector(mostRecentlyReadThreadSelector); const isActiveThreadCurrentlyUnread = useSelector( state => !activeChatThreadID || !!state.threadStore.threadInfos[activeChatThreadID]?.currentUser.unread, ); const dispatch = useDispatch(); const onClickChat = React.useCallback( (event: SyntheticEvent) => { event.preventDefault(); dispatch({ type: updateNavInfoActionType, payload: { tab: 'chat', activeChatThreadID: isActiveThreadCurrentlyUnread ? mostRecentlyReadThread : activeChatThreadID, }, }); }, [ dispatch, isActiveThreadCurrentlyUnread, mostRecentlyReadThread, activeChatThreadID, ], ); const boundUnreadCount = useSelector(unreadCount); let chatBadge = null; if (boundUnreadCount > 0) { chatBadge = {boundUnreadCount}; } const chatNavigationItem = React.useMemo( () => (
{chatBadge}

Chat

), [chatBadge, onClickChat], ); const onClickCalendar = React.useCallback( (event: SyntheticEvent) => { event.preventDefault(); dispatch({ type: updateNavInfoActionType, payload: { tab: 'calendar' }, }); }, [dispatch], ); const isCalendarEnabled = useSelector(state => state.enabledApps.calendar); const calendarNavigationItem = React.useMemo(() => { if (!isCalendarEnabled) { return null; } return (

Calendar

); }, [isCalendarEnabled, onClickCalendar]); const onClickApps = React.useCallback( (event: SyntheticEvent) => { event.preventDefault(); dispatch({ type: updateNavInfoActionType, payload: { tab: 'apps', }, }); }, [dispatch], ); const appNavigationItem = React.useMemo( () => (

Apps

), [onClickApps], ); return ( {chatNavigationItem} {calendarNavigationItem} {appNavigationItem} ); } export default AppSwitcher; diff --git a/web/sidebar/community-picker.react.js b/web/sidebar/community-picker.react.js index 33695c28a..add1366f2 100644 --- a/web/sidebar/community-picker.react.js +++ b/web/sidebar/community-picker.react.js @@ -1,66 +1,66 @@ // @flow import classNames from 'classnames'; import * as React from 'react'; import { useDispatch } from 'react-redux'; import Button from '../components/button.react'; +import { updateNavInfoActionType } from '../redux/action-types.js'; import { useSelector } from '../redux/redux-utils.js'; import SWMansionIcon from '../SWMansionIcon.react'; -import { updateNavInfoActionType } from '../types/nav-types.js'; import css from './community-picker.css'; function CommunityPicker(): React.Node { const dispatch = useDispatch(); const openAccountSettings = React.useCallback( (event: SyntheticEvent) => { event.preventDefault(); dispatch({ type: updateNavInfoActionType, payload: { tab: 'settings', settingsSection: 'account' }, }); }, [dispatch], ); const isSettingsOpen = useSelector(state => state.navInfo.tab === 'settings'); const settingsButtonContainerClass = classNames({ [css.activeContainer]: isSettingsOpen, }); const openChat = React.useCallback( (event: SyntheticEvent) => { event.preventDefault(); dispatch({ type: updateNavInfoActionType, payload: { tab: 'chat' }, }); }, [dispatch], ); const isInboxOpen = useSelector( state => state.navInfo.tab === 'chat' || state.navInfo.tab === 'apps' || state.navInfo.tab === 'calendar', ); const inboxButtonContainerClass = classNames({ [css.activeContainer]: isInboxOpen, }); return (
); } export default CommunityPicker; diff --git a/web/sidebar/settings-switcher.react.js b/web/sidebar/settings-switcher.react.js index 871cb36ce..c831f1657 100644 --- a/web/sidebar/settings-switcher.react.js +++ b/web/sidebar/settings-switcher.react.js @@ -1,64 +1,64 @@ // @flow import * as React from 'react'; import { useDispatch } from 'react-redux'; +import { updateNavInfoActionType } from '../redux/action-types'; import { navSettingsSectionSelector } from '../selectors/nav-selectors.js'; -import { updateNavInfoActionType } from '../types/nav-types'; import css from './left-layout-aside.css'; import NavigationPanel from './navigation-panel.react'; function SettingsSwitcher(): React.Node { const dispatch = useDispatch(); const onClickAccountSettings = React.useCallback( (event: SyntheticEvent) => { event.preventDefault(); dispatch({ type: updateNavInfoActionType, payload: { tab: 'settings', settingsSection: 'account' }, }); }, [dispatch], ); const accountSettingsNavigationItem = React.useMemo( () => (

My Account

), [onClickAccountSettings], ); const onClickDangerZone = React.useCallback( (event: SyntheticEvent) => { event.preventDefault(); dispatch({ type: updateNavInfoActionType, payload: { tab: 'settings', settingsSection: 'danger-zone' }, }); }, [dispatch], ); const dangerZoneNavigationItem = React.useMemo( () => (

Danger Zone

), [onClickDangerZone], ); return ( {accountSettingsNavigationItem} {dangerZoneNavigationItem} ); } export default SettingsSwitcher; diff --git a/web/types/nav-types.js b/web/types/nav-types.js index 78cb59311..0519d45bd 100644 --- a/web/types/nav-types.js +++ b/web/types/nav-types.js @@ -1,22 +1,20 @@ // @flow import type { BaseNavInfo } from 'lib/types/nav-types'; import type { ThreadInfo } from 'lib/types/thread-types'; export type NavigationTab = 'calendar' | 'chat' | 'apps' | 'settings'; export type NavigationSettingsSection = 'account' | 'danger-zone'; export type NavigationChatMode = 'view' | 'create'; export type NavInfo = { ...$Exact, +tab: NavigationTab, +activeChatThreadID: ?string, +pendingThread?: ThreadInfo, +settingsSection?: NavigationSettingsSection, +selectedUserList?: $ReadOnlyArray, +chatMode?: NavigationChatMode, }; - -export const updateNavInfoActionType = 'UPDATE_NAV_INFO';