diff --git a/lib/utils/url-utils.js b/lib/utils/url-utils.js index 0f8f715fe..ea0c0ca34 100644 --- a/lib/utils/url-utils.js +++ b/lib/utils/url-utils.js @@ -1,87 +1,96 @@ // @flow import { pendingThreadIDRegex } from '../shared/thread-utils.js'; export type URLInfo = { +year?: number, +month?: number, // 1-indexed +verify?: string, +calendar?: boolean, +chat?: boolean, +thread?: string, +settings?: 'account' | 'danger-zone', +threadCreation?: boolean, +selectedUserList?: $ReadOnlyArray, + +inviteSecret?: string, ... }; // We use groups to capture parts of the URL and any changes // to regexes must be reflected in infoFromURL. 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 accountSettingsRegex = new RegExp('(/|^)settings/account(/|$)', 'i'); const dangerZoneRegex = new RegExp('(/|^)settings/danger-zone(/|$)', 'i'); const threadPendingRegex = new RegExp( `(/|^)thread/(${pendingThreadIDRegex})(/|$)`, 'i', ); const threadCreationRegex = new RegExp( '(/|^)thread/new(/([0-9]+([+][0-9]+)*))?(/|$)', 'i', ); +const inviteLinkRegex = new RegExp( + '(/|^)handle/invite/([a-zA-Z0-9]+)(/|$)', + '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 accountSettingsTest = accountSettingsRegex.test(url); const dangerZoneTest = dangerZoneRegex.test(url); const threadPendingMatches = threadPendingRegex.exec(url); const threadCreateMatches = threadCreationRegex.exec(url); + const inviteLinkMatches = inviteLinkRegex.exec(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 (threadPendingMatches) { returnObj.thread = threadPendingMatches[2]; } if (threadCreateMatches) { returnObj.threadCreation = true; returnObj.selectedUserList = threadCreateMatches[3]?.split('+') ?? []; } if (verifyMatches) { returnObj.verify = verifyMatches[2]; } + if (inviteLinkMatches) { + returnObj.inviteSecret = inviteLinkMatches[2]; + } if (calendarTest) { returnObj.calendar = true; } else if (chatTest) { returnObj.chat = true; } else if (accountSettingsTest) { returnObj.settings = 'account'; } else if (dangerZoneTest) { returnObj.settings = 'danger-zone'; } return returnObj; } const setURLPrefix = 'SET_URL_PREFIX'; export { infoFromURL, setURLPrefix }; diff --git a/web/types/nav-types.js b/web/types/nav-types.js index 6d47e25cf..ba9e58c4d 100644 --- a/web/types/nav-types.js +++ b/web/types/nav-types.js @@ -1,43 +1,45 @@ // @flow import t from 'tcomb'; import type { TInterface } from 'tcomb'; import { type BaseNavInfo } from 'lib/types/nav-types.js'; import { type ThreadInfo, threadInfoValidator, } from 'lib/types/thread-types.js'; import { tID, tShape } from 'lib/utils/validation-utils.js'; export type NavigationTab = 'calendar' | 'chat' | 'settings'; const navigationTabValidator = t.enums.of(['calendar', 'chat', 'settings']); export type NavigationSettingsSection = 'account' | 'danger-zone'; const navigationSettingsSectionValidator = t.enums.of([ 'account', 'danger-zone', ]); export type NavigationChatMode = 'view' | 'create'; const navigationChatModeValidator = t.enums.of(['view', 'create']); export type NavInfo = { ...$Exact, +tab: NavigationTab, +activeChatThreadID: ?string, +pendingThread?: ThreadInfo, +settingsSection?: NavigationSettingsSection, +selectedUserList?: $ReadOnlyArray, +chatMode?: NavigationChatMode, + +inviteSecret?: ?string, }; export const navInfoValidator: TInterface = tShape<$Exact>({ startDate: t.String, endDate: t.String, tab: navigationTabValidator, activeChatThreadID: t.maybe(tID), pendingThread: t.maybe(threadInfoValidator), settingsSection: t.maybe(navigationSettingsSectionValidator), selectedUserList: t.maybe(t.list(t.String)), chatMode: t.maybe(navigationChatModeValidator), + inviteSecret: t.maybe(t.String), }); diff --git a/web/url-utils.js b/web/url-utils.js index 0e08982c7..1206d3f35 100644 --- a/web/url-utils.js +++ b/web/url-utils.js @@ -1,139 +1,143 @@ // @flow import invariant from 'invariant'; import { startDateForYearAndMonth, endDateForYearAndMonth, } from 'lib/utils/date-utils.js'; import { infoFromURL } from 'lib/utils/url-utils.js'; import { yearExtractor, monthExtractor } from './selectors/nav-selectors.js'; import type { NavInfo } from './types/nav-types.js'; 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') { if (navInfo.chatMode === 'create') { const users = navInfo.selectedUserList?.join('+') ?? ''; const potentiallyTrailingSlash = users.length > 0 ? '/' : ''; newURL += `thread/new/${users}${potentiallyTrailingSlash}`; } else { const activeChatThreadID = navInfo.activeChatThreadID; if (activeChatThreadID) { newURL += `thread/${activeChatThreadID}/`; } } } else if (navInfo.tab === 'settings' && navInfo.settingsSection) { newURL += `${navInfo.settingsSection}/`; } } 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.settings) { tab = 'settings'; } const chatMode = urlInfo.threadCreation || navInfo?.chatMode === 'create' ? 'create' : 'view'; const newNavInfo: NavInfo = { tab, startDate: startDateForYearAndMonth(year, month), endDate: endDateForYearAndMonth(year, month), activeChatThreadID, chatMode, }; if (urlInfo.selectedUserList) { newNavInfo.selectedUserList = urlInfo.selectedUserList; } if (urlInfo.settings) { newNavInfo.settingsSection = urlInfo.settings; } + if (urlInfo.inviteSecret) { + newNavInfo.inviteSecret = urlInfo.inviteSecret; + } + return newNavInfo; } export { canonicalURLFromReduxState, navInfoFromURL };