diff --git a/landing/siwe.react.js b/landing/siwe.react.js index 51066d31d..ffd1e9d28 100644 --- a/landing/siwe.react.js +++ b/landing/siwe.react.js @@ -1,184 +1,189 @@ // @flow import { useConnectModal, RainbowKitProvider, darkTheme, useModalState, } from '@rainbow-me/rainbowkit'; import '@rainbow-me/rainbowkit/styles.css'; import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; import invariant from 'invariant'; import _merge from 'lodash/fp/merge.js'; import * as React from 'react'; import { useAccount, useWalletClient, WagmiProvider } from 'wagmi'; import ConnectedWalletInfo from 'lib/components/connected-wallet-info.react.js'; import type { SIWEWebViewMessage } from 'lib/types/siwe-types.js'; import { getSIWEStatementForPublicKey, siweMessageSigningExplanationStatements, createSIWEMessage, } from 'lib/utils/siwe-utils.js'; -import { AlchemyENSCacheProvider, wagmiConfig } from 'lib/utils/wagmi-utils.js'; +import { + AlchemyENSCacheProvider, + getWagmiConfig, +} from 'lib/utils/wagmi-utils.js'; import { SIWEContext } from './siwe-context.js'; import css from './siwe.css'; import { useMonitorForWalletConnectModal, type WalletConnectModalUpdate, } from './walletconnect-hooks.js'; function postMessageToNativeWebView(message: SIWEWebViewMessage) { window.ReactNativeWebView?.postMessage?.(JSON.stringify(message)); } +const wagmiConfig = getWagmiConfig(); + type Signer = { +signMessage: ({ +message: string, ... }) => Promise, ... }; async function signInWithEthereum( address: string, signer: Signer, nonce: string, statement: string, ) { invariant(nonce, 'nonce must be present in signInWithEthereum'); const message = createSIWEMessage(address, statement, nonce); const signature = await signer.signMessage({ message }); postMessageToNativeWebView({ type: 'siwe_success', address, message, signature, }); } const queryClient = new QueryClient(); function SIWE(): React.Node { const { address } = useAccount(); const { data: signer } = useWalletClient(); const { siweNonce, siwePrimaryIdentityPublicKey } = React.useContext(SIWEContext); const onClick = React.useCallback(() => { invariant(siweNonce, 'nonce must be present during SIWE attempt'); invariant( siwePrimaryIdentityPublicKey, 'primaryIdentityPublicKey must be present during SIWE attempt', ); const statement = getSIWEStatementForPublicKey( siwePrimaryIdentityPublicKey, ); void signInWithEthereum(address, signer, siweNonce, statement); }, [address, signer, siweNonce, siwePrimaryIdentityPublicKey]); const { openConnectModal } = useConnectModal(); const hasNonce = siweNonce !== null && siweNonce !== undefined; React.useEffect(() => { if (hasNonce && openConnectModal) { openConnectModal(); } }, [hasNonce, openConnectModal]); const [wcModalOpen, setWCModalOpen] = React.useState(false); const prevConnectModalOpen = React.useRef(false); const modalState = useModalState(); const closeTimeoutRef = React.useRef(); const { connectModalOpen } = modalState; React.useEffect(() => { if ( !connectModalOpen && !wcModalOpen && prevConnectModalOpen.current && !signer ) { closeTimeoutRef.current = setTimeout( () => postMessageToNativeWebView({ type: 'siwe_closed' }), 250, ); } else if (closeTimeoutRef.current) { clearTimeout(closeTimeoutRef.current); closeTimeoutRef.current = undefined; } prevConnectModalOpen.current = connectModalOpen; }, [connectModalOpen, wcModalOpen, signer]); const onWalletConnectModalUpdate = React.useCallback( (update: WalletConnectModalUpdate) => { if (update.state === 'closed') { setWCModalOpen(false); postMessageToNativeWebView({ type: 'walletconnect_modal_update', ...update, }); } else { setWCModalOpen(true); postMessageToNativeWebView({ type: 'walletconnect_modal_update', ...update, }); } }, [], ); useMonitorForWalletConnectModal(onWalletConnectModalUpdate); if (!hasNonce) { return (

Unable to proceed: nonce not found.

); } else if (!signer) { return null; } else { return (

Wallet Connected

{siweMessageSigningExplanationStatements}

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

Sign in using this wallet
); } } function SIWEWrapper(): React.Node { const theme = React.useMemo(() => { return _merge(darkTheme())({ radii: { modal: 0, modalMobile: 0, }, colors: { modalBackdrop: '#242529', }, }); }, []); return ( ); } export default SIWEWrapper; diff --git a/lib/utils/wagmi-utils.js b/lib/utils/wagmi-utils.js index 357d0b695..a71c74465 100644 --- a/lib/utils/wagmi-utils.js +++ b/lib/utils/wagmi-utils.js @@ -1,60 +1,69 @@ // @flow import { connectorsForWallets } from '@rainbow-me/rainbowkit'; import { injectedWallet, metaMaskWallet, rainbowWallet, walletConnectWallet, // eslint-disable-next-line import/extensions } from '@rainbow-me/rainbowkit/wallets'; import { AlchemyProvider } from 'ethers'; import * as React from 'react'; import { http } from 'viem'; import { createConfig } from 'wagmi'; // eslint-disable-next-line import/extensions import { mainnet } from 'wagmi/chains'; import { ENSCacheProvider } from '../components/ens-cache-provider.react.js'; -const wallets = [injectedWallet]; const projectId = process.env.COMM_WALLETCONNECT_KEY; -if (projectId) { - wallets.push(rainbowWallet, metaMaskWallet, walletConnectWallet); -} - -const connectors = connectorsForWallets( - [{ groupName: 'Recommended', wallets }], - { - appName: 'Comm', - projectId, - }, -); - const alchemyKey = process.env.COMM_ALCHEMY_KEY; const transport = alchemyKey ? http(`https://eth-mainnet.g.alchemy.com/v2/${alchemyKey}`) : http(); -const wagmiConfig: mixed = createConfig({ - connectors, - chains: [mainnet], - transports: { - [mainnet.id]: transport, - }, -}); + +type GetWagmiConfigParams = { + +includeInjectedWallet?: ?boolean, +}; +function getWagmiConfig(params?: ?GetWagmiConfigParams): mixed { + const wallets = []; + if (params?.includeInjectedWallet) { + wallets.push(injectedWallet); + } + if (projectId) { + wallets.push(rainbowWallet, metaMaskWallet, walletConnectWallet); + } + + const connectors = connectorsForWallets( + [{ groupName: 'Recommended', wallets }], + { + appName: 'Comm', + projectId, + }, + ); + + return createConfig({ + connectors, + chains: [mainnet], + transports: { + [mainnet.id]: transport, + }, + }); +} const ethersAlchemyProvider = new AlchemyProvider('mainnet', alchemyKey); type Props = { +children: React.Node, }; function AlchemyENSCacheProvider(props: Props): React.Node { const { children } = props; return ( {children} ); } -export { wagmiConfig, AlchemyENSCacheProvider }; +export { getWagmiConfig, AlchemyENSCacheProvider }; diff --git a/web/app.react.js b/web/app.react.js index 46e18e4bf..72813959d 100644 --- a/web/app.react.js +++ b/web/app.react.js @@ -1,563 +1,568 @@ // @flow import 'basscss/css/basscss.min.css'; import './theme.css'; import { config as faConfig } from '@fortawesome/fontawesome-svg-core'; import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; 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 { WagmiProvider } 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 { StaffContextProvider } from 'lib/components/staff-provider.react.js'; import { IdentitySearchProvider } from 'lib/identity-search/identity-search-context.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 type { SecondaryTunnelbrokerConnection } from 'lib/tunnelbroker/secondary-tunnelbroker-connection.js'; import { TunnelbrokerProvider } from 'lib/tunnelbroker/tunnelbroker-context.js'; import type { LoadingStatus } from 'lib/types/loading-types.js'; import type { WebNavInfo } from 'lib/types/nav-types.js'; import type { Dispatch } from 'lib/types/redux-types.js'; import type { MessageToDeviceRequest } from 'lib/types/tunnelbroker/message-to-device-request-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 { + AlchemyENSCacheProvider, + getWagmiConfig, +} from 'lib/utils/wagmi-utils.js'; import QrCodeLogin from './account/qr-code-login.react.js'; import AppThemeWrapper from './app-theme-wrapper.react.js'; import { authoritativeKeyserverID } from './authoritative-keyserver.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 { MemberListSidebarProvider } from './chat/member-list-sidebar/member-list-sidebar-provider.react.js'; import NavigationArrows from './components/navigation-arrows.react.js'; import { olmAPI } from './crypto/olm-api.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 DisconnectedBar from './redux/disconnected-bar.js'; import FocusHandler from './redux/focus-handler.react.js'; import { KeyserverReachabilityHandler } from './redux/keyserver-reachability-handler.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 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 { getCommSharedWorker } from './shared-worker/shared-worker-provider.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 { canonicalURLFromReduxState, navInfoFromURL } from './url-utils.js'; import { useWebLock, TUNNELBROKER_LOCK_NAME } from './web-lock.js'; // 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: 76, stateVersion: persistConfig.version, ...desktopDetails, }, authoritativeKeyserverID, olmAPI, }); 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 shared worker immediately void getCommSharedWorker(); const queryClient = new QueryClient(); +const wagmiConfig = getWagmiConfig({ includeInjectedWallet: true }); + type BaseProps = { +location: { +pathname: string, ... }, }; type Props = { ...BaseProps, // Redux state +navInfo: WebNavInfo, +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 === 'build-info') { mainContent = null; } 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 WEB_TUNNELBROKER_CHANNEL = new BroadcastChannel('shared-tunnelbroker'); const WEB_TUNNELBROKER_MESSAGE_TYPES = Object.freeze({ SEND_MESSAGE: 'send-message', MESSAGE_STATUS: 'message-status', }); function useOtherTabsTunnelbrokerConnection(): SecondaryTunnelbrokerConnection { const onSendMessageCallbacks = React.useRef< Set<(MessageToDeviceRequest) => mixed>, >(new Set()); const onMessageStatusCallbacks = React.useRef< Set<(messageID: string, error: ?string) => mixed>, >(new Set()); React.useEffect(() => { const messageHandler = (event: MessageEvent) => { if (typeof event.data !== 'object' || !event.data) { console.log( 'Invalid message received from shared ' + 'tunnelbroker broadcast channel', event.data, ); return; } const data = event.data; if (data.type === WEB_TUNNELBROKER_MESSAGE_TYPES.SEND_MESSAGE) { if (typeof data.message !== 'object' || !data.message) { console.log( 'Invalid tunnelbroker message request received ' + 'from shared tunnelbroker broadcast channel', event.data, ); return; } // We know that the input was already validated const message: MessageToDeviceRequest = (data.message: any); for (const callback of onSendMessageCallbacks.current) { callback(message); } } else if (data.type === WEB_TUNNELBROKER_MESSAGE_TYPES.MESSAGE_STATUS) { if (typeof data.messageID !== 'string') { console.log( 'Missing message id in message status message ' + 'from shared tunnelbroker broadcast channel', ); return; } const messageID = data.messageID; if ( typeof data.error !== 'string' && data.error !== null && data.error !== undefined ) { console.log( 'Invalid error in message status message ' + 'from shared tunnelbroker broadcast channel', data.error, ); return; } const error = data.error; for (const callback of onMessageStatusCallbacks.current) { callback(messageID, error); } } else { console.log( 'Invalid message type ' + 'from shared tunnelbroker broadcast channel', data, ); } }; WEB_TUNNELBROKER_CHANNEL.addEventListener('message', messageHandler); return () => WEB_TUNNELBROKER_CHANNEL.removeEventListener('message', messageHandler); }, [onMessageStatusCallbacks, onSendMessageCallbacks]); return React.useMemo( () => ({ sendMessage: message => WEB_TUNNELBROKER_CHANNEL.postMessage({ type: WEB_TUNNELBROKER_MESSAGE_TYPES.SEND_MESSAGE, message, }), onSendMessage: callback => { onSendMessageCallbacks.current.add(callback); return () => { onSendMessageCallbacks.current.delete(callback); }; }, setMessageStatus: (messageID, error) => { WEB_TUNNELBROKER_CHANNEL.postMessage({ type: WEB_TUNNELBROKER_MESSAGE_TYPES.MESSAGE_STATUS, messageID, error, }); }, onMessageStatus: callback => { onMessageStatusCallbacks.current.add(callback); return () => { onMessageStatusCallbacks.current.delete(callback); }; }, }), [onMessageStatusCallbacks, onSendMessageCallbacks], ); } 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 { lockStatus, releaseLockOrAbortRequest } = useWebLock( TUNNELBROKER_LOCK_NAME, ); const secondaryTunnelbrokerConnection: SecondaryTunnelbrokerConnection = useOtherTabsTunnelbrokerConnection(); return ( ); }, ); function AppWithProvider(props: BaseProps): React.Node { return ( ); } export default AppWithProvider;