diff --git a/web/account/account-hooks.js b/web/account/account-hooks.js index 4271dba66..15902a83e 100644 --- a/web/account/account-hooks.js +++ b/web/account/account-hooks.js @@ -1,320 +1,320 @@ // @flow import olm from '@commapp/olm'; import invariant from 'invariant'; import localforage from 'localforage'; import * as React from 'react'; -import { useDispatch } from 'react-redux'; import uuid from 'uuid'; import { initialEncryptedMessageContent, getOneTimeKeyValuesFromBlob, getPrekeyValueFromBlob, } from 'lib/shared/crypto-utils.js'; import type { SignedIdentityKeysBlob, CryptoStore, IdentityKeysBlob, CryptoStoreContextType, OLMIdentityKeys, NotificationsSessionCreatorContextType, NotificationsOlmDataType, } from 'lib/types/crypto-types.js'; import type { OlmSessionInitializationInfo } from 'lib/types/request-types.js'; +import { useDispatch } from 'lib/utils/redux-utils.js'; import { generateCryptoKey, encryptData, exportKeyToJWK, } from '../crypto/aes-gcm-crypto-utils.js'; import { NOTIFICATIONS_OLM_DATA_CONTENT, NOTIFICATIONS_OLM_DATA_ENCRYPTION_KEY, } from '../database/utils/constants.js'; import { isDesktopSafari } from '../database/utils/db-utils.js'; import { initOlm } from '../olm/olm-utils.js'; import { setCryptoStore } from '../redux/crypto-store-reducer.js'; import { useSelector } from '../redux/redux-utils.js'; const CryptoStoreContext: React.Context = React.createContext(null); const WebNotificationsSessionCreatorContext: React.Context = React.createContext(null); type Props = { +children: React.Node, }; function GetOrCreateCryptoStoreProvider(props: Props): React.Node { const dispatch = useDispatch(); const createCryptoStore = React.useCallback(async () => { await initOlm(); const identityAccount = new olm.Account(); identityAccount.create(); const { ed25519: identityED25519, curve25519: identityCurve25519 } = JSON.parse(identityAccount.identity_keys()); const identityAccountPicklingKey = uuid.v4(); const pickledIdentityAccount = identityAccount.pickle( identityAccountPicklingKey, ); const notificationAccount = new olm.Account(); notificationAccount.create(); const { ed25519: notificationED25519, curve25519: notificationCurve25519 } = JSON.parse(notificationAccount.identity_keys()); const notificationAccountPicklingKey = uuid.v4(); const pickledNotificationAccount = notificationAccount.pickle( notificationAccountPicklingKey, ); const newCryptoStore = { primaryAccount: { picklingKey: identityAccountPicklingKey, pickledAccount: pickledIdentityAccount, }, primaryIdentityKeys: { ed25519: identityED25519, curve25519: identityCurve25519, }, notificationAccount: { picklingKey: notificationAccountPicklingKey, pickledAccount: pickledNotificationAccount, }, notificationIdentityKeys: { ed25519: notificationED25519, curve25519: notificationCurve25519, }, }; dispatch({ type: setCryptoStore, payload: newCryptoStore }); return newCryptoStore; }, [dispatch]); const currentCryptoStore = useSelector(state => state.cryptoStore); const createCryptoStorePromiseRef = React.useRef>(null); const getCryptoStorePromise = React.useCallback(() => { if (currentCryptoStore) { return Promise.resolve(currentCryptoStore); } const currentCreateCryptoStorePromiseRef = createCryptoStorePromiseRef.current; if (currentCreateCryptoStorePromiseRef) { return currentCreateCryptoStorePromiseRef; } const newCreateCryptoStorePromise = (async () => { try { return await createCryptoStore(); } catch (e) { createCryptoStorePromiseRef.current = undefined; throw e; } })(); createCryptoStorePromiseRef.current = newCreateCryptoStorePromise; return newCreateCryptoStorePromise; }, [createCryptoStore, currentCryptoStore]); const isCryptoStoreSet = !!currentCryptoStore; React.useEffect(() => { if (!isCryptoStoreSet) { createCryptoStorePromiseRef.current = undefined; } }, [isCryptoStoreSet]); const contextValue = React.useMemo( () => ({ getInitializedCryptoStore: getCryptoStorePromise, }), [getCryptoStorePromise], ); return ( {props.children} ); } function useGetOrCreateCryptoStore(): () => Promise { const context = React.useContext(CryptoStoreContext); invariant(context, 'CryptoStoreContext not found'); return context.getInitializedCryptoStore; } function useGetSignedIdentityKeysBlob(): () => Promise { const getOrCreateCryptoStore = useGetOrCreateCryptoStore(); return React.useCallback(async () => { const { primaryAccount, primaryIdentityKeys, notificationIdentityKeys } = await getOrCreateCryptoStore(); await initOlm(); const primaryOLMAccount = new olm.Account(); primaryOLMAccount.unpickle( primaryAccount.picklingKey, primaryAccount.pickledAccount, ); const identityKeysBlob: IdentityKeysBlob = { primaryIdentityPublicKeys: primaryIdentityKeys, notificationIdentityPublicKeys: notificationIdentityKeys, }; const payloadToBeSigned: string = JSON.stringify(identityKeysBlob); const signedIdentityKeysBlob: SignedIdentityKeysBlob = { payload: payloadToBeSigned, signature: primaryOLMAccount.sign(payloadToBeSigned), }; return signedIdentityKeysBlob; }, [getOrCreateCryptoStore]); } function WebNotificationsSessionCreatorProvider(props: Props): React.Node { const getOrCreateCryptoStore = useGetOrCreateCryptoStore(); const currentCryptoStore = useSelector(state => state.cryptoStore); const createNewNotificationsSession = React.useCallback( async ( notificationsIdentityKeys: OLMIdentityKeys, notificationsInitializationInfo: OlmSessionInitializationInfo, ) => { const [{ notificationAccount }, encryptionKey] = await Promise.all([ getOrCreateCryptoStore(), generateCryptoKey({ extractable: isDesktopSafari }), initOlm(), ]); const account = new olm.Account(); const { picklingKey, pickledAccount } = notificationAccount; account.unpickle(picklingKey, pickledAccount); const notificationsPrekey = getPrekeyValueFromBlob( notificationsInitializationInfo.prekey, ); const [notificationsOneTimeKey] = getOneTimeKeyValuesFromBlob( notificationsInitializationInfo.oneTimeKey, ); const session = new olm.Session(); session.create_outbound( account, notificationsIdentityKeys.curve25519, notificationsIdentityKeys.ed25519, notificationsPrekey, notificationsInitializationInfo.prekeySignature, notificationsOneTimeKey, ); const { body: initialNotificationsEncryptedMessage } = session.encrypt( JSON.stringify(initialEncryptedMessageContent), ); const mainSession = session.pickle(picklingKey); const notificationsOlmData: NotificationsOlmDataType = { mainSession, pendingSessionUpdate: mainSession, updateCreationTimestamp: Date.now(), picklingKey, }; const encryptedOlmData = await encryptData( new TextEncoder().encode(JSON.stringify(notificationsOlmData)), encryptionKey, ); const persistEncryptionKeyPromise = (async () => { let cryptoKeyPersistentForm = encryptionKey; if (isDesktopSafari) { // Safari doesn't support structured clone algorithm in service // worker context so we have to store CryptoKey as JSON cryptoKeyPersistentForm = await exportKeyToJWK(encryptionKey); } await localforage.setItem( NOTIFICATIONS_OLM_DATA_ENCRYPTION_KEY, cryptoKeyPersistentForm, ); })(); await Promise.all([ localforage.setItem(NOTIFICATIONS_OLM_DATA_CONTENT, encryptedOlmData), persistEncryptionKeyPromise, ]); return initialNotificationsEncryptedMessage; }, [getOrCreateCryptoStore], ); const notificationsSessionPromise = React.useRef>(null); const createNotificationsSession = React.useCallback( async ( notificationsIdentityKeys: OLMIdentityKeys, notificationsInitializationInfo: OlmSessionInitializationInfo, ) => { if (notificationsSessionPromise.current) { return notificationsSessionPromise.current; } const newNotificationsSessionPromise = (async () => { try { return await createNewNotificationsSession( notificationsIdentityKeys, notificationsInitializationInfo, ); } catch (e) { notificationsSessionPromise.current = undefined; throw e; } })(); notificationsSessionPromise.current = newNotificationsSessionPromise; return newNotificationsSessionPromise; }, [createNewNotificationsSession], ); const isCryptoStoreSet = !!currentCryptoStore; React.useEffect(() => { if (!isCryptoStoreSet) { notificationsSessionPromise.current = undefined; } }, [isCryptoStoreSet]); const contextValue = React.useMemo( () => ({ notificationsSessionCreator: createNotificationsSession, }), [createNotificationsSession], ); return ( {props.children} ); } function useWebNotificationsSessionCreator(): ( notificationsIdentityKeys: OLMIdentityKeys, notificationsInitializationInfo: OlmSessionInitializationInfo, ) => Promise { const context = React.useContext(WebNotificationsSessionCreatorContext); invariant(context, 'WebNotificationsSessionCreator not found.'); return context.notificationsSessionCreator; } export { useGetSignedIdentityKeysBlob, useGetOrCreateCryptoStore, WebNotificationsSessionCreatorProvider, useWebNotificationsSessionCreator, GetOrCreateCryptoStoreProvider, }; diff --git a/web/account/log-in-form.react.js b/web/account/log-in-form.react.js index cf12893a9..39e1556bf 100644 --- a/web/account/log-in-form.react.js +++ b/web/account/log-in-form.react.js @@ -1,87 +1,87 @@ // @flow import { useConnectModal } from '@rainbow-me/rainbowkit'; import * as React from 'react'; -import { useDispatch } from 'react-redux'; import { useWalletClient } from 'wagmi'; import { isDev } from 'lib/utils/dev-utils.js'; +import { useDispatch } from 'lib/utils/redux-utils.js'; import { useGetOrCreateCryptoStore } from './account-hooks.js'; import css from './log-in-form.css'; import SIWEButton from './siwe-button.react.js'; import SIWELoginForm from './siwe-login-form.react.js'; import TraditionalLoginForm from './traditional-login-form.react.js'; import Button from '../components/button.react.js'; import OrBreak from '../components/or-break.react.js'; import { updateNavInfoActionType } from '../redux/action-types.js'; function LoginForm(): React.Node { const { openConnectModal } = useConnectModal(); const { data: signer } = useWalletClient(); const dispatch = useDispatch(); const getOrCreateCryptoStore = useGetOrCreateCryptoStore(); React.useEffect(() => { getOrCreateCryptoStore(); }, [getOrCreateCryptoStore]); const onQRCodeLoginButtonClick = React.useCallback(() => { dispatch({ type: updateNavInfoActionType, payload: { loginMethod: 'qr-code', }, }); }, [dispatch]); const qrCodeLoginButton = React.useMemo(() => { if (!isDev) { return null; } return (
); }, [onQRCodeLoginButtonClick]); const [siweAuthFlowSelected, setSIWEAuthFlowSelected] = React.useState(false); const onSIWEButtonClick = React.useCallback(() => { setSIWEAuthFlowSelected(true); openConnectModal && openConnectModal(); }, [openConnectModal]); const cancelSIWEAuthFlow = React.useCallback(() => { setSIWEAuthFlowSelected(false); }, []); if (siweAuthFlowSelected && signer) { return (
); } return (
{qrCodeLoginButton}
); } export default LoginForm; diff --git a/web/account/siwe-login-form.react.js b/web/account/siwe-login-form.react.js index 4162fc733..4bc6624f8 100644 --- a/web/account/siwe-login-form.react.js +++ b/web/account/siwe-login-form.react.js @@ -1,265 +1,265 @@ // @flow import '@rainbow-me/rainbowkit/styles.css'; import classNames from 'classnames'; import invariant from 'invariant'; import * as React from 'react'; -import { useDispatch } from 'react-redux'; import { useAccount, useWalletClient } from 'wagmi'; import { setDataLoadedActionType } from 'lib/actions/client-db-store-actions.js'; import { getSIWENonce, getSIWENonceActionTypes, siweAuth, siweAuthActionTypes, } from 'lib/actions/siwe-actions.js'; import ConnectedWalletInfo from 'lib/components/connected-wallet-info.react.js'; import SWMansionIcon from 'lib/components/SWMansionIcon.react.js'; import stores from 'lib/facts/stores.js'; import { createLoadingStatusSelector } from 'lib/selectors/loading-selectors.js'; import type { LogInStartingPayload } from 'lib/types/account-types.js'; import type { OLMIdentityKeys } from 'lib/types/crypto-types.js'; import { useDispatchActionPromise, useServerCall, } from 'lib/utils/action-utils.js'; import { ServerError } from 'lib/utils/errors.js'; +import { useDispatch } from 'lib/utils/redux-utils.js'; import { createSIWEMessage, getSIWEStatementForPublicKey, siweMessageSigningExplanationStatements, } from 'lib/utils/siwe-utils.js'; import { useGetSignedIdentityKeysBlob } from './account-hooks.js'; import HeaderSeparator from './header-separator.react.js'; import css from './siwe.css'; import Button from '../components/button.react.js'; import OrBreak from '../components/or-break.react.js'; import LoadingIndicator from '../loading-indicator.react.js'; import { useSelector } from '../redux/redux-utils.js'; import { webLogInExtraInfoSelector } from '../selectors/account-selectors.js'; type SIWELogInError = 'account_does_not_exist'; type SIWELoginFormProps = { +cancelSIWEAuthFlow: () => void, }; const getSIWENonceLoadingStatusSelector = createLoadingStatusSelector( getSIWENonceActionTypes, ); const siweAuthLoadingStatusSelector = createLoadingStatusSelector(siweAuthActionTypes); function SIWELoginForm(props: SIWELoginFormProps): React.Node { const { address } = useAccount(); const { data: signer } = useWalletClient(); const dispatchActionPromise = useDispatchActionPromise(); const getSIWENonceCall = useServerCall(getSIWENonce); const getSIWENonceCallLoadingStatus = useSelector( getSIWENonceLoadingStatusSelector, ); const siweAuthLoadingStatus = useSelector(siweAuthLoadingStatusSelector); const siweAuthCall = useServerCall(siweAuth); const logInExtraInfo = useSelector(webLogInExtraInfoSelector); const [siweNonce, setSIWENonce] = React.useState(null); const siweNonceShouldBeFetched = !siweNonce && getSIWENonceCallLoadingStatus !== 'loading'; React.useEffect(() => { if (!siweNonceShouldBeFetched) { return; } dispatchActionPromise( getSIWENonceActionTypes, (async () => { const response = await getSIWENonceCall(); setSIWENonce(response); })(), ); }, [dispatchActionPromise, getSIWENonceCall, siweNonceShouldBeFetched]); const primaryIdentityPublicKeys: ?OLMIdentityKeys = useSelector( state => state.cryptoStore?.primaryIdentityKeys, ); const getSignedIdentityKeysBlob = useGetSignedIdentityKeysBlob(); const callSIWEAuthEndpoint = React.useCallback( async (message: string, signature: string, extraInfo) => { const signedIdentityKeysBlob = await getSignedIdentityKeysBlob(); invariant( signedIdentityKeysBlob, 'signedIdentityKeysBlob must be set in attemptSIWEAuth', ); try { return await siweAuthCall({ message, signature, signedIdentityKeysBlob, doNotRegister: true, ...extraInfo, }); } catch (e) { if ( e instanceof ServerError && e.message === 'account_does_not_exist' ) { setError('account_does_not_exist'); } throw e; } }, [getSignedIdentityKeysBlob, siweAuthCall], ); const attemptSIWEAuth = React.useCallback( (message: string, signature: string) => { const extraInfo = logInExtraInfo(); return dispatchActionPromise( siweAuthActionTypes, callSIWEAuthEndpoint(message, signature, extraInfo), undefined, ({ calendarQuery: extraInfo.calendarQuery }: LogInStartingPayload), ); }, [callSIWEAuthEndpoint, dispatchActionPromise, logInExtraInfo], ); const dispatch = useDispatch(); const onSignInButtonClick = React.useCallback(async () => { invariant(signer, 'signer must be present during SIWE attempt'); invariant(siweNonce, 'nonce must be present during SIWE attempt'); invariant( primaryIdentityPublicKeys, 'primaryIdentityPublicKeys must be present during SIWE attempt', ); const statement = getSIWEStatementForPublicKey( primaryIdentityPublicKeys.ed25519, ); const message = createSIWEMessage(address, statement, siweNonce); const signature = await signer.signMessage({ message }); await attemptSIWEAuth(message, signature); dispatch({ type: setDataLoadedActionType, payload: { dataLoaded: true, }, }); }, [ address, attemptSIWEAuth, primaryIdentityPublicKeys, signer, siweNonce, dispatch, ]); const { cancelSIWEAuthFlow } = props; const backButtonColor = React.useMemo( () => ({ backgroundColor: '#211E2D' }), [], ); const signInButtonColor = React.useMemo( () => ({ backgroundColor: '#6A20E3' }), [], ); const [error, setError] = React.useState(); const mainMiddleAreaClassName = classNames({ [css.mainMiddleArea]: true, [css.hidden]: !!error, }); const errorOverlayClassNames = classNames({ [css.errorOverlay]: true, [css.hidden]: !error, }); if ( siweAuthLoadingStatus === 'loading' || !siweNonce || !primaryIdentityPublicKeys ) { return (
); } let errorText; if (error === 'account_does_not_exist') { errorText = ( <>

No Comm account found for that Ethereum wallet!

We require that users register on their mobile devices. Comm relies on a primary device capable of scanning QR codes in order to authorize secondary devices.

You can install our iOS app  here , or our Android app  here .

); } return (

Sign in with Ethereum

Wallet Connected

{siweMessageSigningExplanationStatements}

By signing up, you agree to our{' '} Terms of Use &{' '} Privacy Policy.

{errorText}
); } export default SIWELoginForm; diff --git a/web/app.react.js b/web/app.react.js index 0ddace9c2..590d25d33 100644 --- a/web/app.react.js +++ b/web/app.react.js @@ -1,401 +1,401 @@ // @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 { useDispatch } from 'react-redux'; 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 { 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 { 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 { TooltipProvider } from './chat/tooltip-provider.js'; import NavigationArrows from './components/navigation-arrows.react.js'; import { initOpaque } from './crypto/opaque-utils.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 { type NavInfo } from './types/nav-types.js'; import { canonicalURLFromReduxState, navInfoFromURL } from './url-utils.js'; 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; registerConfig({ // We can't securely cache credentials on web, so we have no way to recover // from a cookie invalidation resolveInvalidatedCookie: null, setSessionIDOnRequest: true, // Never reset the calendar range calendarRangeInactivityLimit: null, platformDetails: { platform: electron?.platform ?? 'web', codeVersion: 43, stateVersion: persistConfig.version, }, }); 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() { let content; if (this.props.loggedIn) { content = ( <> {this.renderMainContent()} {this.props.modals} ); } else { content = ( <> {this.renderLoginPage()} {this.props.modals} ); } return ( {content} ); } onHeaderDoubleClick = () => electron?.doubleClickTopBar(); stopDoubleClickPropagation = electron ? e => e.stopPropagation() : null; renderLoginPage() { const { loginMethod } = this.props.navInfo; if (loginMethod === 'qr-code') { return ; } return ; } renderMainContent() { 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() { const { tab, settingsSection } = this.props.navInfo; let mainContent; if (tab === 'settings') { if (settingsSection === 'account') { mainContent = ; } 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/calendar/day.react.js b/web/calendar/day.react.js index 95883a335..111c7a1bf 100644 --- a/web/calendar/day.react.js +++ b/web/calendar/day.react.js @@ -1,259 +1,259 @@ // @flow import classNames from 'classnames'; import invariant from 'invariant'; import _some from 'lodash/fp/some.js'; import * as React from 'react'; -import { useDispatch } from 'react-redux'; import { createLocalEntry, createLocalEntryActionType, } from 'lib/actions/entry-actions.js'; import { useModalContext, type PushModal, } from 'lib/components/modal-provider.react.js'; import { onScreenThreadInfos as onScreenThreadInfosSelector } from 'lib/selectors/thread-selectors.js'; import { entryKey } from 'lib/shared/entry-utils.js'; import type { EntryInfo } from 'lib/types/entry-types.js'; import type { MinimallyEncodedThreadInfo } from 'lib/types/minimally-encoded-thread-permissions-types.js'; import type { Dispatch } from 'lib/types/redux-types.js'; import type { ThreadInfo } from 'lib/types/thread-types.js'; import { dateString, dateFromString } from 'lib/utils/date-utils.js'; +import { useDispatch } from 'lib/utils/redux-utils.js'; import css from './calendar.css'; import type { InnerEntry } from './entry.react.js'; import Entry from './entry.react.js'; import LogInFirstModal from '../modals/account/log-in-first-modal.react.js'; import HistoryModal from '../modals/history/history-modal.react.js'; import ThreadPickerModal from '../modals/threads/thread-picker-modal.react.js'; import { useSelector } from '../redux/redux-utils.js'; import { htmlTargetFromEvent } from '../vector-utils.js'; import { AddVector, HistoryVector } from '../vectors.react.js'; type BaseProps = { +dayString: string, +entryInfos: $ReadOnlyArray, +startingTabIndex: number, }; type Props = { ...BaseProps, +onScreenThreadInfos: $ReadOnlyArray, +viewerID: ?string, +loggedIn: boolean, +nextLocalID: number, +dispatch: Dispatch, +pushModal: PushModal, +popModal: () => void, }; type State = { +hovered: boolean, }; class Day extends React.PureComponent { state: State = { hovered: false, }; entryContainer: ?HTMLDivElement; entryContainerSpacer: ?HTMLDivElement; actionLinks: ?HTMLDivElement; entries: Map = new Map(); componentDidUpdate(prevProps: Props) { if (this.props.entryInfos.length > prevProps.entryInfos.length) { invariant(this.entryContainer, 'entryContainer ref not set'); this.entryContainer.scrollTop = this.entryContainer.scrollHeight; } } render() { const now = new Date(); const isToday = dateString(now) === this.props.dayString; const tdClasses = classNames(css.day, { [css.currentDay]: isToday }); let actionLinks = null; const hovered = this.state.hovered; if (hovered) { const actionLinksClassName = `${css.actionLinks} ${css.dayActionLinks}`; actionLinks = ( ); } const entries = this.props.entryInfos .filter(entryInfo => _some(['id', entryInfo.threadID])(this.props.onScreenThreadInfos), ) .map((entryInfo, i) => { const key = entryKey(entryInfo); return ( ); }); const entryContainerClasses = classNames(css.entryContainer, { [css.focusedEntryContainer]: hovered, }); const date = dateFromString(this.props.dayString); return (

{date.getDate()}

{entries}
{actionLinks} ); } actionLinksRef = (actionLinks: ?HTMLDivElement) => { this.actionLinks = actionLinks; }; entryContainerRef = (entryContainer: ?HTMLDivElement) => { this.entryContainer = entryContainer; }; entryContainerSpacerRef = (entryContainerSpacer: ?HTMLDivElement) => { this.entryContainerSpacer = entryContainerSpacer; }; entryRef = (key: string, entry: InnerEntry) => { this.entries.set(key, entry); }; onMouseEnter = () => { this.setState({ hovered: true }); }; onMouseLeave = () => { this.setState({ hovered: false }); }; onClick = (event: SyntheticEvent) => { const target = htmlTargetFromEvent(event); invariant( this.entryContainer instanceof HTMLDivElement, "entryContainer isn't div", ); invariant( this.entryContainerSpacer instanceof HTMLDivElement, "entryContainerSpacer isn't div", ); if ( target === this.entryContainer || target === this.entryContainerSpacer || (this.actionLinks && target === this.actionLinks) ) { this.onAddEntry(event); } }; onAddEntry = (event: SyntheticEvent<*>) => { event.preventDefault(); invariant( this.props.onScreenThreadInfos.length > 0, "onAddEntry shouldn't be clicked if no onScreenThreadInfos", ); if (this.props.onScreenThreadInfos.length === 1) { this.createNewEntry(this.props.onScreenThreadInfos[0].id); } else if (this.props.onScreenThreadInfos.length > 1) { this.props.pushModal( , ); } }; createNewEntry = (threadID: string) => { if (!this.props.loggedIn) { this.props.pushModal(); return; } const viewerID = this.props.viewerID; invariant(viewerID, 'should have viewerID in order to create thread'); this.props.dispatch({ type: createLocalEntryActionType, payload: createLocalEntry( threadID, this.props.nextLocalID, this.props.dayString, viewerID, ), }); }; onHistory = (event: SyntheticEvent) => { event.preventDefault(); this.props.pushModal( , ); }; focusOnFirstEntryNewerThan = (time: number) => { const entryInfo = this.props.entryInfos.find( candidate => candidate.creationTime > time, ); if (entryInfo) { const entry = this.entries.get(entryKey(entryInfo)); invariant(entry, 'entry for entryinfo should be defined'); entry.focus(); } }; } const ConnectedDay: React.ComponentType = React.memo( function ConnectedDay(props) { const onScreenThreadInfos = useSelector(onScreenThreadInfosSelector); const viewerID = useSelector(state => state.currentUserInfo?.id); const loggedIn = useSelector( state => !!(state.currentUserInfo && !state.currentUserInfo.anonymous && true), ); const nextLocalID = useSelector(state => state.nextLocalID); const dispatch = useDispatch(); const { pushModal, popModal } = useModalContext(); return ( ); }, ); export default ConnectedDay; diff --git a/web/calendar/entry.react.js b/web/calendar/entry.react.js index 2dace3b53..9ba7115c7 100644 --- a/web/calendar/entry.react.js +++ b/web/calendar/entry.react.js @@ -1,507 +1,507 @@ // @flow import classNames from 'classnames'; import invariant from 'invariant'; import * as React from 'react'; -import { useDispatch } from 'react-redux'; import { createEntryActionTypes, useCreateEntry, saveEntryActionTypes, useSaveEntry, deleteEntryActionTypes, useDeleteEntry, concurrentModificationResetActionType, } from 'lib/actions/entry-actions.js'; import { useModalContext, type PushModal, } from 'lib/components/modal-provider.react.js'; import { connectionSelector } from 'lib/selectors/keyserver-selectors.js'; import { threadInfoSelector } from 'lib/selectors/thread-selectors.js'; import { colorIsDark } from 'lib/shared/color-utils.js'; import { entryKey } from 'lib/shared/entry-utils.js'; import { threadHasPermission } from 'lib/shared/thread-utils.js'; import type { Shape } from 'lib/types/core.js'; import { type EntryInfo, type CreateEntryInfo, type SaveEntryInfo, type SaveEntryResult, type SaveEntryPayload, type CreateEntryPayload, type DeleteEntryInfo, type DeleteEntryResult, type CalendarQuery, } from 'lib/types/entry-types.js'; import type { LoadingStatus } from 'lib/types/loading-types.js'; import type { MinimallyEncodedResolvedThreadInfo } from 'lib/types/minimally-encoded-thread-permissions-types.js'; import type { Dispatch } from 'lib/types/redux-types.js'; import { threadPermissions } from 'lib/types/thread-permission-types.js'; import type { ResolvedThreadInfo } from 'lib/types/thread-types.js'; import { type DispatchActionPromise, useDispatchActionPromise, } from 'lib/utils/action-utils.js'; import { dateString } from 'lib/utils/date-utils.js'; import { useResolvedThreadInfo } from 'lib/utils/entity-helpers.js'; import { ServerError } from 'lib/utils/errors.js'; +import { useDispatch } from 'lib/utils/redux-utils.js'; import { ashoatKeyserverID } from 'lib/utils/validation-utils.js'; import css from './calendar.css'; import LoadingIndicator from '../loading-indicator.react.js'; import LogInFirstModal from '../modals/account/log-in-first-modal.react.js'; import ConcurrentModificationModal from '../modals/concurrent-modification-modal.react.js'; import HistoryModal from '../modals/history/history-modal.react.js'; import { useSelector } from '../redux/redux-utils.js'; import { nonThreadCalendarQuery } from '../selectors/nav-selectors.js'; import { HistoryVector, DeleteVector } from '../vectors.react.js'; type BaseProps = { +innerRef: (key: string, me: Entry) => void, +entryInfo: EntryInfo, +focusOnFirstEntryNewerThan: (time: number) => void, +tabIndex: number, }; type Props = { ...BaseProps, +threadInfo: ResolvedThreadInfo | MinimallyEncodedResolvedThreadInfo, +loggedIn: boolean, +calendarQuery: () => CalendarQuery, +online: boolean, +dispatch: Dispatch, +dispatchActionPromise: DispatchActionPromise, +createEntry: (info: CreateEntryInfo) => Promise, +saveEntry: (info: SaveEntryInfo) => Promise, +deleteEntry: (info: DeleteEntryInfo) => Promise, +pushModal: PushModal, +popModal: () => void, }; type State = { +focused: boolean, +loadingStatus: LoadingStatus, +text: string, }; class Entry extends React.PureComponent { textarea: ?HTMLTextAreaElement; creating: boolean; needsUpdateAfterCreation: boolean; needsDeleteAfterCreation: boolean; nextSaveAttemptIndex: number; mounted: boolean; currentlySaving: ?string; constructor(props: Props) { super(props); this.state = { focused: false, loadingStatus: 'inactive', text: props.entryInfo.text, }; this.creating = false; this.needsUpdateAfterCreation = false; this.needsDeleteAfterCreation = false; this.nextSaveAttemptIndex = 0; } guardedSetState(input: Shape) { if (this.mounted) { this.setState(input); } } componentDidMount() { this.mounted = true; this.props.innerRef(entryKey(this.props.entryInfo), this); this.updateHeight(); // Whenever a new Entry is created, focus on it if (!this.props.entryInfo.id) { this.focus(); } } componentDidUpdate(prevProps: Props) { if ( !this.state.focused && this.props.entryInfo.text !== this.state.text && this.props.entryInfo.text !== prevProps.entryInfo.text ) { this.setState({ text: this.props.entryInfo.text }); this.currentlySaving = null; } if ( this.props.online && !prevProps.online && this.state.loadingStatus === 'error' ) { this.save(); } } focus() { invariant( this.textarea instanceof HTMLTextAreaElement, 'textarea ref not set', ); this.textarea.focus(); } onMouseDown: (event: SyntheticEvent) => void = event => { if (this.state.focused && event.target !== this.textarea) { // Don't lose focus when some non-textarea part is clicked event.preventDefault(); } }; componentWillUnmount() { this.mounted = false; } updateHeight: () => void = () => { invariant( this.textarea instanceof HTMLTextAreaElement, 'textarea ref not set', ); this.textarea.style.height = 'auto'; this.textarea.style.height = this.textarea.scrollHeight + 'px'; }; render(): React.Node { let actionLinks = null; if (this.state.focused) { let historyButton = null; if (this.props.entryInfo.id) { historyButton = ( History ); } const rightActionLinksClassName = `${css.rightActionLinks} ${css.actionLinksText}`; actionLinks = (
Delete {historyButton} {this.props.threadInfo.uiName}
); } const darkColor = colorIsDark(this.props.threadInfo.color); const entryClasses = classNames({ [css.entry]: true, [css.darkEntry]: darkColor, [css.focusedEntry]: this.state.focused, }); const style = { backgroundColor: `#${this.props.threadInfo.color}` }; const loadingIndicatorColor = darkColor ? 'white' : 'black'; const canEditEntry = threadHasPermission( this.props.threadInfo, threadPermissions.EDIT_ENTRIES, ); return (