diff --git a/lib/utils/url-utils.js b/lib/utils/url-utils.js index 1957d495e..93172a385 100644 --- a/lib/utils/url-utils.js +++ b/lib/utils/url-utils.js @@ -1,60 +1,65 @@ // @flow import urlParseLax from 'url-parse-lax'; export type URLInfo = { year?: number, month?: number, // 1-indexed verify?: string, calendar?: boolean, chat?: boolean, + apps?: boolean, thread?: string, ... }; const yearRegex = new RegExp('(/|^)year/([0-9]+)(/|$)', 'i'); const monthRegex = new RegExp('(/|^)month/([0-9]+)(/|$)', 'i'); const threadRegex = new RegExp('(/|^)thread/([0-9]+)(/|$)', 'i'); const verifyRegex = new RegExp('(/|^)verify/([a-f0-9]+)(/|$)', 'i'); const calendarRegex = new RegExp('(/|^)calendar(/|$)', 'i'); const chatRegex = new RegExp('(/|^)chat(/|$)', 'i'); +const appsRegex = new RegExp('(/|^)apps(/|$)', 'i'); function infoFromURL(url: string): URLInfo { const yearMatches = yearRegex.exec(url); const monthMatches = monthRegex.exec(url); const threadMatches = threadRegex.exec(url); const verifyMatches = verifyRegex.exec(url); const calendarTest = calendarRegex.test(url); const chatTest = chatRegex.test(url); + const appsTest = appsRegex.test(url); const returnObj = {}; if (yearMatches) { returnObj.year = parseInt(yearMatches[2], 10); } if (monthMatches) { const month = parseInt(monthMatches[2], 10); if (month < 1 || month > 12) { throw new Error('invalid_month'); } returnObj.month = month; } if (threadMatches) { returnObj.thread = threadMatches[2]; } if (verifyMatches) { returnObj.verify = verifyMatches[2]; } if (calendarTest) { returnObj.calendar = true; } else if (chatTest) { returnObj.chat = true; + } else if (appsTest) { + returnObj.apps = true; } return returnObj; } function normalizeURL(url: string): string { return urlParseLax(url).href; } const setURLPrefix = 'SET_URL_PREFIX'; export { infoFromURL, normalizeURL, setURLPrefix }; diff --git a/web/app.react.js b/web/app.react.js index e72dc8b03..3f95e0698 100644 --- a/web/app.react.js +++ b/web/app.react.js @@ -1,241 +1,244 @@ // @flow import '@fontsource/inter'; import '@fontsource/inter/500.css'; import '@fontsource/inter/600.css'; import '@fontsource/ibm-plex-sans'; import '@fontsource/ibm-plex-sans/500.css'; import '@fontsource/ibm-plex-sans/600.css'; 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 { mostRecentReadThreadSelector } 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 InputStateContainer from './input/input-state-container.react'; import LoadingIndicator from './loading-indicator.react'; import { ModalProvider, useModalContext } from './modals/modal-provider.react'; 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 LeftLayoutAside from './sidebar/left-layout-aside.react'; import Splash from './splash/splash.react'; import './typography.css'; import css from './style.css'; import { type NavInfo, updateNavInfoActionType } 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, +mostRecentReadThread: ?string, +activeThreadCurrentlyUnread: boolean, // Redux dispatch functions +dispatch: Dispatch, +modal: ?React.Node, }; 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); } } } render() { let content; if (this.props.loggedIn) { content = this.renderMainContent(); } else { content = ; } return ( {content} {this.props.modal} ); } renderMainContent() { let mainContent; if (this.props.navInfo.tab === 'calendar') { mainContent = ; } else if (this.props.navInfo.tab === 'chat') { mainContent = ; + } else if (this.props.navInfo.tab === 'apps') { + mainContent = ; } return (

Comm

{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 mostRecentReadThread = useSelector(mostRecentReadThreadSelector); const activeThreadCurrentlyUnread = useSelector( state => !activeChatThreadID || !!state.threadStore.threadInfos[activeChatThreadID]?.currentUser.unread, ); const dispatch = useDispatch(); const modalContext = useModalContext(); return ( ); }, ); function AppWithProvider(props: BaseProps): React.Node { return ( ); } export default AppWithProvider; diff --git a/web/apps/apps-directory.react.js b/web/apps/apps-directory.react.js new file mode 100644 index 000000000..ea3508abd --- /dev/null +++ b/web/apps/apps-directory.react.js @@ -0,0 +1,9 @@ +// @flow + +import * as React from 'react'; + +function AppsDirectory(): React.Node { + return
Apps directory
; +} + +export default AppsDirectory; diff --git a/web/sidebar/app-switcher.react.js b/web/sidebar/app-switcher.react.js index 963234f49..6f4949afe 100644 --- a/web/sidebar/app-switcher.react.js +++ b/web/sidebar/app-switcher.react.js @@ -1,110 +1,132 @@ // @flow import classNames from 'classnames'; import invariant from 'invariant'; import * as React from 'react'; import { useDispatch } from 'react-redux'; import { mostRecentReadThreadSelector, unreadCount, } from 'lib/selectors/thread-selectors'; import { useSelector } from '../redux/redux-utils'; import SWMansionIcon from '../SWMansionIcon.react'; import getTitle from '../title/getTitle'; import { updateNavInfoActionType } from '../types/nav-types'; import css from './left-layout-aside.css'; function AppSwitcher(): React.Node { const activeChatThreadID = useSelector( state => state.navInfo.activeChatThreadID, ); const navInfo = useSelector(state => state.navInfo); const mostRecentReadThread = useSelector(mostRecentReadThreadSelector); const activeThreadCurrentlyUnread = useSelector( state => !activeChatThreadID || !!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(); const onClickCalendar = React.useCallback( (event: SyntheticEvent) => { event.preventDefault(); dispatch({ type: updateNavInfoActionType, payload: { tab: 'calendar' }, }); }, [dispatch], ); const onClickChat = React.useCallback( (event: SyntheticEvent) => { event.preventDefault(); dispatch({ type: updateNavInfoActionType, payload: { tab: 'chat', activeChatThreadID: activeThreadCurrentlyUnread ? mostRecentReadThread : activeChatThreadID, }, }); }, [ dispatch, activeThreadCurrentlyUnread, mostRecentReadThread, activeChatThreadID, ], ); + const onClickApps = React.useCallback( + (event: SyntheticEvent) => { + event.preventDefault(); + dispatch({ + type: updateNavInfoActionType, + payload: { + tab: 'apps', + }, + }); + }, + [dispatch], + ); + invariant(viewerID, 'should be set'); let chatBadge = null; if (boundUnreadCount > 0) { chatBadge =
{boundUnreadCount}
; } const calendarNavClasses = classNames({ [css['current-tab']]: navInfo.tab === 'calendar', }); const chatNavClasses = classNames({ [css['current-tab']]: navInfo.tab === 'chat', }); + const appsNavClasses = classNames({ + [css['current-tab']]: navInfo.tab === 'apps', + }); return (
); } export default AppSwitcher; diff --git a/web/types/nav-types.js b/web/types/nav-types.js index 8b2f3c2a3..9c720fb2a 100644 --- a/web/types/nav-types.js +++ b/web/types/nav-types.js @@ -1,13 +1,13 @@ // @flow import type { BaseNavInfo } from 'lib/types/nav-types'; import type { ThreadInfo } from 'lib/types/thread-types'; export type NavInfo = { ...$Exact, - +tab: 'calendar' | 'chat', + +tab: 'calendar' | 'chat' | 'apps', +activeChatThreadID: ?string, +pendingThread?: ThreadInfo, }; export const updateNavInfoActionType = 'UPDATE_NAV_INFO'; diff --git a/web/url-utils.js b/web/url-utils.js index 821e60182..ff974be17 100644 --- a/web/url-utils.js +++ b/web/url-utils.js @@ -1,108 +1,115 @@ // @flow import invariant from 'invariant'; import { startDateForYearAndMonth, endDateForYearAndMonth, } from 'lib/utils/date-utils'; import { infoFromURL } from 'lib/utils/url-utils'; import { yearExtractor, monthExtractor } from './selectors/nav-selectors'; import type { NavInfo } from './types/nav-types'; function canonicalURLFromReduxState( navInfo: NavInfo, currentURL: string, loggedIn: boolean, ): string { const urlInfo = infoFromURL(currentURL); const today = new Date(); let newURL = `/`; if (loggedIn) { newURL += `${navInfo.tab}/`; if (navInfo.tab === 'calendar') { const { startDate, endDate } = navInfo; const year = yearExtractor(startDate, endDate); if (urlInfo.year !== undefined) { invariant( year !== null && year !== undefined, `${startDate} and ${endDate} aren't in the same year`, ); newURL += `year/${year}/`; } else if ( year !== null && year !== undefined && year !== today.getFullYear() ) { newURL += `year/${year}/`; } const month = monthExtractor(startDate, endDate); if (urlInfo.month !== undefined) { invariant( month !== null && month !== undefined, `${startDate} and ${endDate} aren't in the same month`, ); newURL += `month/${month}/`; } else if ( month !== null && month !== undefined && month !== today.getMonth() + 1 ) { newURL += `month/${month}/`; } } else if (navInfo.tab === 'chat') { const activeChatThreadID = navInfo.activeChatThreadID; if (activeChatThreadID) { newURL += `thread/${activeChatThreadID}/`; } } } return newURL; } // Given a URL, this function parses out a navInfo object, leaving values as // default if they are unspecified. function navInfoFromURL( url: string, backupInfo: { now?: Date, navInfo?: NavInfo }, ): NavInfo { const urlInfo = infoFromURL(url); const { navInfo } = backupInfo; const now = backupInfo.now ? backupInfo.now : new Date(); let year = urlInfo.year; if (!year && navInfo) { year = yearExtractor(navInfo.startDate, navInfo.endDate); } if (!year) { year = now.getFullYear(); } let month = urlInfo.month; if (!month && navInfo) { month = monthExtractor(navInfo.startDate, navInfo.endDate); } if (!month) { month = now.getMonth() + 1; } let activeChatThreadID = null; if (urlInfo.thread) { activeChatThreadID = urlInfo.thread.toString(); } else if (navInfo) { activeChatThreadID = navInfo.activeChatThreadID; } + let tab = 'chat'; + if (urlInfo.calendar) { + tab = 'calendar'; + } else if (urlInfo.apps) { + tab = 'apps'; + } + return { - tab: urlInfo.chat ? 'chat' : 'calendar', + tab, startDate: startDateForYearAndMonth(year, month), endDate: endDateForYearAndMonth(year, month), activeChatThreadID, }; } export { canonicalURLFromReduxState, navInfoFromURL };