diff --git a/lib/utils/url-utils.js b/lib/utils/url-utils.js index 2cddcd1cf..e4042ebab 100644 --- a/lib/utils/url-utils.js +++ b/lib/utils/url-utils.js @@ -1,137 +1,158 @@ // @flow import t, { type TInterface } from 'tcomb'; import { idSchemaRegex, tID, tShape, pendingThreadIDRegex, } from './validation-utils.js'; type MutableURLInfo = { year?: number, month?: number, // 1-indexed verify?: string, calendar?: boolean, chat?: boolean, thread?: string, - settings?: 'account' | 'keyservers' | 'danger-zone', + settings?: + | 'account' + | 'friend-list' + | 'block-list' + | 'keyservers' + | 'danger-zone', threadCreation?: boolean, selectedUserList?: $ReadOnlyArray, inviteSecret?: string, qrCode?: boolean, ... }; export type URLInfo = $ReadOnly; export const urlInfoValidator: TInterface = tShape({ year: t.maybe(t.Number), month: t.maybe(t.Number), verify: t.maybe(t.String), calendar: t.maybe(t.Boolean), chat: t.maybe(t.Boolean), thread: t.maybe(tID), - settings: t.maybe(t.enums.of(['account', 'keyservers', 'danger-zone'])), + settings: t.maybe( + t.enums.of([ + 'account', + 'friend-list', + 'block-list', + 'keyservers', + 'danger-zone', + ]), + ), threadCreation: t.maybe(t.Boolean), selectedUserList: t.maybe(t.list(t.String)), inviteSecret: t.maybe(t.String), qrCode: t.maybe(t.Boolean), }); // 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/(${idSchemaRegex})(/|$)`, '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 friendListRegex = new RegExp('(/|^)settings/friend-list(/|$)', 'i'); +const blockListRegex = new RegExp('(/|^)settings/block-list(/|$)', 'i'); const keyserversRegex = new RegExp('(/|^)settings/keyservers(/|$)', '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', ); const qrCodeLoginRegex = new RegExp('(/|^)qr-code(/|$)', '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 friendListTest = friendListRegex.test(url); + const blockListTest = blockListRegex.test(url); const keyserversSettingsTest = keyserversRegex.test(url); const dangerZoneTest = dangerZoneRegex.test(url); const threadPendingMatches = threadPendingRegex.exec(url); const threadCreateMatches = threadCreationRegex.exec(url); const inviteLinkMatches = inviteLinkRegex.exec(url); const qrCodeLoginMatches = qrCodeLoginRegex.exec(url); const returnObj: MutableURLInfo = {}; 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 (friendListTest) { + returnObj.settings = 'friend-list'; + } else if (blockListTest) { + returnObj.settings = 'block-list'; } else if (keyserversSettingsTest) { returnObj.settings = 'keyservers'; } else if (dangerZoneTest) { returnObj.settings = 'danger-zone'; } else if (qrCodeLoginMatches) { returnObj.qrCode = true; } return returnObj; } const setURLPrefix = 'SET_URL_PREFIX'; export type URLPathParams = { +[name: string]: string }; function replacePathParams(path: string, params: URLPathParams = {}): string { for (const name in params) { path = path.replace(`:${name}`, params[name]); } return path; } export { infoFromURL, setURLPrefix, replacePathParams }; diff --git a/web/app.react.js b/web/app.react.js index d44d93d4a..a110e4e9a 100644 --- a/web/app.react.js +++ b/web/app.react.js @@ -1,418 +1,422 @@ // @flow import 'basscss/css/basscss.min.css'; import './theme.css'; import { config as faConfig } from '@fortawesome/fontawesome-svg-core'; import classnames from 'classnames'; import _isEqual from 'lodash/fp/isEqual.js'; import * as React from 'react'; import { DndProvider } from 'react-dnd'; import { HTML5Backend } from 'react-dnd-html5-backend'; import { WagmiConfig } from 'wagmi'; import { fetchEntriesActionTypes, updateCalendarQueryActionTypes, } from 'lib/actions/entry-actions.js'; import { ChatMentionContextProvider } from 'lib/components/chat-mention-provider.react.js'; import { EditUserAvatarProvider } from 'lib/components/edit-user-avatar-provider.react.js'; import { ModalProvider, useModalContext, } from 'lib/components/modal-provider.react.js'; import { createLoadingStatusSelector, combineLoadingStatuses, } from 'lib/selectors/loading-selectors.js'; import { isLoggedIn } from 'lib/selectors/user-selectors.js'; import { extractMajorDesktopVersion } from 'lib/shared/version-utils.js'; import { TunnelbrokerProvider } from 'lib/tunnelbroker/tunnelbroker-context.js'; import type { LoadingStatus } from 'lib/types/loading-types.js'; import type { Dispatch } from 'lib/types/redux-types.js'; import { getConfig, registerConfig } from 'lib/utils/config.js'; import { useDispatch } from 'lib/utils/redux-utils.js'; import { infoFromURL } from 'lib/utils/url-utils.js'; import { AlchemyENSCacheProvider, wagmiConfig } from 'lib/utils/wagmi-utils.js'; import QrCodeLogin from './account/qr-code-login.react.js'; import AppThemeWrapper from './app-theme-wrapper.react.js'; import WebEditThreadAvatarProvider from './avatars/web-edit-thread-avatar-provider.react.js'; import Calendar from './calendar/calendar.react.js'; import Chat from './chat/chat.react.js'; import { EditModalProvider } from './chat/edit-message-provider.js'; import NavigationArrows from './components/navigation-arrows.react.js'; import { initOpaque } from './crypto/opaque-utils.js'; import { getDatabaseModule } from './database/database-module-provider.js'; import electron from './electron.js'; import InputStateContainer from './input/input-state-container.react.js'; import InviteLinkHandler from './invite-links/invite-link-handler.react.js'; import InviteLinksRefresher from './invite-links/invite-links-refresher.react.js'; import LoadingIndicator from './loading-indicator.react.js'; import { MenuProvider } from './menu-provider.react.js'; import UpdateModalHandler from './modals/update-modal.react.js'; import SettingsSwitcher from './navigation-panels/settings-switcher.react.js'; import Topbar from './navigation-panels/topbar.react.js'; import useBadgeHandler from './push-notif/badge-handler.react.js'; import { PushNotificationsHandler } from './push-notif/push-notifs-handler.js'; import { updateNavInfoActionType } from './redux/action-types.js'; import { DisconnectedBarVisibilityHandler } from './redux/disconnected-bar-visibility-handler.js'; import DisconnectedBar from './redux/disconnected-bar.js'; import FocusHandler from './redux/focus-handler.react.js'; import { persistConfig } from './redux/persist.js'; import PolicyAcknowledgmentHandler from './redux/policy-acknowledgment-handler.js'; import { useSelector } from './redux/redux-utils.js'; import VisibilityHandler from './redux/visibility-handler.react.js'; import history from './router-history.js'; import { MessageSearchStateProvider } from './search/message-search-state-provider.react.js'; import { createTunnelbrokerInitMessage } from './selectors/tunnelbroker-selectors.js'; import AccountSettings from './settings/account-settings.react.js'; import DangerZone from './settings/danger-zone.react.js'; import KeyserverSelectionList from './settings/keyserver-selection-list.react.js'; import CommunityPicker from './sidebar/community-picker.react.js'; import Splash from './splash/splash.react.js'; import './typography.css'; import css from './style.css'; import { TooltipProvider } from './tooltips/tooltip-provider.js'; import { type NavInfo } from './types/nav-types.js'; import { canonicalURLFromReduxState, navInfoFromURL } from './url-utils.js'; void initOpaque(); // 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; const desktopDetails = electron?.version ? { majorDesktopVersion: extractMajorDesktopVersion(electron?.version) } : null; registerConfig({ // We can't securely cache credentials on web resolveKeyserverSessionInvalidationUsingNativeCredentials: null, setSessionIDOnRequest: true, // Never reset the calendar range calendarRangeInactivityLimit: null, platformDetails: { platform: electron?.platform ?? 'web', codeVersion: 63, stateVersion: persistConfig.version, ...desktopDetails, }, }); const versionBroadcast = new BroadcastChannel('comm_version'); versionBroadcast.postMessage(getConfig().platformDetails.codeVersion); versionBroadcast.onmessage = (event: MessageEvent) => { if (event.data && event.data !== getConfig().platformDetails.codeVersion) { location.reload(); } }; // Start initializing the database immediately void getDatabaseModule(); 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 urlInfo = infoFromURL(pathname); const newNavInfo = navInfoFromURL(urlInfo, { 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); } } if (loggedIn !== prevProps.loggedIn) { electron?.clearHistory(); } } onWordmarkClicked = () => { this.props.dispatch({ type: updateNavInfoActionType, payload: { tab: 'chat' }, }); }; render(): React.Node { let content; if (this.props.loggedIn) { content = ( <> {this.renderMainContent()} {this.props.modals} ); } else { content = ( <> {this.renderLoginPage()} {this.props.modals} ); } return ( {content} ); } onHeaderDoubleClick = (): void => electron?.doubleClickTopBar(); stopDoubleClickPropagation: ?(SyntheticEvent) => void = electron ? e => e.stopPropagation() : null; renderLoginPage(): React.Node { const { loginMethod } = this.props.navInfo; if (loginMethod === 'qr-code') { return ; } return ; } renderMainContent(): React.Node { const mainContent = this.getMainContentWithSwitcher(); let navigationArrows = null; if (electron) { navigationArrows = ; } const headerClasses = classnames({ [css.header]: true, [css['electron-draggable']]: electron, }); const wordmarkClasses = classnames({ [css.wordmark]: true, [css['electron-non-draggable']]: electron, [css['wordmark-macos']]: electron?.platform === 'macos', }); return (

Comm

{navigationArrows}
{mainContent}
); } getMainContentWithSwitcher(): React.Node { const { tab, settingsSection } = this.props.navInfo; let mainContent: React.Node; if (tab === 'settings') { if (settingsSection === 'account') { mainContent = ; + } else if (settingsSection === 'friend-list') { + mainContent = null; + } else if (settingsSection === 'block-list') { + mainContent = null; } else if (settingsSection === 'keyservers') { mainContent = ; } else if (settingsSection === 'danger-zone') { mainContent = ; } return (
{mainContent}
); } if (tab === 'calendar') { mainContent = ; } else if (tab === 'chat') { mainContent = ; } const mainContentClass = classnames( css['main-content-container'], css['main-content-container-column'], ); return (
{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, ); useBadgeHandler(); const dispatch = useDispatch(); const modalContext = useModalContext(); const modals = React.useMemo( () => modalContext.modals.map(([modal, key]) => ( {modal} )), [modalContext.modals], ); const tunnelbrokerInitMessage = useSelector(createTunnelbrokerInitMessage); return ( ); }, ); function AppWithProvider(props: BaseProps): React.Node { return ( ); } export default AppWithProvider; diff --git a/web/settings/user-settings-list.react.js b/web/settings/user-settings-list.react.js index 2cc62bce4..085ba15e2 100644 --- a/web/settings/user-settings-list.react.js +++ b/web/settings/user-settings-list.react.js @@ -1,92 +1,122 @@ // @flow import * as React from 'react'; import { useDispatch } from 'lib/utils/redux-utils.js'; import UserSettingsListItem from './user-settings-list-item.react.js'; import css from './user-settings-list.css'; import PanelHeader from '../components/panel-header.react.js'; import Panel, { type PanelData } from '../components/panel.react.js'; import { updateNavInfoActionType } from '../redux/action-types.js'; import { useStaffCanSee } from '../utils/staff-utils.js'; function UserSettingsList(): React.Node { const dispatch = useDispatch(); const staffCanSee = useStaffCanSee(); const onClickAccountSettings = React.useCallback(() => { dispatch({ type: updateNavInfoActionType, payload: { tab: 'settings', settingsSection: 'account' }, }); }, [dispatch]); + const onClickFriendList = React.useCallback(() => { + dispatch({ + type: updateNavInfoActionType, + payload: { tab: 'settings', settingsSection: 'friend-list' }, + }); + }, [dispatch]); + + const onClickBlockList = React.useCallback(() => { + dispatch({ + type: updateNavInfoActionType, + payload: { tab: 'settings', settingsSection: 'block-list' }, + }); + }, [dispatch]); + const onClickKeyservers = React.useCallback(() => { dispatch({ type: updateNavInfoActionType, payload: { tab: 'settings', settingsSection: 'keyservers' }, }); }, [dispatch]); const onClickDangerZone = React.useCallback(() => { dispatch({ type: updateNavInfoActionType, payload: { tab: 'settings', settingsSection: 'danger-zone' }, }); }, [dispatch]); const keyserverSettingsListItem = React.useMemo(() => { if (!staffCanSee) { return null; } return ( ); }, [onClickKeyservers, staffCanSee]); const userSettingsListBody = React.useMemo( () => (
+ + {keyserverSettingsListItem}
), - [keyserverSettingsListItem, onClickAccountSettings, onClickDangerZone], + [ + keyserverSettingsListItem, + onClickAccountSettings, + onClickBlockList, + onClickDangerZone, + onClickFriendList, + ], ); const panelData: $ReadOnlyArray = React.useMemo( () => [ { header: , body: userSettingsListBody, classNameOveride: css.container, }, ], [userSettingsListBody], ); const userSettingsList = React.useMemo( () => , [panelData], ); return userSettingsList; } export default UserSettingsList; diff --git a/web/types/nav-types.js b/web/types/nav-types.js index d64f05357..26bee355e 100644 --- a/web/types/nav-types.js +++ b/web/types/nav-types.js @@ -1,57 +1,61 @@ // @flow import t from 'tcomb'; import type { TInterface } from 'tcomb'; import { type BaseNavInfo } from 'lib/types/nav-types.js'; import { legacyThreadInfoValidator, type ThreadInfo, } from 'lib/types/thread-types.js'; import { type AccountUserInfo, accountUserInfoValidator, } from 'lib/types/user-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 LoginMethod = 'form' | 'qr-code'; const loginMethodValidator = t.enums.of(['form', 'qr-code']); export type NavigationSettingsSection = | 'account' + | 'friend-list' + | 'block-list' | 'keyservers' | 'danger-zone'; const navigationSettingsSectionValidator = t.enums.of([ 'account', + 'friend-list', + 'block-list', 'keyservers', '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, +loginMethod?: LoginMethod, }; export const navInfoValidator: TInterface = tShape<$Exact>({ startDate: t.String, endDate: t.String, tab: navigationTabValidator, activeChatThreadID: t.maybe(tID), pendingThread: t.maybe(legacyThreadInfoValidator), settingsSection: t.maybe(navigationSettingsSectionValidator), selectedUserList: t.maybe(t.list(accountUserInfoValidator)), chatMode: t.maybe(navigationChatModeValidator), inviteSecret: t.maybe(t.String), loginMethod: t.maybe(loginMethodValidator), });