diff --git a/components.json b/components.json index 3c34db1d978..f1a99c9ba1b 100644 --- a/components.json +++ b/components.json @@ -16,5 +16,8 @@ "src/components/views/dialogs/InviteDialog.tsx": "src/components/views/dialogs/InviteDialog.tsx", "src/components/views/right_panel/UserInfo.tsx": "src/components/views/right_panel/UserInfo.tsx", "src/components/structures/HomePage.tsx": "src/components/structures/HomePage.tsx", - "src/components/views/dialogs/spotlight/SpotlightDialog.tsx": "src/components/views/dialogs/spotlight/SpotlightDialog.tsx" + "src/components/views/dialogs/spotlight/SpotlightDialog.tsx": "src/components/views/dialogs/spotlight/SpotlightDialog.tsx", + "src/components/structures/auth/Registration.tsx": "src/components/structures/auth/Registration.tsx", + "src/components/views/auth/RegistrationForm.tsx": "src/components/views/auth/RegistrationForm.tsx", + "src/components/structures/MatrixChat.tsx": "src/components/structures/MatrixChat.tsx" } diff --git a/config.sample.json b/config.sample.json index 7974608974a..a6f57e1aba0 100644 --- a/config.sample.json +++ b/config.sample.json @@ -47,5 +47,6 @@ "brand": "Element Call" }, "map_style_url": "https://api.maptiler.com/maps/streets/style.json?key=fU3vlMsMn4Jb6dnEIFsx", - "community_bot_user_id": "@communitybot:superhero.com" + "community_bot_user_id": "@communitybot:superhero.com", + "affiliation_registration_endpoint": "https://matrix.superhero.com/ae-wallet-bot/register/" } diff --git a/src/components/structures/MatrixChat.tsx b/src/components/structures/MatrixChat.tsx new file mode 100644 index 00000000000..608ac0ff3a6 --- /dev/null +++ b/src/components/structures/MatrixChat.tsx @@ -0,0 +1,2207 @@ +/* +Copyright 2015-2022 The Matrix.org Foundation C.I.C. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +import React, { createRef } from "react"; +// what-input helps improve keyboard accessibility +import "what-input"; +import { + ClientEvent, + createClient, + EventType, + HttpApiEvent, + MatrixClient, + MatrixEventEvent, + MatrixEvent, + RoomType, + SyncStateData, + SyncState, +} from "matrix-js-sdk/src/matrix"; +import { InvalidStoreError } from "matrix-js-sdk/src/errors"; +import { defer, IDeferred, QueryDict } from "matrix-js-sdk/src/utils"; +import { logger } from "matrix-js-sdk/src/logger"; +import { throttle } from "lodash"; +import { CryptoEvent } from "matrix-js-sdk/src/crypto"; +import { DecryptionError } from "matrix-js-sdk/src/crypto/algorithms"; +import { IKeyBackupInfo } from "matrix-js-sdk/src/crypto/keybackup"; +import { Filter } from "matrix-react-sdk/src/components/views/dialogs/spotlight/Filter"; +import dis from "matrix-react-sdk/src/dispatcher/dispatcher"; +import SdkConfig, { ConfigOptions } from "matrix-react-sdk/src/SdkConfig"; +import { Action } from "matrix-react-sdk/src/dispatcher/actions"; +import { Spinner } from "@matrix-org/react-sdk-module-api/lib/components/Spinner"; +import { DecryptionFailureTracker } from "matrix-react-sdk/src/DecryptionFailureTracker"; +import { Linkify } from "matrix-react-sdk/src/HtmlUtils"; +import LegacyCallHandler from "matrix-react-sdk/src/LegacyCallHandler"; +import Login from "matrix-react-sdk/src/components/structures/auth/Login"; +import { MatrixClientPeg, IMatrixClientCreds } from "matrix-react-sdk/src/MatrixClientPeg"; +import Modal from "matrix-react-sdk/src/Modal"; +import Notifier from "matrix-react-sdk/src/Notifier"; +import PageType from "matrix-react-sdk/src/PageTypes"; +import PlatformPeg from "matrix-react-sdk/src/PlatformPeg"; +import { PosthogAnalytics } from "matrix-react-sdk/src/PosthogAnalytics"; +import PosthogTrackers from "matrix-react-sdk/src/PosthogTrackers"; +import { startAnyRegistrationFlow } from "matrix-react-sdk/src/Registration"; +import { storeRoomAliasInCache } from "matrix-react-sdk/src/RoomAliasCache"; +import { showStartChatInviteDialog, showRoomInviteDialog } from "matrix-react-sdk/src/RoomInvite"; +import Views from "matrix-react-sdk/src/Views"; +import { IRoomStateEventsActionPayload } from "matrix-react-sdk/src/actions/MatrixActionCreators"; +import { viewUserDeviceSettings } from "matrix-react-sdk/src/actions/handlers/viewUserDeviceSettings"; +import NewRecoveryMethodDialog from "matrix-react-sdk/src/async-components/views/dialogs/security/NewRecoveryMethodDialog"; +import RecoveryMethodRemovedDialog from "matrix-react-sdk/src/async-components/views/dialogs/security/RecoveryMethodRemovedDialog"; +import LoggedInView from "matrix-react-sdk/src/components/structures/LoggedInView"; +import CompleteSecurity from "matrix-react-sdk/src/components/structures/auth/CompleteSecurity"; +import { ConfirmSessionLockTheftView } from "matrix-react-sdk/src/components/structures/auth/ConfirmSessionLockTheftView"; +import E2eSetup from "matrix-react-sdk/src/components/structures/auth/E2eSetup"; +import ForgotPassword from "matrix-react-sdk/src/components/structures/auth/ForgotPassword"; +import { SessionLockStolenView } from "matrix-react-sdk/src/components/structures/auth/SessionLockStolenView"; +import SoftLogout from "matrix-react-sdk/src/components/structures/auth/SoftLogout"; +import Welcome from "matrix-react-sdk/src/components/views/auth/Welcome"; +import CreateRoomDialog from "matrix-react-sdk/src/components/views/dialogs/CreateRoomDialog"; +import ErrorDialog from "matrix-react-sdk/src/components/views/dialogs/ErrorDialog"; +import IncomingSasDialog from "matrix-react-sdk/src/components/views/dialogs/IncomingSasDialog"; +import KeySignatureUploadFailedDialog from "matrix-react-sdk/src/components/views/dialogs/KeySignatureUploadFailedDialog"; +import QuestionDialog from "matrix-react-sdk/src/components/views/dialogs/QuestionDialog"; +import UserSettingsDialog from "matrix-react-sdk/src/components/views/dialogs/UserSettingsDialog"; +import { UserTab } from "matrix-react-sdk/src/components/views/dialogs/UserTab"; +import RovingSpotlightDialog from "matrix-react-sdk/src/components/views/dialogs/spotlight/SpotlightDialog"; +import AccessibleButton, { ButtonEvent } from "matrix-react-sdk/src/components/views/elements/AccessibleButton"; +import ErrorBoundary from "matrix-react-sdk/src/components/views/elements/ErrorBoundary"; +import { UseCaseSelection } from "matrix-react-sdk/src/components/views/elements/UseCaseSelection"; +import GenericToast from "matrix-react-sdk/src/components/views/toasts/GenericToast"; +import VerificationRequestToast from "matrix-react-sdk/src/components/views/toasts/VerificationRequestToast"; +import DialPadModal from "matrix-react-sdk/src/components/views/voip/DialPadModal"; +import { TimelineRenderingType } from "matrix-react-sdk/src/contexts/RoomContext"; +import { SdkContextClass, SDKContext } from "matrix-react-sdk/src/contexts/SDKContext"; +import * as Lifecycle from "matrix-react-sdk/src/Lifecycle"; +import { ActionPayload } from "matrix-react-sdk/src/dispatcher/payloads"; +import { AfterLeaveRoomPayload } from "matrix-react-sdk/src/dispatcher/payloads/AfterLeaveRoomPayload"; +import { DoAfterSyncPreparedPayload } from "matrix-react-sdk/src/dispatcher/payloads/DoAfterSyncPreparedPayload"; +import { OpenToTabPayload } from "matrix-react-sdk/src/dispatcher/payloads/OpenToTabPayload"; +import { ShowThreadPayload } from "matrix-react-sdk/src/dispatcher/payloads/ShowThreadPayload"; +import { ViewHomePagePayload } from "matrix-react-sdk/src/dispatcher/payloads/ViewHomePagePayload"; +import { ViewRoomPayload } from "matrix-react-sdk/src/dispatcher/payloads/ViewRoomPayload"; +import { ViewStartChatOrReusePayload } from "matrix-react-sdk/src/dispatcher/payloads/ViewStartChatOrReusePayload"; +import { _t, _td, getCurrentLanguage } from "matrix-react-sdk/src/languageHandler"; +import PerformanceMonitor, { PerformanceEntryNames } from "matrix-react-sdk/src/performance"; +import { initSentry } from "matrix-react-sdk/src/sentry"; +import { SettingLevel } from "matrix-react-sdk/src/settings/SettingLevel"; +import SettingsStore from "matrix-react-sdk/src/settings/SettingsStore"; +import { UIFeature } from "matrix-react-sdk/src/settings/UIFeature"; +import ThemeController from "matrix-react-sdk/src/settings/controllers/ThemeController"; +import { UseCase } from "matrix-react-sdk/src/settings/enums/UseCase"; +import { FontWatcher } from "matrix-react-sdk/src/settings/watchers/FontWatcher"; +import ThemeWatcher from "matrix-react-sdk/src/settings/watchers/ThemeWatcher"; +import { CallStore } from "matrix-react-sdk/src/stores/CallStore"; +import ThreepidInviteStore, { + IThreepidInvite, + IThreepidInviteWireFormat, +} from "matrix-react-sdk/src/stores/ThreepidInviteStore"; +import ToastStore from "matrix-react-sdk/src/stores/ToastStore"; +import UIStore, { UI_EVENTS } from "matrix-react-sdk/src/stores/UIStore"; +import { NotificationLevel } from "matrix-react-sdk/src/stores/notifications/NotificationLevel"; +import { + RoomNotificationStateStore, + UPDATE_STATUS_INDICATOR, +} from "matrix-react-sdk/src/stores/notifications/RoomNotificationStateStore"; +import { SummarizedNotificationState } from "matrix-react-sdk/src/stores/notifications/SummarizedNotificationState"; +import RightPanelStore from "matrix-react-sdk/src/stores/right-panel/RightPanelStore"; +import { RightPanelPhases } from "matrix-react-sdk/src/stores/right-panel/RightPanelStorePhases"; +import RoomListStore from "matrix-react-sdk/src/stores/room-list/RoomListStore"; +import { RoomUpdateCause } from "matrix-react-sdk/src/stores/room-list/models"; +import AutoDiscoveryUtils from "matrix-react-sdk/src/utils/AutoDiscoveryUtils"; +import DMRoomMap from "matrix-react-sdk/src/utils/DMRoomMap"; +import { messageForSyncError } from "matrix-react-sdk/src/utils/ErrorUtils"; +import ResizeNotifier from "matrix-react-sdk/src/utils/ResizeNotifier"; +import { checkSessionLockFree, getSessionLock } from "matrix-react-sdk/src/utils/SessionLock"; +import { SnakedObject } from "matrix-react-sdk/src/utils/SnakedObject"; +import { ValidatedServerConfig } from "matrix-react-sdk/src/utils/ValidatedServerConfig"; +import { shouldSkipSetupEncryption } from "matrix-react-sdk/src/utils/crypto/shouldSkipSetupEncryption"; +import { findDMForUser } from "matrix-react-sdk/src/utils/dm/findDMForUser"; +import { leaveRoomBehaviour } from "matrix-react-sdk/src/utils/leave-behaviour"; +import { isLocalRoom } from "matrix-react-sdk/src/utils/localRoom/isLocalRoom"; +import { shouldUseLoginForWelcome } from "matrix-react-sdk/src/utils/pages"; +import { makeRoomPermalink } from "matrix-react-sdk/src/utils/permalinks/Permalinks"; +import { showSpaceInvite } from "matrix-react-sdk/src/utils/space"; +import { copyPlaintext } from "matrix-react-sdk/src/utils/strings"; +import { VoiceBroadcastResumer, cleanUpBroadcasts } from "matrix-react-sdk/src/voice-broadcast"; +import SecurityCustomisations from "matrix-react-sdk/src/customisations/Security"; +import { + hideToast as hideAnalyticsToast, + showToast as showAnalyticsToast, +} from "matrix-react-sdk/src/toasts/AnalyticsToast"; +import * as Rooms from "matrix-react-sdk/src/Rooms"; +import createRoom, { IOpts } from "matrix-react-sdk/src/createRoom"; +import * as StorageManager from "matrix-react-sdk/src/utils/StorageManager"; +import { showToast as showMobileGuideToast } from "matrix-react-sdk/src/toasts/MobileGuideToast"; +import { showToast as showNotificationsToast } from "matrix-react-sdk/src/toasts/DesktopNotificationsToast"; + +import Registration from "./auth/Registration"; + +const AUTH_SCREENS = ["register", "login", "forgot_password", "start_sso", "start_cas", "welcome"]; + +// Actions that are redirected through the onboarding process prior to being +// re-dispatched. NOTE: some actions are non-trivial and would require +// re-factoring to be included in this list in future. +const ONBOARDING_FLOW_STARTERS = [Action.ViewUserSettings, "view_create_chat", "view_create_room"]; + +interface IScreen { + screen: string; + params?: QueryDict; +} + +interface IProps { + config: ConfigOptions; + onNewScreen: (screen: string, replaceLast: boolean) => void; + enableGuest?: boolean; + // the queryParams extracted from the [real] query-string of the URI + realQueryParams: QueryDict; + // the initial queryParams extracted from the hash-fragment of the URI + startingFragmentQueryParams?: QueryDict; + // called when we have completed a token login + onTokenLoginCompleted: () => void; + // Represents the screen to display as a result of parsing the initial window.location + initialScreenAfterLogin?: IScreen; + // displayname, if any, to set on the device when logging in/registering. + defaultDeviceDisplayName?: string; +} + +interface IState { + // the master view we are showing. + view: Views; + // What the LoggedInView would be showing if visible + // eslint-disable-next-line camelcase + page_type?: PageType; + // The ID of the room we're viewing. This is either populated directly + // in the case where we view a room by ID or by RoomView when it resolves + // what ID an alias points at. + currentRoomId: string | null; + // If we're trying to just view a user ID (i.e. /user URL), this is it + currentUserId: string | null; + // this is persisted as mx_lhs_size, loaded in LoggedInView + collapseLhs: boolean; + // Parameters used in the registration dance with the IS + // eslint-disable-next-line camelcase + register_client_secret?: string; + // eslint-disable-next-line camelcase + register_session_id?: string; + // eslint-disable-next-line camelcase + register_id_sid?: string; + // When showing Modal dialogs we need to set aria-hidden on the root app element + // and disable it when there are no dialogs + hideToSRUsers: boolean; + syncError: Error | null; + resizeNotifier: ResizeNotifier; + serverConfig?: ValidatedServerConfig; + ready: boolean; + threepidInvite?: IThreepidInvite; + roomOobData?: object; + pendingInitialSync?: boolean; + justRegistered?: boolean; + roomJustCreatedOpts?: IOpts; + forceTimeline?: boolean; // see props +} + +export default class MatrixChat extends React.PureComponent { + public static displayName = "MatrixChat"; + + public static defaultProps = { + realQueryParams: {}, + startingFragmentQueryParams: {}, + config: {}, + onTokenLoginCompleted: (): void => {}, + }; + + private firstSyncComplete = false; + private firstSyncPromise: IDeferred; + + private screenAfterLogin?: IScreen; + private tokenLogin?: boolean; + private focusComposer: boolean; + private subTitleStatus: string; + private prevWindowWidth: number; + private voiceBroadcastResumer?: VoiceBroadcastResumer; + + private readonly loggedInView: React.RefObject; + private readonly dispatcherRef: string; + private readonly themeWatcher: ThemeWatcher; + private readonly fontWatcher: FontWatcher; + private readonly stores: SdkContextClass; + + public constructor(props: IProps) { + super(props); + this.stores = SdkContextClass.instance; + this.stores.constructEagerStores(); + + this.state = { + view: Views.LOADING, + collapseLhs: false, + currentRoomId: null, + currentUserId: null, + + hideToSRUsers: false, + + syncError: null, // If the current syncing status is ERROR, the error object, otherwise null. + resizeNotifier: new ResizeNotifier(), + ready: false, + }; + + this.loggedInView = createRef(); + + SdkConfig.put(this.props.config); + + // Used by _viewRoom before getting state from sync + this.firstSyncComplete = false; + this.firstSyncPromise = defer(); + + if (this.props.config.sync_timeline_limit) { + MatrixClientPeg.opts.initialSyncLimit = this.props.config.sync_timeline_limit; + } + + // a thing to call showScreen with once login completes. this is kept + // outside this.state because updating it should never trigger a + // rerender. + this.screenAfterLogin = this.props.initialScreenAfterLogin; + if (this.screenAfterLogin) { + const params = this.screenAfterLogin.params || {}; + if (this.screenAfterLogin.screen.startsWith("room/") && params["signurl"] && params["email"]) { + // probably a threepid invite - try to store it + const roomId = this.screenAfterLogin.screen.substring("room/".length); + ThreepidInviteStore.instance.storeInvite(roomId, params as unknown as IThreepidInviteWireFormat); + } + } + + this.prevWindowWidth = UIStore.instance.windowWidth || 1000; + UIStore.instance.on(UI_EVENTS.Resize, this.handleResize); + + // For PersistentElement + this.state.resizeNotifier.on("middlePanelResized", this.dispatchTimelineResize); + + RoomNotificationStateStore.instance.on(UPDATE_STATUS_INDICATOR, this.onUpdateStatusIndicator); + + this.dispatcherRef = dis.register(this.onAction); + + this.themeWatcher = new ThemeWatcher(); + this.fontWatcher = new FontWatcher(); + this.themeWatcher.start(); + this.fontWatcher.start(); + + this.focusComposer = false; + + // object field used for tracking the status info appended to the title tag. + // we don't do it as react state as i'm scared about triggering needless react refreshes. + this.subTitleStatus = ""; + + initSentry(SdkConfig.get("sentry")); + + if (!checkSessionLockFree()) { + // another instance holds the lock; confirm its theft before proceeding + setTimeout(() => this.setState({ view: Views.CONFIRM_LOCK_THEFT }), 0); + } else { + this.startInitSession(); + } + } + + /** + * Kick off a call to {@link initSession}, and handle any errors + */ + private startInitSession = (): void => { + this.initSession().catch((err) => { + // TODO: show an error screen, rather than a spinner of doom + logger.error("Error initialising Matrix session", err); + }); + }; + + /** + * Do what we can to establish a Matrix session. + * + * * Special-case soft-logged-out sessions + * * If we have OIDC or token login parameters, follow them + * * If we have a guest access token in the query params, use that + * * If we have parameters in local storage, use them + * * Attempt to auto-register as a guest + * * If all else fails, present a login screen. + */ + private async initSession(): Promise { + // The Rust Crypto SDK will break if two Element instances try to use the same datastore at once, so + // make sure we are the only Element instance in town (on this browser/domain). + if (!(await getSessionLock(() => this.onSessionLockStolen()))) { + // we failed to get the lock. onSessionLockStolen should already have been called, so nothing left to do. + return; + } + + // If the user was soft-logged-out, we want to make the SoftLogout component responsible for doing any + // token auth (rather than Lifecycle.attemptDelegatedAuthLogin), since SoftLogout knows about submitting the + // device ID and preserving the session. + // + // So, we start by special-casing soft-logged-out sessions. + if (Lifecycle.isSoftLogout()) { + // When the session loads it'll be detected as soft logged out and a dispatch + // will be sent out to say that, triggering this MatrixChat to show the soft + // logout page. + Lifecycle.loadSession(); + return; + } + + // Otherwise, the first thing to do is to try the token params in the query-string + const delegatedAuthSucceeded = await Lifecycle.attemptDelegatedAuthLogin( + this.props.realQueryParams, + this.props.defaultDeviceDisplayName, + this.getFragmentAfterLogin(), + ); + + // remove the loginToken or auth code from the URL regardless + if ( + this.props.realQueryParams?.loginToken || + this.props.realQueryParams?.code || + this.props.realQueryParams?.state + ) { + this.props.onTokenLoginCompleted(); + } + + if (delegatedAuthSucceeded) { + // token auth/OIDC worked! Time to fire up the client. + this.tokenLogin = true; + + // Create and start the client + // accesses the new credentials just set in storage during attemptDelegatedAuthLogin + // and sets logged in state + await Lifecycle.restoreFromLocalStorage({ ignoreGuest: true }); + await this.postLoginSetup(); + return; + } + + // if the user has followed a login or register link, don't reanimate + // the old creds, but rather go straight to the relevant page + const firstScreen = this.screenAfterLogin ? this.screenAfterLogin.screen : null; + const restoreSuccess = await this.loadSession(); + if (restoreSuccess) { + return; + } + + // If the first screen is an auth screen, we don't want to wait for login. + if (firstScreen !== null && AUTH_SCREENS.includes(firstScreen)) { + this.showScreenAfterLogin(); + } + } + + private async onSessionLockStolen(): Promise { + // switch to the LockStolenView. We deliberately do this immediately, rather than going through the dispatcher, + // because there can be a substantial queue in the dispatcher, and some of the events in it might require an + // active MatrixClient. + await new Promise((resolve) => { + this.setState({ view: Views.LOCK_STOLEN }, resolve); + }); + + // now we can tell the Lifecycle routines to abort any active startup, and to stop the active client. + await Lifecycle.onSessionLockStolen(); + } + + private async postLoginSetup(): Promise { + const cli = MatrixClientPeg.safeGet(); + const cryptoEnabled = cli.isCryptoEnabled(); + if (!cryptoEnabled) { + this.onLoggedIn(); + } + + const promisesList: Promise[] = [this.firstSyncPromise.promise]; + let crossSigningIsSetUp = false; + if (cryptoEnabled) { + // check if the user has previously published public cross-signing keys, + // as a proxy to figure out if it's worth prompting the user to verify + // from another device. + promisesList.push( + (async (): Promise => { + crossSigningIsSetUp = await cli.userHasCrossSigningKeys(); + })(), + ); + } + + // Now update the state to say we're waiting for the first sync to complete rather + // than for the login to finish. + this.setState({ pendingInitialSync: true }); + + await Promise.all(promisesList); + + if (!cryptoEnabled) { + this.setState({ pendingInitialSync: false }); + return; + } + + if (crossSigningIsSetUp) { + // if the user has previously set up cross-signing, verify this device so we can fetch the + // private keys. + if (SecurityCustomisations.SHOW_ENCRYPTION_SETUP_UI === false) { + this.onLoggedIn(); + } else { + this.setStateForNewView({ view: Views.COMPLETE_SECURITY }); + } + } else if ( + (await cli.doesServerSupportUnstableFeature("org.matrix.e2e_cross_signing")) && + !shouldSkipSetupEncryption(cli) + ) { + // if cross-signing is not yet set up, do so now if possible. + this.setStateForNewView({ view: Views.E2E_SETUP }); + } else { + this.onLoggedIn(); + } + this.setState({ pendingInitialSync: false }); + } + + public setState( + state: + | ((prevState: Readonly, props: Readonly) => Pick | IState | null) + | (Pick | IState | null), + callback?: () => void, + ): void { + if (this.shouldTrackPageChange(this.state, { ...this.state, ...state })) { + this.startPageChangeTimer(); + } + super.setState(state, callback); + } + + public componentDidMount(): void { + window.addEventListener("resize", this.onWindowResized); + } + + public componentDidUpdate(prevProps: IProps, prevState: IState): void { + if (this.shouldTrackPageChange(prevState, this.state)) { + const durationMs = this.stopPageChangeTimer(); + if (durationMs != null) { + PosthogTrackers.instance.trackPageChange(this.state.view, this.state.page_type, durationMs); + } + } + if (this.focusComposer) { + dis.fire(Action.FocusSendMessageComposer); + this.focusComposer = false; + } + } + + public componentWillUnmount(): void { + Lifecycle.stopMatrixClient(); + dis.unregister(this.dispatcherRef); + this.themeWatcher.stop(); + this.fontWatcher.stop(); + UIStore.destroy(); + this.state.resizeNotifier.removeListener("middlePanelResized", this.dispatchTimelineResize); + window.removeEventListener("resize", this.onWindowResized); + + this.stores.accountPasswordStore.clearPassword(); + this.voiceBroadcastResumer?.destroy(); + } + + private onWindowResized = (): void => { + // XXX: This is a very unreliable way to detect whether or not the the devtools are open + this.warnInConsole(); + }; + + private warnInConsole = throttle((): void => { + const largeFontSize = "50px"; + const normalFontSize = "15px"; + + const waitText = _t("console_wait"); + const scamText = _t("console_scam_warning"); + const devText = _t("console_dev_note"); + + global.mx_rage_logger.bypassRageshake( + "log", + `%c${waitText}\n%c${scamText}\n%c${devText}`, + `font-size:${largeFontSize}; color:blue;`, + `font-size:${normalFontSize}; color:red;`, + `font-size:${normalFontSize};`, + ); + }, 1000); + + private getFallbackHsUrl(): string | undefined { + if (this.getServerProperties().serverConfig?.isDefault) { + return this.props.config.fallback_hs_url; + } + } + + private getServerProperties(): { serverConfig: ValidatedServerConfig } { + const props = this.state.serverConfig || SdkConfig.get("validated_server_config")!; + return { serverConfig: props }; + } + + private loadSession(): Promise { + // the extra Promise.resolve() ensures that synchronous exceptions hit the same codepath as + // asynchronous ones. + return Promise.resolve() + .then(() => { + return Lifecycle.loadSession({ + fragmentQueryParams: this.props.startingFragmentQueryParams, + enableGuest: this.props.enableGuest, + guestHsUrl: this.getServerProperties().serverConfig.hsUrl, + guestIsUrl: this.getServerProperties().serverConfig.isUrl, + defaultDeviceDisplayName: this.props.defaultDeviceDisplayName, + }); + }) + .then((loadedSession) => { + if (!loadedSession) { + // fall back to showing the welcome screen... unless we have a 3pid invite pending + if (ThreepidInviteStore.instance.pickBestInvite()) { + dis.dispatch({ action: "start_registration" }); + } else { + dis.dispatch({ action: "view_welcome_page" }); + } + } + return loadedSession; + }); + // Note we don't catch errors from this: we catch everything within + // loadSession as there's logic there to ask the user if they want + // to try logging out. + } + + private startPageChangeTimer(): void { + PerformanceMonitor.instance.start(PerformanceEntryNames.PAGE_CHANGE); + } + + private stopPageChangeTimer(): number | null { + const perfMonitor = PerformanceMonitor.instance; + + perfMonitor.stop(PerformanceEntryNames.PAGE_CHANGE); + + const entries = perfMonitor.getEntries({ + name: PerformanceEntryNames.PAGE_CHANGE, + }); + const measurement = entries.pop(); + + return measurement ? measurement.duration : null; + } + + private shouldTrackPageChange(prevState: IState, state: IState): boolean { + return ( + prevState.currentRoomId !== state.currentRoomId || + prevState.view !== state.view || + prevState.page_type !== state.page_type + ); + } + + private setStateForNewView(state: Partial): void { + if (state.view === undefined) { + throw new Error("setStateForNewView with no view!"); + } + this.setState({ + currentUserId: undefined, + justRegistered: false, + ...state, + } as IState); + } + + private onAction = (payload: ActionPayload): void => { + // once the session lock has been stolen, don't try to do anything. + if (this.state.view === Views.LOCK_STOLEN) { + return; + } + + // Start the onboarding process for certain actions + if (MatrixClientPeg.get()?.isGuest() && ONBOARDING_FLOW_STARTERS.includes(payload.action)) { + // This will cause `payload` to be dispatched later, once a + // sync has reached the "prepared" state. Setting a matrix ID + // will cause a full login and sync and finally the deferred + // action will be dispatched. + dis.dispatch({ + action: Action.DoAfterSyncPrepared, + deferred_action: payload, + }); + dis.dispatch({ action: "require_registration" }); + return; + } + + switch (payload.action) { + case "MatrixActions.accountData": + // XXX: This is a collection of several hacks to solve a minor problem. We want to + // update our local state when the identity server changes, but don't want to put that in + // the js-sdk as we'd be then dictating how all consumers need to behave. However, + // this component is already bloated and we probably don't want this tiny logic in + // here, but there's no better place in the react-sdk for it. Additionally, we're + // abusing the MatrixActionCreator stuff to avoid errors on dispatches. + if (payload.event_type === "m.identity_server") { + const fullUrl = payload.event_content ? payload.event_content["base_url"] : null; + if (!fullUrl) { + MatrixClientPeg.safeGet().setIdentityServerUrl(undefined); + localStorage.removeItem("mx_is_access_token"); + localStorage.removeItem("mx_is_url"); + } else { + MatrixClientPeg.safeGet().setIdentityServerUrl(fullUrl); + localStorage.removeItem("mx_is_access_token"); // clear token + localStorage.setItem("mx_is_url", fullUrl); // XXX: Do we still need this? + } + + // redispatch the change with a more specific action + dis.dispatch({ action: "id_server_changed" }); + } + break; + case "logout": + LegacyCallHandler.instance.hangupAllCalls(); + Promise.all([ + ...[...CallStore.instance.activeCalls].map((call) => call.disconnect()), + cleanUpBroadcasts(this.stores), + ]).finally(() => Lifecycle.logout(this.stores.oidcClientStore)); + break; + case "require_registration": + startAnyRegistrationFlow(payload as any); + break; + case "start_registration": + if (Lifecycle.isSoftLogout()) { + this.onSoftLogout(); + break; + } + // This starts the full registration flow + if (payload.screenAfterLogin) { + this.screenAfterLogin = payload.screenAfterLogin; + } + this.startRegistration(payload.params || {}); + break; + case "start_login": + if (Lifecycle.isSoftLogout()) { + this.onSoftLogout(); + break; + } + if (payload.screenAfterLogin) { + this.screenAfterLogin = payload.screenAfterLogin; + } + this.viewLogin(); + break; + case "start_password_recovery": + this.setStateForNewView({ + view: Views.FORGOT_PASSWORD, + }); + this.notifyNewScreen("forgot_password"); + break; + case "start_chat": + createRoom(MatrixClientPeg.safeGet(), { + dmUserId: payload.user_id, + }); + break; + case "leave_room": + this.leaveRoom(payload.room_id); + break; + case "forget_room": + this.forgetRoom(payload.room_id); + break; + case "copy_room": + this.copyRoom(payload.room_id); + break; + case "reject_invite": + Modal.createDialog(QuestionDialog, { + title: _t("reject_invitation_dialog|title"), + description: _t("reject_invitation_dialog|confirmation"), + onFinished: (confirm) => { + if (confirm) { + // FIXME: controller shouldn't be loading a view :( + const modal = Modal.createDialog(Spinner, undefined, "mx_Dialog_spinner"); + + MatrixClientPeg.safeGet() + .leave(payload.room_id) + .then( + () => { + // @ts-expect-error TODO + modal.close(); + if (this.state.currentRoomId === payload.room_id) { + dis.dispatch({ action: Action.ViewHomePage }); + } + }, + (err) => { + // @ts-expect-error TODO + modal.close(); + Modal.createDialog(ErrorDialog, { + title: _t("reject_invitation_dialog|failed"), + description: err.toString(), + }); + }, + ); + } + }, + }); + break; + case "view_user_info": + this.viewUser(payload.userId, payload.subAction); + break; + case "MatrixActions.RoomState.events": { + const event = (payload as IRoomStateEventsActionPayload).event; + if ( + event.getType() === EventType.RoomCanonicalAlias && + event.getRoomId() === this.state.currentRoomId + ) { + // re-view the current room so we can update alias/id in the URL properly + this.viewRoom({ + action: Action.ViewRoom, + room_id: this.state.currentRoomId, + metricsTrigger: undefined, // room doesn't change + }); + } + break; + } + case Action.ViewRoom: { + // Takes either a room ID or room alias: if switching to a room the client is already + // known to be in (eg. user clicks on a room in the recents panel), supply the ID + // If the user is clicking on a room in the context of the alias being presented + // to them, supply the room alias. If both are supplied, the room ID will be ignored. + const promise = this.viewRoom(payload as ViewRoomPayload); + if (payload.deferred_action) { + promise.then(() => { + dis.dispatch(payload.deferred_action); + }); + } + break; + } + case Action.ViewUserDeviceSettings: { + viewUserDeviceSettings(); + break; + } + case Action.ViewUserSettings: { + const tabPayload = payload as OpenToTabPayload; + Modal.createDialog( + UserSettingsDialog, + { initialTabId: tabPayload.initialTabId as UserTab, sdkContext: this.stores }, + /*className=*/ undefined, + /*isPriority=*/ false, + /*isStatic=*/ true, + ); + + // View the welcome or home page if we need something to look at + this.viewSomethingBehindModal(); + break; + } + case "view_create_room": + this.createRoom(payload.public, payload.defaultName, payload.type); + + // View the welcome or home page if we need something to look at + this.viewSomethingBehindModal(); + break; + case Action.ViewRoomDirectory: { + Modal.createDialog( + RovingSpotlightDialog, + { + initialText: payload.initialText, + initialFilter: Filter.PublicRooms, + }, + "mx_SpotlightDialog_wrapper", + false, + true, + ); + + // View the welcome or home page if we need something to look at + this.viewSomethingBehindModal(); + break; + } + case "view_welcome_page": + this.viewWelcome(); + break; + case Action.ViewHomePage: + this.viewHome(payload.justRegistered); + break; + case Action.ViewStartChatOrReuse: + this.chatCreateOrReuse(payload.user_id); + break; + case "view_create_chat": + showStartChatInviteDialog(payload.initialText || ""); + + // View the welcome or home page if we need something to look at + this.viewSomethingBehindModal(); + break; + case "view_invite": { + const room = MatrixClientPeg.safeGet().getRoom(payload.roomId); + if (room?.isSpaceRoom()) { + showSpaceInvite(room); + } else { + showRoomInviteDialog(payload.roomId); + } + break; + } + case "view_last_screen": + // This function does what we want, despite the name. The idea is that it shows + // the last room we were looking at or some reasonable default/guess. We don't + // have to worry about email invites or similar being re-triggered because the + // function will have cleared that state and not execute that path. + this.showScreenAfterLogin(); + break; + case "hide_left_panel": + this.setState( + { + collapseLhs: true, + }, + () => { + this.state.resizeNotifier.notifyLeftHandleResized(); + }, + ); + break; + case "show_left_panel": + this.setState( + { + collapseLhs: false, + }, + () => { + this.state.resizeNotifier.notifyLeftHandleResized(); + }, + ); + break; + case Action.OpenDialPad: + Modal.createDialog(DialPadModal, {}, "mx_Dialog_dialPadWrapper"); + break; + case Action.OnLoggedIn: + this.stores.client = MatrixClientPeg.safeGet(); + if ( + // Skip this handling for token login as that always calls onLoggedIn itself + !this.tokenLogin && + !Lifecycle.isSoftLogout() && + this.state.view !== Views.LOGIN && + this.state.view !== Views.REGISTER && + this.state.view !== Views.COMPLETE_SECURITY && + this.state.view !== Views.E2E_SETUP && + this.state.view !== Views.USE_CASE_SELECTION + ) { + this.onLoggedIn(); + } + break; + case "on_client_not_viable": + this.onSoftLogout(); + break; + case Action.OnLoggedOut: + this.onLoggedOut(); + break; + case "will_start_client": + this.setState({ ready: false }, () => { + // if the client is about to start, we are, by definition, not ready. + // Set ready to false now, then it'll be set to true when the sync + // listener we set below fires. + this.onWillStartClient(); + }); + break; + case "client_started": + this.onClientStarted(); + break; + case "send_event": + this.onSendEvent(payload.room_id, payload.event); + break; + case "aria_hide_main_app": + this.setState({ + hideToSRUsers: true, + }); + break; + case "aria_unhide_main_app": + this.setState({ + hideToSRUsers: false, + }); + break; + case Action.PseudonymousAnalyticsAccept: + hideAnalyticsToast(); + SettingsStore.setValue("pseudonymousAnalyticsOptIn", null, SettingLevel.ACCOUNT, true); + break; + case Action.PseudonymousAnalyticsReject: + hideAnalyticsToast(); + SettingsStore.setValue("pseudonymousAnalyticsOptIn", null, SettingLevel.ACCOUNT, false); + break; + case Action.ShowThread: { + const { rootEvent, initialEvent, highlighted, scrollIntoView, push } = payload as ShowThreadPayload; + + const threadViewCard = { + phase: RightPanelPhases.ThreadView, + state: { + threadHeadEvent: rootEvent, + initialEvent: initialEvent, + isInitialEventHighlighted: highlighted, + initialEventScrollIntoView: scrollIntoView, + }, + }; + if (push ?? false) { + RightPanelStore.instance.pushCard(threadViewCard); + } else { + RightPanelStore.instance.setCards([{ phase: RightPanelPhases.ThreadPanel }, threadViewCard]); + } + + // Focus the composer + dis.dispatch({ + action: Action.FocusSendMessageComposer, + context: TimelineRenderingType.Thread, + }); + + break; + } + case Action.OpenSpotlight: + Modal.createDialog( + RovingSpotlightDialog, + { + initialText: payload.initialText, + initialFilter: payload.initialFilter, + }, + "mx_SpotlightDialog_wrapper", + false, + true, + ); + break; + } + }; + + private setPage(pageType: PageType): void { + this.setState({ + page_type: pageType, + }); + } + + private async startRegistration(params: { [key: string]: string }): Promise { + const newState: Partial = { + view: Views.REGISTER, + }; + + // Only honour params if they are all present, otherwise we reset + // HS and IS URLs when switching to registration. + if (params.client_secret && params.session_id && params.hs_url && params.is_url && params.sid) { + newState.serverConfig = await AutoDiscoveryUtils.validateServerConfigWithStaticUrls( + params.hs_url, + params.is_url, + ); + + // If the hs url matches then take the hs name we know locally as it is likely prettier + const defaultConfig = SdkConfig.get("validated_server_config"); + if (defaultConfig && defaultConfig.hsUrl === newState.serverConfig.hsUrl) { + newState.serverConfig.hsName = defaultConfig.hsName; + newState.serverConfig.hsNameIsDifferent = defaultConfig.hsNameIsDifferent; + newState.serverConfig.isDefault = defaultConfig.isDefault; + newState.serverConfig.isNameResolvable = defaultConfig.isNameResolvable; + } + + newState.register_client_secret = params.client_secret; + newState.register_session_id = params.session_id; + newState.register_id_sid = params.sid; + } + + this.setStateForNewView(newState); + ThemeController.isLogin = true; + this.themeWatcher.recheck(); + this.notifyNewScreen("register"); + } + + // switch view to the given room + private async viewRoom(roomInfo: ViewRoomPayload): Promise { + this.focusComposer = true; + + if (roomInfo.room_alias) { + logger.log(`Switching to room alias ${roomInfo.room_alias} at event ${roomInfo.event_id}`); + } else { + logger.log(`Switching to room id ${roomInfo.room_id} at event ${roomInfo.event_id}`); + } + + // Wait for the first sync to complete so that if a room does have an alias, + // it would have been retrieved. + if (!this.firstSyncComplete) { + if (!this.firstSyncPromise) { + logger.warn("Cannot view a room before first sync. room_id:", roomInfo.room_id); + return; + } + await this.firstSyncPromise.promise; + } + + let presentedId = roomInfo.room_alias || roomInfo.room_id!; + const room = MatrixClientPeg.safeGet().getRoom(roomInfo.room_id); + if (room) { + // Not all timeline events are decrypted ahead of time anymore + // Only the critical ones for a typical UI are + // This will start the decryption process for all events when a + // user views a room + room.decryptAllEvents(); + const theAlias = Rooms.getDisplayAliasForRoom(room); + if (theAlias) { + presentedId = theAlias; + // Store display alias of the presented room in cache to speed future + // navigation. + storeRoomAliasInCache(theAlias, room.roomId); + } + + // Store this as the ID of the last room accessed. This is so that we can + // persist which room is being stored across refreshes and browser quits. + localStorage?.setItem("mx_last_room_id", room.roomId); + } + + // If we are redirecting to a Room Alias and it is for the room we already showing then replace history item + let replaceLast = presentedId[0] === "#" && roomInfo.room_id === this.state.currentRoomId; + + if (isLocalRoom(this.state.currentRoomId)) { + // Replace local room history items + replaceLast = true; + } + + if (roomInfo.room_id === this.state.currentRoomId) { + // if we are re-viewing the same room then copy any state we already know + roomInfo.threepid_invite = roomInfo.threepid_invite ?? this.state.threepidInvite; + roomInfo.oob_data = roomInfo.oob_data ?? this.state.roomOobData; + roomInfo.forceTimeline = roomInfo.forceTimeline ?? this.state.forceTimeline; + roomInfo.justCreatedOpts = roomInfo.justCreatedOpts ?? this.state.roomJustCreatedOpts; + } + + if (roomInfo.event_id && roomInfo.highlighted) { + presentedId += "/" + roomInfo.event_id; + } + this.setState( + { + view: Views.LOGGED_IN, + currentRoomId: roomInfo.room_id ?? null, + page_type: PageType.RoomView, + threepidInvite: roomInfo.threepid_invite, + roomOobData: roomInfo.oob_data, + forceTimeline: roomInfo.forceTimeline, + ready: true, + roomJustCreatedOpts: roomInfo.justCreatedOpts, + }, + () => { + ThemeController.isLogin = false; + this.themeWatcher.recheck(); + this.notifyNewScreen("room/" + presentedId, replaceLast); + }, + ); + } + + private viewSomethingBehindModal(): void { + if (this.state.view !== Views.LOGGED_IN) { + this.viewWelcome(); + return; + } + if (!this.state.currentRoomId && !this.state.currentUserId) { + this.viewHome(); + } + } + + private viewWelcome(): void { + if (shouldUseLoginForWelcome(SdkConfig.get())) { + return this.viewLogin(); + } + this.setStateForNewView({ + view: Views.WELCOME, + }); + this.notifyNewScreen("welcome"); + ThemeController.isLogin = true; + this.themeWatcher.recheck(); + } + + private viewLogin(otherState?: any): void { + this.setStateForNewView({ + view: Views.LOGIN, + ...otherState, + }); + this.notifyNewScreen("login"); + ThemeController.isLogin = true; + this.themeWatcher.recheck(); + } + + private viewHome(justRegistered = false): void { + // The home page requires the "logged in" view, so we'll set that. + this.setStateForNewView({ + view: Views.LOGGED_IN, + justRegistered, + currentRoomId: null, + }); + this.setPage(PageType.HomePage); + this.notifyNewScreen("home"); + ThemeController.isLogin = false; + this.themeWatcher.recheck(); + } + + private viewUser(userId: string, subAction: string): void { + // Wait for the first sync so that `getRoom` gives us a room object if it's + // in the sync response + const waitForSync = this.firstSyncPromise ? this.firstSyncPromise.promise : Promise.resolve(); + waitForSync.then(() => { + if (subAction === "chat") { + this.chatCreateOrReuse(userId); + return; + } + this.notifyNewScreen("user/" + userId); + this.setState({ currentUserId: userId }); + this.setPage(PageType.UserView); + }); + } + + private async createRoom(defaultPublic = false, defaultName?: string, type?: RoomType): Promise { + const modal = Modal.createDialog(CreateRoomDialog, { + type, + defaultPublic, + defaultName, + }); + + const [shouldCreate, opts] = await modal.finished; + if (shouldCreate) { + createRoom(MatrixClientPeg.safeGet(), opts!); + } + } + + private chatCreateOrReuse(userId: string): void { + const snakedConfig = new SnakedObject(this.props.config); + // Use a deferred action to reshow the dialog once the user has registered + if (MatrixClientPeg.safeGet().isGuest()) { + // No point in making 2 DMs with welcome bot. This assumes view_set_mxid will + // result in a new DM with the welcome user. + if (userId !== snakedConfig.get("welcome_user_id")) { + dis.dispatch>({ + action: Action.DoAfterSyncPrepared, + deferred_action: { + action: Action.ViewStartChatOrReuse, + user_id: userId, + }, + }); + } + dis.dispatch({ + action: "require_registration", + // If the set_mxid dialog is cancelled, view /welcome because if the + // browser was pointing at /user/@someone:domain?action=chat, the URL + // needs to be reset so that they can revisit /user/.. // (and trigger + // `_chatCreateOrReuse` again) + go_welcome_on_cancel: true, + screen_after: { + screen: `user/${snakedConfig.get("welcome_user_id")}`, + params: { action: "chat" }, + }, + }); + return; + } + + // TODO: Immutable DMs replaces this + + const client = MatrixClientPeg.safeGet(); + const dmRoom = findDMForUser(client, userId); + + if (dmRoom) { + dis.dispatch({ + action: Action.ViewRoom, + room_id: dmRoom.roomId, + metricsTrigger: "MessageUser", + }); + } else { + dis.dispatch({ + action: "start_chat", + user_id: userId, + }); + } + } + + private leaveRoomWarnings(roomId: string): JSX.Element[] { + const roomToLeave = MatrixClientPeg.safeGet().getRoom(roomId); + const isSpace = roomToLeave?.isSpaceRoom(); + // Show a warning if there are additional complications. + const warnings: JSX.Element[] = []; + + const memberCount = roomToLeave?.currentState.getJoinedMemberCount(); + if (memberCount === 1) { + warnings.push( + + {" " /* Whitespace, otherwise the sentences get smashed together */} + {_t("leave_room_dialog|last_person_warning")} + , + ); + + return warnings; + } + + const joinRules = roomToLeave?.currentState.getStateEvents("m.room.join_rules", ""); + if (joinRules) { + const rule = joinRules.getContent().join_rule; + if (rule !== "public") { + warnings.push( + + {" " /* Whitespace, otherwise the sentences get smashed together */} + {isSpace + ? _t("leave_room_dialog|space_rejoin_warning") + : _t("leave_room_dialog|room_rejoin_warning")} + , + ); + } + } + return warnings; + } + + private leaveRoom(roomId: string): void { + const cli = MatrixClientPeg.safeGet(); + const roomToLeave = cli.getRoom(roomId); + const warnings = this.leaveRoomWarnings(roomId); + + const isSpace = roomToLeave?.isSpaceRoom(); + Modal.createDialog(QuestionDialog, { + title: isSpace ? _t("space|leave_dialog_action") : _t("action|leave_room"), + description: ( + + {isSpace + ? _t("leave_room_dialog|leave_space_question", { + spaceName: roomToLeave?.name ?? _t("common|unnamed_space"), + }) + : _t("leave_room_dialog|leave_room_question", { + roomName: roomToLeave?.name ?? _t("common|unnamed_room"), + })} + {warnings} + + ), + button: _t("action|leave"), + onFinished: async (shouldLeave) => { + if (shouldLeave) { + await leaveRoomBehaviour(cli, roomId); + + dis.dispatch({ + action: Action.AfterLeaveRoom, + room_id: roomId, + }); + } + }, + }); + } + + private forgetRoom(roomId: string): void { + const room = MatrixClientPeg.safeGet().getRoom(roomId); + MatrixClientPeg.safeGet() + .forget(roomId) + .then(() => { + // Switch to home page if we're currently viewing the forgotten room + if (this.state.currentRoomId === roomId) { + dis.dispatch({ action: Action.ViewHomePage }); + } + + // We have to manually update the room list because the forgotten room will not + // be notified to us, therefore the room list will have no other way of knowing + // the room is forgotten. + if (room) RoomListStore.instance.manualRoomUpdate(room, RoomUpdateCause.RoomRemoved); + }) + .catch((err) => { + const errCode = err.errcode || _td("error|unknown_error_code"); + Modal.createDialog(ErrorDialog, { + title: _t("error_dialog|forget_room_failed", { errCode }), + description: err && err.message ? err.message : _t("invite|failed_generic"), + }); + }); + } + + private async copyRoom(roomId: string): Promise { + const roomLink = makeRoomPermalink(MatrixClientPeg.safeGet(), roomId); + const success = await copyPlaintext(roomLink); + if (!success) { + Modal.createDialog(ErrorDialog, { + title: _t("error_dialog|copy_room_link_failed|title"), + description: _t("error_dialog|copy_room_link_failed|description"), + }); + } + } + + /** + * Starts a chat with the welcome user, if the user doesn't already have one + * @returns {string} The room ID of the new room, or null if no room was created + */ + private async startWelcomeUserChat(): Promise { + const snakedConfig = new SnakedObject(this.props.config); + const welcomeUserId = snakedConfig.get("welcome_user_id"); + if (!welcomeUserId) return null; + + // We can end up with multiple tabs post-registration where the user + // might then end up with a session and we don't want them all making + // a chat with the welcome user: try to de-dupe. + // We need to wait for the first sync to complete for this to + // work though. + let waitFor: Promise; + if (!this.firstSyncComplete) { + waitFor = this.firstSyncPromise.promise; + } else { + waitFor = Promise.resolve(); + } + await waitFor; + + const welcomeUserRooms = DMRoomMap.shared().getDMRoomsForUserId(welcomeUserId); + if (welcomeUserRooms.length === 0) { + const roomId = await createRoom(MatrixClientPeg.safeGet(), { + dmUserId: snakedConfig.get("welcome_user_id"), + // Only view the welcome user if we're NOT looking at a room + andView: !this.state.currentRoomId, + spinner: false, // we're already showing one: we don't need another one + }); + // This is a bit of a hack, but since the deduplication relies + // on m.direct being up to date, we need to force a sync + // of the database, otherwise if the user goes to the other + // tab before the next save happens (a few minutes), the + // saved sync will be restored from the db and this code will + // run without the update to m.direct, making another welcome + // user room (it doesn't wait for new data from the server, just + // the saved sync to be loaded). + const saveWelcomeUser = (ev: MatrixEvent): void => { + if (ev.getType() === EventType.Direct && ev.getContent()[welcomeUserId]) { + MatrixClientPeg.safeGet().store.save(true); + MatrixClientPeg.safeGet().removeListener(ClientEvent.AccountData, saveWelcomeUser); + } + }; + MatrixClientPeg.safeGet().on(ClientEvent.AccountData, saveWelcomeUser); + + return roomId; + } + return null; + } + + /** + * Called when a new logged in session has started + */ + private async onLoggedIn(): Promise { + ThemeController.isLogin = false; + this.themeWatcher.recheck(); + StorageManager.tryPersistStorage(); + + if (MatrixClientPeg.currentUserIsJustRegistered() && SettingsStore.getValue("FTUE.useCaseSelection") === null) { + this.setStateForNewView({ view: Views.USE_CASE_SELECTION }); + + // Listen to changes in settings and hide the use case screen if appropriate - this is necessary because + // account settings can still be changing at this point in app init (due to the initial sync being cached, + // then subsequent syncs being received from the server) + // + // This seems unlikely for something that should happen directly after registration, but if a user does + // their initial login on another device/browser than they registered on, we want to avoid asking this + // question twice + // + // initPosthogAnalyticsToast pioneered this technique, we’re just reusing it here. + SettingsStore.watchSetting( + "FTUE.useCaseSelection", + null, + (originalSettingName, changedInRoomId, atLevel, newValueAtLevel, newValue) => { + if (newValue !== null && this.state.view === Views.USE_CASE_SELECTION) { + this.onShowPostLoginScreen(); + } + }, + ); + } else { + return this.onShowPostLoginScreen(); + } + } + + private async onShowPostLoginScreen(useCase?: UseCase): Promise { + if (useCase) { + PosthogAnalytics.instance.setProperty("ftueUseCaseSelection", useCase); + SettingsStore.setValue("FTUE.useCaseSelection", null, SettingLevel.ACCOUNT, useCase); + } + + this.setStateForNewView({ view: Views.LOGGED_IN }); + // If a specific screen is set to be shown after login, show that above + // all else, as it probably means the user clicked on something already. + if (this.screenAfterLogin?.screen) { + this.showScreen(this.screenAfterLogin.screen, this.screenAfterLogin.params); + this.screenAfterLogin = undefined; + } else if (MatrixClientPeg.currentUserIsJustRegistered()) { + MatrixClientPeg.setJustRegisteredUserId(null); + + const snakedConfig = new SnakedObject(this.props.config); + if (snakedConfig.get("welcome_user_id") && getCurrentLanguage().startsWith("en")) { + const welcomeUserRoom = await this.startWelcomeUserChat(); + if (welcomeUserRoom === null) { + // We didn't redirect to the welcome user room, so show + // the homepage. + dis.dispatch({ action: Action.ViewHomePage, justRegistered: true }); + } + } else if (ThreepidInviteStore.instance.pickBestInvite()) { + // The user has a 3pid invite pending - show them that + const threepidInvite = ThreepidInviteStore.instance.pickBestInvite(); + + // HACK: This is a pretty brutal way of threading the invite back through + // our systems, but it's the safest we have for now. + const params = ThreepidInviteStore.instance.translateToWireFormat(threepidInvite); + this.showScreen(`room/${threepidInvite.roomId}`, params); + } else { + // The user has just logged in after registering, + // so show the homepage. + dis.dispatch({ action: Action.ViewHomePage, justRegistered: true }); + } + } else { + this.showScreenAfterLogin(); + } + + if (SdkConfig.get("mobile_guide_toast")) { + // The toast contains further logic to detect mobile platforms, + // check if it has been dismissed before, etc. + showMobileGuideToast(); + } + + const userNotice = SdkConfig.get("user_notice"); + if (userNotice) { + const key = "user_notice_" + userNotice.title; + if (!userNotice.show_once || !localStorage.getItem(key)) { + ToastStore.sharedInstance().addOrReplaceToast({ + key, + title: userNotice.title, + props: { + description: {userNotice.description}, + acceptLabel: _t("action|ok"), + onAccept: () => { + ToastStore.sharedInstance().dismissToast(key); + localStorage.setItem(key, "1"); + }, + }, + component: GenericToast, + className: "mx_AnalyticsToast", + priority: 100, + }); + } + } + } + + private initPosthogAnalyticsToast(): void { + // Show the analytics toast if necessary + if (SettingsStore.getValue("pseudonymousAnalyticsOptIn") === null) { + showAnalyticsToast(); + } + + // Listen to changes in settings and show the toast if appropriate - this is necessary because account + // settings can still be changing at this point in app init (due to the initial sync being cached, then + // subsequent syncs being received from the server) + SettingsStore.watchSetting( + "pseudonymousAnalyticsOptIn", + null, + (originalSettingName, changedInRoomId, atLevel, newValueAtLevel, newValue) => { + if (newValue === null) { + showAnalyticsToast(); + } else { + // It's possible for the value to change if a cached sync loads at page load, but then network + // sync contains a new value of the flag with it set to false (e.g. another device set it since last + // loading the page); so hide the toast. + // (this flipping usually happens before first render so the user won't notice it; anyway flicker + // on/off is probably better than showing the toast again when the user already dismissed it) + hideAnalyticsToast(); + } + }, + ); + } + + private showScreenAfterLogin(): void { + // If screenAfterLogin is set, use that, then null it so that a second login will + // result in view_home_page, _user_settings or _room_directory + if (this.screenAfterLogin && this.screenAfterLogin.screen) { + this.showScreen(this.screenAfterLogin.screen, this.screenAfterLogin.params); + this.screenAfterLogin = undefined; + } else if (localStorage && localStorage.getItem("mx_last_room_id")) { + // Before defaulting to directory, show the last viewed room + this.viewLastRoom(); + } else { + if (MatrixClientPeg.safeGet().isGuest()) { + dis.dispatch({ action: "view_welcome_page" }); + } else { + dis.dispatch({ action: Action.ViewHomePage }); + } + } + } + + private viewLastRoom(): void { + dis.dispatch({ + action: Action.ViewRoom, + room_id: localStorage.getItem("mx_last_room_id") ?? undefined, + metricsTrigger: undefined, // other + }); + } + + /** + * Called when the session is logged out + */ + private onLoggedOut(): void { + this.viewLogin({ + ready: false, + collapseLhs: false, + currentRoomId: null, + }); + this.subTitleStatus = ""; + this.setPageSubtitle(); + this.stores.onLoggedOut(); + } + + /** + * Called when the session is softly logged out + */ + private onSoftLogout(): void { + this.notifyNewScreen("soft_logout"); + this.setStateForNewView({ + view: Views.SOFT_LOGOUT, + ready: false, + collapseLhs: false, + currentRoomId: null, + }); + this.subTitleStatus = ""; + this.setPageSubtitle(); + } + + /** + * Called just before the matrix client is started + * (useful for setting listeners) + */ + private onWillStartClient(): void { + // reset the 'have completed first sync' flag, + // since we're about to start the client and therefore about + // to do the first sync + this.firstSyncComplete = false; + this.firstSyncPromise = defer(); + const cli = MatrixClientPeg.safeGet(); + + // Allow the JS SDK to reap timeline events. This reduces the amount of + // memory consumed as the JS SDK stores multiple distinct copies of room + // state (each of which can be 10s of MBs) for each DISJOINT timeline. This is + // particularly noticeable when there are lots of 'limited' /sync responses + // such as when laptops unsleep. + // https://github.com/vector-im/element-web/issues/3307#issuecomment-282895568 + cli.setCanResetTimelineCallback((roomId) => { + logger.log("Request to reset timeline in room ", roomId, " viewing:", this.state.currentRoomId); + if (roomId !== this.state.currentRoomId) { + // It is safe to remove events from rooms we are not viewing. + return true; + } + // We are viewing the room which we want to reset. It is only safe to do + // this if we are not scrolled up in the view. To find out, delegate to + // the timeline panel. If the timeline panel doesn't exist, then we assume + // it is safe to reset the timeline. + if (!this.loggedInView.current) { + return true; + } + return this.loggedInView.current.canResetTimelineInRoom(roomId); + }); + + cli.on(ClientEvent.Sync, (state: SyncState, prevState: SyncState | null, data?: SyncStateData) => { + if (state === SyncState.Error || state === SyncState.Reconnecting) { + if (data?.error instanceof InvalidStoreError) { + Lifecycle.handleInvalidStoreError(data.error); + } + this.setState({ syncError: data?.error ?? null }); + } else if (this.state.syncError) { + this.setState({ syncError: null }); + } + + if (state === SyncState.Syncing && prevState === SyncState.Syncing) { + return; + } + logger.debug(`MatrixClient sync state => ${state}`); + if (state !== SyncState.Prepared) { + return; + } + + this.firstSyncComplete = true; + this.firstSyncPromise.resolve(); + + if (Notifier.shouldShowPrompt() && !MatrixClientPeg.userRegisteredWithinLastHours(24)) { + showNotificationsToast(false); + } + + dis.fire(Action.FocusSendMessageComposer); + this.setState({ + ready: true, + }); + }); + + cli.on(HttpApiEvent.SessionLoggedOut, function (errObj) { + if (Lifecycle.isLoggingOut()) return; + + // A modal might have been open when we were logged out by the server + Modal.closeCurrentModal("Session.logged_out"); + + if (errObj.httpStatus === 401 && errObj.data && errObj.data["soft_logout"]) { + logger.warn("Soft logout issued by server - avoiding data deletion"); + Lifecycle.softLogout(); + return; + } + + Modal.createDialog(ErrorDialog, { + title: _t("auth|session_logged_out_title"), + description: _t("auth|session_logged_out_description"), + }); + + dis.dispatch({ + action: "logout", + }); + }); + cli.on(HttpApiEvent.NoConsent, function (message, consentUri) { + Modal.createDialog( + QuestionDialog, + { + title: _t("terms|tac_title"), + description: ( +
+

{_t("terms|tac_description", { homeserverDomain: cli.getDomain() })}

+
+ ), + button: _t("terms|tac_button"), + cancelButton: _t("action|dismiss"), + onFinished: (confirmed) => { + if (confirmed) { + const wnd = window.open(consentUri, "_blank")!; + wnd.opener = null; + } + }, + }, + undefined, + true, + ); + }); + + const dft = DecryptionFailureTracker.instance; + + // Shelved for later date when we have time to think about persisting history of + // tracked events across sessions. + // dft.loadTrackedEventHashMap(); + + dft.start(); + + // When logging out, stop tracking failures and destroy state + cli.on(HttpApiEvent.SessionLoggedOut, () => dft.stop()); + cli.on(MatrixEventEvent.Decrypted, (e, err) => dft.eventDecrypted(e, err as DecryptionError)); + + cli.on(ClientEvent.Room, (room) => { + if (cli.isCryptoEnabled()) { + const blacklistEnabled = SettingsStore.getValueAt( + SettingLevel.ROOM_DEVICE, + "blacklistUnverifiedDevices", + room.roomId, + /*explicit=*/ true, + ); + room.setBlacklistUnverifiedDevices(blacklistEnabled); + } + }); + cli.on(CryptoEvent.Warning, (type) => { + switch (type) { + case "CRYPTO_WARNING_OLD_VERSION_DETECTED": + Modal.createDialog(ErrorDialog, { + title: _t("encryption|old_version_detected_title"), + description: _t("encryption|old_version_detected_description", { + brand: SdkConfig.get().brand, + }), + }); + break; + } + }); + cli.on(CryptoEvent.KeyBackupFailed, async (errcode): Promise => { + let haveNewVersion: boolean | undefined; + let newVersionInfo: IKeyBackupInfo | null = null; + // if key backup is still enabled, there must be a new backup in place + if (cli.getKeyBackupEnabled()) { + haveNewVersion = true; + } else { + // otherwise check the server to see if there's a new one + try { + newVersionInfo = await cli.getKeyBackupVersion(); + if (newVersionInfo !== null) haveNewVersion = true; + } catch (e) { + logger.error("Saw key backup error but failed to check backup version!", e); + return; + } + } + + if (haveNewVersion) { + Modal.createDialogAsync( + import( + "matrix-react-sdk/src/async-components/views/dialogs/security/NewRecoveryMethodDialog" + ) as unknown as Promise, + { newVersionInfo: newVersionInfo! }, + ); + } else { + Modal.createDialogAsync( + import( + "matrix-react-sdk/src/async-components/views/dialogs/security/RecoveryMethodRemovedDialog" + ) as unknown as Promise, + ); + } + }); + + cli.on(CryptoEvent.KeySignatureUploadFailure, (failures, source, continuation) => { + Modal.createDialog(KeySignatureUploadFailedDialog, { failures, source, continuation }); + }); + + cli.on(CryptoEvent.VerificationRequestReceived, (request) => { + if (request.verifier) { + Modal.createDialog( + IncomingSasDialog, + { + verifier: request.verifier, + }, + undefined, + /* priority = */ false, + /* static = */ true, + ); + } else if (request.pending) { + ToastStore.sharedInstance().addOrReplaceToast({ + key: "verifreq_" + request.transactionId, + title: _t("encryption|verification_requested_toast_title"), + icon: "verification", + props: { request }, + component: VerificationRequestToast, + priority: 90, + }); + } + }); + + this.voiceBroadcastResumer = new VoiceBroadcastResumer(cli); + } + + /** + * Called shortly after the matrix client has started. Useful for + * setting up anything that requires the client to be started. + * @private + */ + private onClientStarted(): void { + const cli = MatrixClientPeg.safeGet(); + + if (cli.isCryptoEnabled()) { + const blacklistEnabled = SettingsStore.getValueAt(SettingLevel.DEVICE, "blacklistUnverifiedDevices"); + cli.setGlobalBlacklistUnverifiedDevices(blacklistEnabled); + + // With cross-signing enabled, we send to unknown devices + // without prompting. Any bad-device status the user should + // be aware of will be signalled through the room shield + // changing colour. More advanced behaviour will come once + // we implement more settings. + cli.setGlobalErrorOnUnknownDevices(false); + } + + // Cannot be done in OnLoggedIn as at that point the AccountSettingsHandler doesn't yet have a client + // Will be moved to a pre-login flow as well + if (PosthogAnalytics.instance.isEnabled() && SettingsStore.isLevelSupported(SettingLevel.ACCOUNT)) { + this.initPosthogAnalyticsToast(); + } + } + + public showScreen(screen: string, params?: { [key: string]: any }): void { + const cli = MatrixClientPeg.get(); + const isLoggedOutOrGuest = !cli || cli.isGuest(); + if (!isLoggedOutOrGuest && AUTH_SCREENS.includes(screen)) { + // user is logged in and landing on an auth page which will uproot their session, redirect them home instead + dis.dispatch({ action: Action.ViewHomePage }); + return; + } + + if (screen === "register") { + dis.dispatch({ + action: "start_registration", + params: params, + }); + PerformanceMonitor.instance.start(PerformanceEntryNames.REGISTER); + } else if (screen === "login") { + dis.dispatch({ + action: "start_login", + params: params, + }); + PerformanceMonitor.instance.start(PerformanceEntryNames.LOGIN); + } else if (screen === "forgot_password") { + dis.dispatch({ + action: "start_password_recovery", + params: params, + }); + } else if (screen === "soft_logout") { + if (!!cli?.getUserId() && !Lifecycle.isSoftLogout()) { + // Logged in - visit a room + this.viewLastRoom(); + } else { + // Ultimately triggers soft_logout if needed + dis.dispatch({ + action: "start_login", + params: params, + }); + } + } else if (screen === "new") { + dis.dispatch({ + action: "view_create_room", + }); + } else if (screen === "dm") { + dis.dispatch({ + action: "view_create_chat", + }); + } else if (screen === "settings") { + dis.fire(Action.ViewUserSettings); + } else if (screen === "welcome") { + dis.dispatch({ + action: "view_welcome_page", + }); + } else if (screen === "home") { + dis.dispatch({ + action: Action.ViewHomePage, + }); + } else if (screen === "start") { + this.showScreen("home"); + dis.dispatch({ + action: "require_registration", + }); + } else if (screen === "directory") { + dis.fire(Action.ViewRoomDirectory); + } else if (screen === "start_sso" || screen === "start_cas") { + let cli = MatrixClientPeg.get(); + if (!cli) { + const { hsUrl, isUrl } = this.getServerProperties().serverConfig; + cli = createClient({ + baseUrl: hsUrl, + idBaseUrl: isUrl, + }); + } + + const type = screen === "start_sso" ? "sso" : "cas"; + PlatformPeg.get()?.startSingleSignOn(cli, type, this.getFragmentAfterLogin()); + } else if (screen.indexOf("room/") === 0) { + // Rooms can have the following formats: + // #room_alias:domain or !opaque_id:domain + const room = screen.substring(5); + const domainOffset = room.indexOf(":") + 1; // 0 in case room does not contain a : + let eventOffset = room.length; + // room aliases can contain slashes only look for slash after domain + if (room.substring(domainOffset).indexOf("/") > -1) { + eventOffset = domainOffset + room.substring(domainOffset).indexOf("/"); + } + const roomString = room.substring(0, eventOffset); + let eventId: string | undefined = room.substring(eventOffset + 1); // empty string if no event id given + + // Previously we pulled the eventID from the segments in such a way + // where if there was no eventId then we'd get undefined. However, we + // now do a splice and join to handle v3 event IDs which results in + // an empty string. To maintain our potential contract with the rest + // of the app, we coerce the eventId to be undefined where applicable. + if (!eventId) eventId = undefined; + + // TODO: Handle encoded room/event IDs: https://github.com/vector-im/element-web/issues/9149 + + let threepidInvite: IThreepidInvite | undefined; + // if we landed here from a 3PID invite, persist it + if (params?.signurl && params?.email) { + threepidInvite = ThreepidInviteStore.instance.storeInvite( + roomString, + params as IThreepidInviteWireFormat, + ); + } + // otherwise check that this room doesn't already have a known invite + if (!threepidInvite) { + const invites = ThreepidInviteStore.instance.getInvites(); + threepidInvite = invites.find((invite) => invite.roomId === roomString); + } + + // on our URLs there might be a ?via=matrix.org or similar to help + // joins to the room succeed. We'll pass these through as an array + // to other levels. If there's just one ?via= then params.via is a + // single string. If someone does something like ?via=one.com&via=two.com + // then params.via is an array of strings. + let via: string[] = []; + if (params?.via) { + if (typeof params.via === "string") via = [params.via]; + else via = params.via; + } + + const payload: ViewRoomPayload = { + action: Action.ViewRoom, + event_id: eventId, + via_servers: via, + // If an event ID is given in the URL hash, notify RoomViewStore to mark + // it as highlighted, which will propagate to RoomView and highlight the + // associated EventTile. + highlighted: Boolean(eventId), + threepid_invite: threepidInvite, + // TODO: Replace oob_data with the threepidInvite (which has the same info). + // This isn't done yet because it's threaded through so many more places. + // See https://github.com/vector-im/element-web/issues/15157 + oob_data: { + name: threepidInvite?.roomName, + avatarUrl: threepidInvite?.roomAvatarUrl, + inviterName: threepidInvite?.inviterName, + }, + room_alias: undefined, + room_id: undefined, + metricsTrigger: undefined, // unknown or external trigger + }; + if (roomString[0] === "#") { + payload.room_alias = roomString; + } else { + payload.room_id = roomString; + } + + dis.dispatch(payload); + } else if (screen.indexOf("user/") === 0) { + const userId = screen.substring(5); + dis.dispatch({ + action: "view_user_info", + userId: userId, + subAction: params?.action, + }); + } else { + logger.info(`Ignoring showScreen for '${screen}'`); + } + } + + private notifyNewScreen(screen: string, replaceLast = false): void { + if (this.props.onNewScreen) { + this.props.onNewScreen(screen, replaceLast); + } + this.setPageSubtitle(); + } + + private onLogoutClick(event: ButtonEvent): void { + dis.dispatch({ + action: "logout", + }); + event.stopPropagation(); + event.preventDefault(); + } + + private handleResize = (): void => { + const LHS_THRESHOLD = 1000; + const width = UIStore.instance.windowWidth; + + if (this.prevWindowWidth < LHS_THRESHOLD && width >= LHS_THRESHOLD) { + dis.dispatch({ action: "show_left_panel" }); + } + + if (this.prevWindowWidth >= LHS_THRESHOLD && width < LHS_THRESHOLD) { + dis.dispatch({ action: "hide_left_panel" }); + } + + this.prevWindowWidth = width; + this.state.resizeNotifier.notifyWindowResized(); + }; + + private dispatchTimelineResize(): void { + dis.dispatch({ action: "timeline_resize" }); + } + + private onRegisterClick = (): void => { + this.showScreen("register"); + }; + + private onLoginClick = (): void => { + this.showScreen("login"); + }; + + private onForgotPasswordClick = (): void => { + this.showScreen("forgot_password"); + }; + + private onRegisterFlowComplete = (credentials: IMatrixClientCreds, password: string): Promise => { + return this.onUserCompletedLoginFlow(credentials, password); + }; + + // returns a promise which resolves to the new MatrixClient + private onRegistered(credentials: IMatrixClientCreds): Promise { + return Lifecycle.setLoggedIn(credentials); + } + + private onSendEvent(roomId: string, event: MatrixEvent): void { + const cli = MatrixClientPeg.get(); + if (!cli) return; + + cli.sendEvent(roomId, event.getType(), event.getContent()).then(() => { + dis.dispatch({ action: "message_sent" }); + }); + } + + private setPageSubtitle(subtitle = ""): void { + if (this.state.currentRoomId) { + const client = MatrixClientPeg.get(); + const room = client?.getRoom(this.state.currentRoomId); + if (room) { + subtitle = `${this.subTitleStatus} | ${room.name} ${subtitle}`; + } + } else { + subtitle = `${this.subTitleStatus} ${subtitle}`; + } + + const title = `${SdkConfig.get().brand} ${subtitle}`; + + if (document.title !== title) { + document.title = title; + } + } + + private onUpdateStatusIndicator = (notificationState: SummarizedNotificationState, state: SyncState): void => { + const numUnreadRooms = notificationState.numUnreadStates; // we know that states === rooms here + + if (PlatformPeg.get()) { + PlatformPeg.get()!.setErrorStatus(state === SyncState.Error); + PlatformPeg.get()!.setNotificationCount(numUnreadRooms); + } + + this.subTitleStatus = ""; + if (state === SyncState.Error) { + this.subTitleStatus += `[${_t("common|offline")}] `; + } + if (numUnreadRooms > 0) { + this.subTitleStatus += `[${numUnreadRooms}]`; + } else if (notificationState.level >= NotificationLevel.Activity) { + this.subTitleStatus += `*`; + } + + this.setPageSubtitle(); + }; + + private onServerConfigChange = (serverConfig: ValidatedServerConfig): void => { + this.setState({ serverConfig }); + }; + + /** + * After registration or login, we run various post-auth steps before entering the app + * proper, such setting up cross-signing or verifying the new session. + * + * Note: SSO users (and any others using token login) currently do not pass through + * this, as they instead jump straight into the app after `attemptTokenLogin`. + */ + private onUserCompletedLoginFlow = async (credentials: IMatrixClientCreds, password: string): Promise => { + this.stores.accountPasswordStore.setPassword(password); + + // Create and start the client + await Lifecycle.setLoggedIn(credentials); + await this.postLoginSetup(); + + PerformanceMonitor.instance.stop(PerformanceEntryNames.LOGIN); + PerformanceMonitor.instance.stop(PerformanceEntryNames.REGISTER); + }; + + // complete security / e2e setup has finished + private onCompleteSecurityE2eSetupFinished = (): void => { + this.onLoggedIn(); + }; + + private getFragmentAfterLogin(): string { + let fragmentAfterLogin = ""; + const initialScreenAfterLogin = this.props.initialScreenAfterLogin; + if ( + initialScreenAfterLogin && + // XXX: workaround for https://github.com/vector-im/element-web/issues/11643 causing a login-loop + !["welcome", "login", "register", "start_sso", "start_cas"].includes(initialScreenAfterLogin.screen) + ) { + fragmentAfterLogin = `/${initialScreenAfterLogin.screen}`; + } + return fragmentAfterLogin; + } + + public render(): React.ReactNode { + const fragmentAfterLogin = this.getFragmentAfterLogin(); + let view: JSX.Element; + + if (this.state.view === Views.LOADING) { + view = ( +
+ +
+ ); + } else if (this.state.view === Views.CONFIRM_LOCK_THEFT) { + view = ( + { + this.setState({ view: Views.LOADING }); + this.startInitSession(); + }} + /> + ); + } else if (this.state.view === Views.COMPLETE_SECURITY) { + view = ; + } else if (this.state.view === Views.E2E_SETUP) { + view = ( + + ); + } else if (this.state.view === Views.LOGGED_IN) { + // store errors stop the client syncing and require user intervention, so we'll + // be showing a dialog. Don't show anything else. + const isStoreError = this.state.syncError && this.state.syncError instanceof InvalidStoreError; + + // `ready` and `view==LOGGED_IN` may be set before `page_type` (because the + // latter is set via the dispatcher). If we don't yet have a `page_type`, + // keep showing the spinner for now. + if (this.state.ready && this.state.page_type && !isStoreError) { + /* for now, we stuff the entirety of our props and state into the LoggedInView. + * we should go through and figure out what we actually need to pass down, as well + * as using something like redux to avoid having a billion bits of state kicking around. + */ + view = ( + + ); + } else { + // we think we are logged in, but are still waiting for the /sync to complete + let errorBox; + if (this.state.syncError && !isStoreError) { + errorBox = ( +
{messageForSyncError(this.state.syncError)}
+ ); + } + view = ( +
+ {errorBox} + +
+ + {_t("action|logout")} + +
+
+ ); + } + } else if (this.state.view === Views.WELCOME) { + view = ; + } else if (this.state.view === Views.REGISTER && SettingsStore.getValue(UIFeature.Registration)) { + const email = ThreepidInviteStore.instance.pickBestInvite()?.toEmail; + view = ( + + ); + } else if (this.state.view === Views.FORGOT_PASSWORD && SettingsStore.getValue(UIFeature.PasswordReset)) { + view = ( + + ); + } else if (this.state.view === Views.LOGIN) { + const showPasswordReset = SettingsStore.getValue(UIFeature.PasswordReset); + view = ( + + ); + } else if (this.state.view === Views.SOFT_LOGOUT) { + view = ( + + ); + } else if (this.state.view === Views.USE_CASE_SELECTION) { + view = => this.onShowPostLoginScreen(useCase)} />; + } else if (this.state.view === Views.LOCK_STOLEN) { + view = ; + } else { + logger.error(`Unknown view ${this.state.view}`); + return null; + } + + return ( + + {view} + + ); + } +} diff --git a/src/components/structures/auth/Registration.tsx b/src/components/structures/auth/Registration.tsx new file mode 100644 index 00000000000..7d10ca5bf8b --- /dev/null +++ b/src/components/structures/auth/Registration.tsx @@ -0,0 +1,767 @@ +/* +Copyright 2015-2021 The Matrix.org Foundation C.I.C. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +import { + AuthType, + createClient, + IAuthData, + IAuthDict, + IInputs, + MatrixError, + IRegisterRequestParams, + IRequestTokenResponse, + MatrixClient, + SSOFlow, + SSOAction, + RegisterResponse, +} from "matrix-js-sdk/src/matrix"; +import React, { Fragment, ReactNode } from "react"; +import classNames from "classnames"; +import { logger } from "matrix-js-sdk/src/logger"; +import { Spinner } from "@matrix-org/react-sdk-module-api/lib/components/Spinner"; +import Login, { OidcNativeFlow } from "matrix-react-sdk/src/Login"; +import { IMatrixClientCreds, MatrixClientPeg } from "matrix-react-sdk/src/MatrixClientPeg"; +import InteractiveAuth, { InteractiveAuthCallback } from "matrix-react-sdk/src/components/structures/InteractiveAuth"; +import { AuthHeaderDisplay } from "matrix-react-sdk/src/components/structures/auth/header/AuthHeaderDisplay"; +import { AuthHeaderProvider } from "matrix-react-sdk/src/components/structures/auth/header/AuthHeaderProvider"; +import AuthBody from "matrix-react-sdk/src/components/views/auth/AuthBody"; +import AuthHeader from "matrix-react-sdk/src/components/views/auth/AuthHeader"; +import AuthPage from "matrix-react-sdk/src/components/views/auth/AuthPage"; +import dis from "matrix-react-sdk/src/dispatcher/dispatcher"; +import AccessibleButton, { ButtonEvent } from "matrix-react-sdk/src/components/views/elements/AccessibleButton"; +import SSOButtons from "matrix-react-sdk/src/components/views/elements/SSOButtons"; +import ServerPicker from "matrix-react-sdk/src/components/views/elements/ServerPicker"; +import * as Lifecycle from "matrix-react-sdk/src/Lifecycle"; +import { Features } from "matrix-react-sdk/src/settings/Settings"; +import SettingsStore from "matrix-react-sdk/src/settings/SettingsStore"; +import AutoDiscoveryUtils from "matrix-react-sdk/src/utils/AutoDiscoveryUtils"; +import { + messageForResourceLimitError, + resourceLimitStrings, + adminContactStrings, +} from "matrix-react-sdk/src/utils/ErrorUtils"; +import { ValidatedServerConfig } from "matrix-react-sdk/src/utils/ValidatedServerConfig"; +import { startOidcLogin } from "matrix-react-sdk/src/utils/oidc/authorize"; + +import { _t } from "../../../languageHandler"; +import RegistrationForm from "../../views/auth/RegistrationForm"; + +const debuglog = (...args: any[]): void => { + if (SettingsStore.getValue("debug_registration")) { + logger.log.call(console, "Registration debuglog:", ...args); + } +}; + +interface IProps { + serverConfig: ValidatedServerConfig; + defaultDeviceDisplayName?: string; + email?: string; + brand?: string; + clientSecret?: string; + sessionId?: string; + idSid?: string; + fragmentAfterLogin?: string; + inviteCode?: string; + + // Called when the user has logged in. Params: + // - object with userId, deviceId, homeserverUrl, identityServerUrl, accessToken + // - The user's password, if available and applicable (may be cached in memory + // for a short time so the user is not required to re-enter their password + // for operations like uploading cross-signing keys). + onLoggedIn(params: IMatrixClientCreds, password: string): Promise; + // registration shouldn't know or care how login is done. + onLoginClick(): void; + onServerConfigChange(config: ValidatedServerConfig): void; +} + +interface IState { + // true if we're waiting for the user to complete + busy: boolean; + errorText?: ReactNode; + // We remember the values entered by the user because + // the registration form will be unmounted during the + // course of registration, but if there's an error we + // want to bring back the registration form with the + // values the user entered still in it. We can keep + // them in this component's state since this component + // persist for the duration of the registration process. + formVals: Record; + // user-interactive auth + // If we've been given a session ID, we're resuming + // straight back into UI auth + doingUIAuth: boolean; + // If set, we've registered but are not going to log + // the user in to their new account automatically. + completedNoSignin: boolean; + flows: + | { + stages: string[]; + }[] + | null; + // We perform liveliness checks later, but for now suppress the errors. + // We also track the server dead errors independently of the regular errors so + // that we can render it differently, and override any other error the user may + // be seeing. + serverIsAlive: boolean; + serverErrorIsFatal: boolean; + serverDeadError?: ReactNode; + + // Our matrix client - part of state because we can't render the UI auth + // component without it. + matrixClient?: MatrixClient; + // The user ID we've just registered + registeredUsername?: string; + // if a different user ID to the one we just registered is logged in, + // this is the user ID that's logged in. + differentLoggedInUserId?: string; + // the SSO flow definition, this is fetched from /login as that's the only + // place it is exposed. + ssoFlow?: SSOFlow; + // the OIDC native login flow, when supported and enabled + // if present, must be used for registration + oidcNativeFlow?: OidcNativeFlow; +} + +export default class Registration extends React.Component { + private readonly loginLogic: Login; + // `replaceClient` tracks latest serverConfig to spot when it changes under the async method which fetches flows + private latestServerConfig?: ValidatedServerConfig; + // cache value from settings store + private oidcNativeFlowEnabled = false; + + public constructor(props: IProps) { + super(props); + + this.state = { + busy: false, + errorText: null, + formVals: { + email: this.props.email, + }, + doingUIAuth: Boolean(this.props.sessionId), + flows: null, + completedNoSignin: false, + serverIsAlive: true, + serverErrorIsFatal: false, + serverDeadError: "", + }; + + // only set on a config level, so we don't need to watch + this.oidcNativeFlowEnabled = SettingsStore.getValue(Features.OidcNativeFlow); + + const { hsUrl, isUrl, delegatedAuthentication } = this.props.serverConfig; + this.loginLogic = new Login(hsUrl, isUrl, null, { + defaultDeviceDisplayName: "Element login check", // We shouldn't ever be used + // if native OIDC is enabled in the client pass the server's delegated auth settings + delegatedAuthentication: this.oidcNativeFlowEnabled ? delegatedAuthentication : undefined, + }); + } + + public componentDidMount(): void { + this.replaceClient(this.props.serverConfig); + //triggers a confirmation dialog for data loss before page unloads/refreshes + window.addEventListener("beforeunload", this.unloadCallback); + } + + public componentWillUnmount(): void { + window.removeEventListener("beforeunload", this.unloadCallback); + } + + private unloadCallback = (event: BeforeUnloadEvent): string | undefined => { + if (this.state.doingUIAuth) { + event.preventDefault(); + event.returnValue = ""; + return ""; + } + }; + + public componentDidUpdate(prevProps: IProps): void { + if ( + prevProps.serverConfig.hsUrl !== this.props.serverConfig.hsUrl || + prevProps.serverConfig.isUrl !== this.props.serverConfig.isUrl + ) { + this.replaceClient(this.props.serverConfig); + } + } + + private async replaceClient(serverConfig: ValidatedServerConfig): Promise { + this.latestServerConfig = serverConfig; + const { hsUrl, isUrl } = serverConfig; + + this.setState({ + errorText: null, + serverDeadError: null, + serverErrorIsFatal: false, + // busy while we do live-ness check (we need to avoid trying to render + // the UI auth component while we don't have a matrix client) + busy: true, + }); + + // Do a liveliness check on the URLs + try { + await AutoDiscoveryUtils.validateServerConfigWithStaticUrls(hsUrl, isUrl); + if (serverConfig !== this.latestServerConfig) return; // discard, serverConfig changed from under us + this.setState({ + serverIsAlive: true, + serverErrorIsFatal: false, + }); + } catch (e) { + if (serverConfig !== this.latestServerConfig) return; // discard, serverConfig changed from under us + this.setState({ + busy: false, + ...AutoDiscoveryUtils.authComponentStateForError(e, "register"), + }); + if (this.state.serverErrorIsFatal) { + return; // Server is dead - do not continue. + } + } + + const cli = createClient({ + baseUrl: hsUrl, + idBaseUrl: isUrl, + }); + + this.loginLogic.setHomeserverUrl(hsUrl); + this.loginLogic.setIdentityServerUrl(isUrl); + // if native OIDC is enabled in the client pass the server's delegated auth settings + const delegatedAuthentication = this.oidcNativeFlowEnabled ? serverConfig.delegatedAuthentication : undefined; + + this.loginLogic.setDelegatedAuthentication(delegatedAuthentication); + + let ssoFlow: SSOFlow | undefined; + let oidcNativeFlow: OidcNativeFlow | undefined; + try { + const loginFlows = await this.loginLogic.getFlows(true); + if (serverConfig !== this.latestServerConfig) return; // discard, serverConfig changed from under us + ssoFlow = loginFlows.find((f) => f.type === "m.login.sso" || f.type === "m.login.cas") as SSOFlow; + oidcNativeFlow = loginFlows.find((f) => f.type === "oidcNativeFlow") as OidcNativeFlow; + } catch (e) { + if (serverConfig !== this.latestServerConfig) return; // discard, serverConfig changed from under us + logger.error("Failed to get login flows to check for SSO support", e); + } + + this.setState(({ flows }) => ({ + matrixClient: cli, + ssoFlow, + oidcNativeFlow, + // if we are using oidc native we won't continue with flow discovery on HS + // so set an empty array to indicate flows are no longer loading + flows: oidcNativeFlow ? [] : flows, + busy: false, + })); + + // don't need to check with homeserver for login flows + // since we are going to use OIDC native flow + if (oidcNativeFlow) { + return; + } + + try { + // We do the first registration request ourselves to discover whether we need to + // do SSO instead. If we've already started the UI Auth process though, we don't + // need to. + if (!this.state.doingUIAuth) { + await this.makeRegisterRequest(null); + if (serverConfig !== this.latestServerConfig) return; // discard, serverConfig changed from under us + // This should never succeed since we specified no auth object. + logger.log("Expecting 401 from register request but got success!"); + } + } catch (e) { + if (serverConfig !== this.latestServerConfig) return; // discard, serverConfig changed from under us + if (e instanceof MatrixError && e.httpStatus === 401) { + this.setState({ + flows: e.data.flows, + }); + } else if (e instanceof MatrixError && (e.httpStatus === 403 || e.errcode === "M_FORBIDDEN")) { + // Check for 403 or M_FORBIDDEN, Synapse used to send 403 M_UNKNOWN but now sends 403 M_FORBIDDEN. + // At this point registration is pretty much disabled, but before we do that let's + // quickly check to see if the server supports SSO instead. If it does, we'll send + // the user off to the login page to figure their account out. + if (ssoFlow) { + // Redirect to login page - server probably expects SSO only + dis.dispatch({ action: "start_login" }); + } else { + this.setState({ + serverErrorIsFatal: true, // fatal because user cannot continue on this server + errorText: _t("auth|registration_disabled"), + // add empty flows array to get rid of spinner + flows: [], + }); + } + } else { + logger.log("Unable to query for supported registration methods.", e); + this.setState({ + errorText: _t("auth|failed_query_registration_methods"), + // add empty flows array to get rid of spinner + flows: [], + }); + } + } + } + + private onFormSubmit = async (formVals: Record): Promise => { + this.setState({ + errorText: "", + busy: true, + formVals, + doingUIAuth: true, + }); + }; + + private requestEmailToken = ( + emailAddress: string, + clientSecret: string, + sendAttempt: number, + sessionId: string, + ): Promise => { + if (!this.state.matrixClient) throw new Error("Matrix client has not yet been loaded"); + return this.state.matrixClient.requestRegisterEmailToken(emailAddress, clientSecret, sendAttempt); + }; + + private onUIAuthFinished: InteractiveAuthCallback = async (success, response): Promise => { + if (!this.state.matrixClient) throw new Error("Matrix client has not yet been loaded"); + + debuglog("Registration: ui authentication finished: ", { success, response }); + if (!success) { + let errorText: ReactNode = (response as Error).message || (response as Error).toString(); + // can we give a better error message? + if (response instanceof MatrixError && response.errcode === "M_RESOURCE_LIMIT_EXCEEDED") { + const errorTop = messageForResourceLimitError( + response.data.limit_type, + response.data.admin_contact, + resourceLimitStrings, + ); + const errorDetail = messageForResourceLimitError( + response.data.limit_type, + response.data.admin_contact, + adminContactStrings, + ); + errorText = ( +
+

{errorTop}

+

{errorDetail}

+
+ ); + } else if ((response as IAuthData).flows?.some((flow) => flow.stages.includes(AuthType.Msisdn))) { + const flows = (response as IAuthData).flows ?? []; + const msisdnAvailable = flows.some((flow) => flow.stages.includes(AuthType.Msisdn)); + if (!msisdnAvailable) { + errorText = _t("auth|unsupported_auth_msisdn"); + } + } else if (response instanceof MatrixError && response.errcode === "M_USER_IN_USE") { + errorText = _t("auth|username_in_use"); + } else if (response instanceof MatrixError && response.errcode === "M_THREEPID_IN_USE") { + errorText = _t("auth|3pid_in_use"); + } + + this.setState({ + busy: false, + doingUIAuth: false, + errorText, + }); + return; + } + + const userId = (response as RegisterResponse).user_id; + const accessToken = (response as RegisterResponse).access_token; + if (!userId || !accessToken) throw new Error("Registration failed"); + + MatrixClientPeg.setJustRegisteredUserId(userId); + + const newState: Partial = { + doingUIAuth: false, + registeredUsername: userId, + differentLoggedInUserId: undefined, + completedNoSignin: false, + // we're still busy until we get unmounted: don't show the registration form again + busy: true, + }; + + // The user came in through an email validation link. To avoid overwriting + // their session, check to make sure the session isn't someone else, and + // isn't a guest user since we'll usually have set a guest user session before + // starting the registration process. This isn't perfect since it's possible + // the user had a separate guest session they didn't actually mean to replace. + const [sessionOwner, sessionIsGuest] = await Lifecycle.getStoredSessionOwner(); + if (sessionOwner && !sessionIsGuest && sessionOwner !== userId) { + logger.log(`Found a session for ${sessionOwner} but ${userId} has just registered.`); + newState.differentLoggedInUserId = sessionOwner; + } + + // if we don't have an email at all, only one client can be involved in this flow, and we can directly log in. + // + // if we've got an email, it needs to be verified. in that case, two clients can be involved in this flow, the + // original client starting the process and the client that submitted the verification token. After the token + // has been submitted, it can not be used again. + // + // we can distinguish them based on whether the client has form values saved (if so, it's the one that started + // the registration), or whether it doesn't have any form values saved (in which case it's the client that + // verified the email address) + // + // as the client that started registration may be gone by the time we've verified the email, and only the client + // that verified the email is guaranteed to exist, we'll always do the login in that client. + const hasEmail = Boolean(this.state.formVals.email); + const hasAccessToken = Boolean(accessToken); + debuglog("Registration: ui auth finished:", { hasEmail, hasAccessToken }); + // don’t log in if we found a session for a different user + if (hasAccessToken && !newState.differentLoggedInUserId) { + await this.props.onLoggedIn( + { + userId, + deviceId: (response as RegisterResponse).device_id!, + homeserverUrl: this.state.matrixClient.getHomeserverUrl(), + identityServerUrl: this.state.matrixClient.getIdentityServerUrl(), + accessToken, + }, + this.state.formVals.password!, + ); + + this.setupPushers(); + } else { + newState.busy = false; + newState.completedNoSignin = true; + } + + this.setState(newState as IState); + }; + + private setupPushers(): Promise { + if (!this.props.brand) { + return Promise.resolve(); + } + const matrixClient = MatrixClientPeg.safeGet(); + return matrixClient.getPushers().then( + (resp) => { + const pushers = resp.pushers; + for (let i = 0; i < pushers.length; ++i) { + if (pushers[i].kind === "email") { + const emailPusher = pushers[i]; + emailPusher.data = { brand: this.props.brand }; + matrixClient.setPusher(emailPusher).then( + () => { + logger.log("Set email branding to " + this.props.brand); + }, + (error) => { + logger.error("Couldn't set email branding: " + error); + }, + ); + } + } + }, + (error) => { + logger.error("Couldn't get pushers: " + error); + }, + ); + } + + private onLoginClick = (ev: ButtonEvent): void => { + ev.preventDefault(); + ev.stopPropagation(); + this.props.onLoginClick(); + }; + + private onGoToFormClicked = (ev: ButtonEvent): void => { + ev.preventDefault(); + ev.stopPropagation(); + this.replaceClient(this.props.serverConfig); + this.setState({ + busy: false, + doingUIAuth: false, + }); + }; + + private makeRegisterRequest = (auth: IAuthDict | null): Promise => { + if (!this.state.matrixClient) throw new Error("Matrix client has not yet been loaded"); + + const registerParams: IRegisterRequestParams = { + username: this.state.formVals.username, + password: this.state.formVals.password, + initial_device_display_name: this.props.defaultDeviceDisplayName, + auth: undefined, + // we still want to avoid the race conditions involved with multiple clients handling registration, but + // we'll handle these after we've received the access_token in onUIAuthFinished + inhibit_login: undefined, + }; + if (auth) registerParams.auth = auth; + debuglog("Registration: sending registration request:", auth); + return this.state.matrixClient.registerRequest(registerParams); + }; + + private getUIAuthInputs(): IInputs { + return { + emailAddress: this.state.formVals.email, + phoneCountry: this.state.formVals.phoneCountry, + phoneNumber: this.state.formVals.phoneNumber, + }; + } + + // Links to the login page shown after registration is completed are routed through this + // which checks the user hasn't already logged in somewhere else (perhaps we should do + // this more generally?) + private onLoginClickWithCheck = async (ev: ButtonEvent): Promise => { + ev.preventDefault(); + + const sessionLoaded = await Lifecycle.loadSession({ ignoreGuest: true }); + if (!sessionLoaded) { + // ok fine, there's still no session: really go to the login page + this.props.onLoginClick(); + } + + return sessionLoaded; + }; + + private renderRegisterComponent(): ReactNode { + if (this.state.matrixClient && this.state.doingUIAuth) { + return ( + + ); + } else if (!this.state.matrixClient && !this.state.busy) { + return null; + } else if (this.state.busy || !this.state.flows) { + return ( +
+ +
+ ); + } else if (this.state.matrixClient && this.state.oidcNativeFlow) { + return ( + { + await startOidcLogin( + this.props.serverConfig.delegatedAuthentication!, + this.state.oidcNativeFlow!.clientId, + this.props.serverConfig.hsUrl, + this.props.serverConfig.isUrl, + true /* isRegistration */, + ); + }} + > + {_t("action|continue")} + + ); + } else if (this.state.matrixClient && this.state.flows.length) { + let ssoSection: JSX.Element | undefined; + if (this.state.ssoFlow) { + let continueWithSection; + const providers = this.state.ssoFlow.identity_providers || []; + // when there is only a single (or 0) providers we show a wide button with `Continue with X` text + if (providers.length > 1) { + // i18n: ssoButtons is a placeholder to help translators understand context + continueWithSection = ( +

+ {_t("auth|continue_with_sso", { ssoButtons: "" }).trim()} +

+ ); + } + + // i18n: ssoButtons & usernamePassword are placeholders to help translators understand context + ssoSection = ( + + {continueWithSection} + +

+ {_t("auth|sso_or_username_password", { + ssoButtons: "", + usernamePassword: "", + }).trim()} +

+
+ ); + } + return ( + + {ssoSection} + + + ); + } + + return null; + } + + public render(): React.ReactNode { + let errorText; + const err = this.state.errorText; + if (err) { + errorText =
{err}
; + } + + let serverDeadSection; + if (!this.state.serverIsAlive) { + const classes = classNames({ + mx_Login_error: true, + mx_Login_serverError: true, + mx_Login_serverErrorNonFatal: !this.state.serverErrorIsFatal, + }); + serverDeadSection =
{this.state.serverDeadError}
; + } + + const signIn = ( + + {_t( + "auth|sign_in_instead_prompt", + {}, + { + a: (sub) => ( + + {sub} + + ), + }, + )} + + ); + + // Only show the 'go back' button if you're not looking at the form + let goBack; + if (this.state.doingUIAuth) { + goBack = ( + + {_t("action|go_back")} + + ); + } + + let body; + if (this.state.completedNoSignin) { + let regDoneText; + if (this.state.differentLoggedInUserId) { + regDoneText = ( +
+

+ {_t("auth|account_clash", { + newAccountId: this.state.registeredUsername, + loggedInUserId: this.state.differentLoggedInUserId, + })} +

+

+ => { + const sessionLoaded = await this.onLoginClickWithCheck(event); + if (sessionLoaded) { + dis.dispatch({ action: "view_welcome_page" }); + } + }} + > + {_t("auth|account_clash_previous_account")} + +

+
+ ); + } else { + // regardless of whether we're the client that started the registration or not, we should + // try our credentials anyway + regDoneText = ( +

+ {_t( + "auth|log_in_new_account", + {}, + { + a: (sub) => ( + => { + const sessionLoaded = await this.onLoginClickWithCheck(event); + if (sessionLoaded) { + dis.dispatch({ action: "view_home_page" }); + } + }} + > + {sub} + + ), + }, + )} +

+ ); + } + body = ( +
+

{_t("auth|registration_successful")}

+ {regDoneText} +
+ ); + } else { + body = ( + +
+ + } + > + {errorText} + {serverDeadSection} + + {this.renderRegisterComponent()} +
+
+ {goBack} + {signIn} +
+
+ ); + } + + return ( + + + + {body} + + + ); + } +} diff --git a/src/components/views/auth/RegistrationForm.tsx b/src/components/views/auth/RegistrationForm.tsx new file mode 100644 index 00000000000..e222e22f6c6 --- /dev/null +++ b/src/components/views/auth/RegistrationForm.tsx @@ -0,0 +1,646 @@ +/* +Copyright 2019 Michael Telatynski <7t3chguy@gmail.com> +Copyright 2015, 2016, 2017, 2018, 2019, 2020 The Matrix.org Foundation C.I.C. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +import React, { BaseSyntheticEvent, ReactNode } from "react"; +import * as Email from "matrix-react-sdk/src/email"; +import { MatrixClient, MatrixError } from "matrix-js-sdk/src/matrix"; +import { logger } from "matrix-js-sdk/src/logger"; +import Modal from "matrix-react-sdk/src/Modal"; +import { PosthogAnalytics } from "matrix-react-sdk/src/PosthogAnalytics"; +import { SAFE_LOCALPART_REGEX } from "matrix-react-sdk/src/Registration"; +import SdkConfig from "matrix-react-sdk/src/SdkConfig"; +import CountryDropdown from "matrix-react-sdk/src/components/views/auth/CountryDropdown"; +import EmailField from "matrix-react-sdk/src/components/views/auth/EmailField"; +import PassphraseConfirmField from "matrix-react-sdk/src/components/views/auth/PassphraseConfirmField"; +import PassphraseField from "matrix-react-sdk/src/components/views/auth/PassphraseField"; +import RegistrationEmailPromptDialog from "matrix-react-sdk/src/components/views/dialogs/RegistrationEmailPromptDialog"; +import Field from "matrix-react-sdk/src/components/views/elements/Field"; +import withValidation, { + IValidationResult, + IFieldState, +} from "matrix-react-sdk/src/components/views/elements/Validation"; +import { looksValid as phoneNumberLooksValid, PhoneNumberCountryDefinition } from "matrix-react-sdk/src/phonenumber"; +import { ValidatedServerConfig } from "matrix-react-sdk/src/utils/ValidatedServerConfig"; + +import { _t, _td } from "../../../languageHandler"; + +enum RegistrationField { + Email = "field_email", + PhoneNumber = "field_phone_number", + Username = "field_username", + Password = "field_password", + PasswordConfirm = "field_password_confirm", + InviteCode = "field_invite_code", +} + +enum UsernameAvailableStatus { + Unknown, + Available, + Unavailable, + Error, + Invalid, +} + +export const PASSWORD_MIN_SCORE = 3; // safely unguessable: moderate protection from offline slow-hash scenario. + +interface IProps { + // Values pre-filled in the input boxes when the component loads + defaultEmail?: string; + defaultPhoneCountry?: string; + defaultPhoneNumber?: string; + defaultUsername?: string; + defaultPassword?: string; + defaultInviteCode?: string; + flows: { + stages: string[]; + }[]; + serverConfig: ValidatedServerConfig; + canSubmit?: boolean; + matrixClient: MatrixClient; + + onRegisterClick(params: { + username: string; + password: string; + email?: string; + phoneCountry?: string; + phoneNumber?: string; + }): Promise; + onEditServerDetailsClick?(): void; +} + +interface IState { + // Field error codes by field ID + fieldValid: Partial>; + // The ISO2 country code selected in the phone number entry + phoneCountry?: string; + username: string; + email: string; + phoneNumber: string; + password: string; + passwordConfirm: string; + passwordComplexity?: number; + inviteCode: string; +} + +/* + * A pure UI component which displays a registration form. + */ +export default class RegistrationForm extends React.PureComponent { + private [RegistrationField.Email]: Field | null = null; + private [RegistrationField.Password]: Field | null = null; + private [RegistrationField.PasswordConfirm]: Field | null = null; + private [RegistrationField.Username]: Field | null = null; + private [RegistrationField.PhoneNumber]: Field | null = null; + private [RegistrationField.InviteCode]: Field | null = null; + + public static defaultProps = { + onValidationChange: logger.error, + canSubmit: true, + }; + + public constructor(props: IProps) { + super(props); + + this.state = { + fieldValid: {}, + phoneCountry: this.props.defaultPhoneCountry, + username: this.props.defaultUsername || "", + email: this.props.defaultEmail || "", + phoneNumber: this.props.defaultPhoneNumber || "", + password: this.props.defaultPassword || "", + passwordConfirm: this.props.defaultPassword || "", + inviteCode: this.props.defaultInviteCode || "", + }; + } + + private onSubmit = async ( + ev: BaseSyntheticEvent, + ): Promise => { + ev.preventDefault(); + ev.persist(); + + if (!this.props.canSubmit) return; + + const allFieldsValid = await this.verifyFieldsBeforeSubmit(); + if (!allFieldsValid) { + return; + } + + if (this.state.email === "") { + if (this.showEmail()) { + Modal.createDialog(RegistrationEmailPromptDialog, { + onFinished: async (confirmed: boolean, email?: string): Promise => { + if (confirmed && email !== undefined) { + this.setState( + { + email, + }, + () => { + this.doSubmit(ev); + }, + ); + } + }, + }); + } else { + // user can't set an e-mail so don't prompt them to + this.doSubmit(ev); + return; + } + } else { + this.doSubmit(ev); + } + }; + + private doSubmit( + ev: BaseSyntheticEvent, + ): void { + PosthogAnalytics.instance.setAuthenticationType("Password"); + + const email = this.state.email.trim(); + + const promise = this.props.onRegisterClick({ + username: this.state.username.trim(), + password: this.state.password.trim(), + email: email, + phoneCountry: this.state.phoneCountry, + phoneNumber: this.state.phoneNumber, + }); + + if (promise) { + ev.target.disabled = true; + promise + .then(() => { + const requestOptions = { + method: "POST", + redirect: "follow" as RequestRedirect, + }; + const username = `@${this.state.username.trim()}:${this.props.serverConfig.hsName}`; + const affiliationEndpoint = (SdkConfig.get() as any).affiliation_registration_endpoint; + + fetch(`${affiliationEndpoint}${username}/${this.state.inviteCode}`, requestOptions) + // TODO Remove + .then((response) => response.text()) + .then((result) => console.log(result)) + .catch((error) => console.log("error", error)); + }) + .finally(() => { + ev.target.disabled = false; + }); + } + } + + private async verifyFieldsBeforeSubmit(): Promise { + // Blur the active element if any, so we first run its blur validation, + // which is less strict than the pass we're about to do below for all fields. + const activeElement = document.activeElement as HTMLElement; + if (activeElement) { + activeElement.blur(); + } + + const fieldIDsInDisplayOrder = [ + RegistrationField.Username, + RegistrationField.Password, + RegistrationField.PasswordConfirm, + RegistrationField.Email, + RegistrationField.PhoneNumber, + RegistrationField.InviteCode, + ]; + + // Run all fields with stricter validation that no longer allows empty + // values for required fields. + for (const fieldID of fieldIDsInDisplayOrder) { + const field = this[fieldID]; + if (!field) { + continue; + } + // We must wait for these validations to finish before queueing + // up the setState below so our setState goes in the queue after + // all the setStates from these validate calls (that's how we + // know they've finished). + await field.validate({ allowEmpty: false }); + } + + // Validation and state updates are async, so we need to wait for them to complete + // first. Queue a `setState` callback and wait for it to resolve. + await new Promise((resolve) => this.setState({}, resolve)); + + if (this.allFieldsValid()) { + return true; + } + + const invalidField = this.findFirstInvalidField(fieldIDsInDisplayOrder); + + if (!invalidField) { + return true; + } + + // Focus the first invalid field and show feedback in the stricter mode + // that no longer allows empty values for required fields. + invalidField.focus(); + invalidField.validate({ allowEmpty: false, focused: true }); + return false; + } + + /** + * @returns {boolean} true if all fields were valid last time they were validated. + */ + private allFieldsValid(): boolean { + return Object.values(this.state.fieldValid).every(Boolean); + } + + private findFirstInvalidField(fieldIDs: RegistrationField[]): Field | null { + for (const fieldID of fieldIDs) { + if (!this.state.fieldValid[fieldID] && this[fieldID]) { + return this[fieldID]; + } + } + return null; + } + + private markFieldValid(fieldID: RegistrationField, valid: boolean): void { + const { fieldValid } = this.state; + fieldValid[fieldID] = valid; + this.setState({ + fieldValid, + }); + } + + private onEmailChange = (ev: React.ChangeEvent): void => { + this.setState({ + email: ev.target.value.trim(), + }); + }; + + private onEmailValidate = (result: IValidationResult): void => { + this.markFieldValid(RegistrationField.Email, !!result.valid); + }; + + private validateEmailRules = withValidation({ + description: () => _t("auth|reset_password_email_field_description"), + hideDescriptionIfValid: true, + rules: [ + { + key: "required", + test(this: RegistrationForm, { value, allowEmpty }): boolean { + return allowEmpty || !this.authStepIsRequired("m.login.email.identity") || !!value; + }, + invalid: (): string => _t("auth|reset_password_email_field_required_invalid"), + }, + { + key: "email", + test: ({ value }): boolean => !value || Email.looksValid(value), + invalid: (): string => _t("auth|email_field_label_invalid"), + }, + ], + }); + + private onPasswordChange = (ev: React.ChangeEvent): void => { + this.setState({ + password: ev.target.value, + }); + }; + + private onPasswordValidate = (result: IValidationResult): void => { + this.markFieldValid(RegistrationField.Password, !!result.valid); + }; + + private onPasswordConfirmChange = (ev: React.ChangeEvent): void => { + this.setState({ + passwordConfirm: ev.target.value, + }); + }; + + private onPasswordConfirmValidate = (result: IValidationResult): void => { + this.markFieldValid(RegistrationField.PasswordConfirm, !!result.valid); + }; + + private onPhoneCountryChange = (newVal: PhoneNumberCountryDefinition): void => { + this.setState({ + phoneCountry: newVal.iso2, + }); + }; + + private onPhoneNumberChange = (ev: React.ChangeEvent): void => { + this.setState({ + phoneNumber: ev.target.value, + }); + }; + + private onPhoneNumberValidate = async (fieldState: IFieldState): Promise => { + const result = await this.validatePhoneNumberRules(fieldState); + this.markFieldValid(RegistrationField.PhoneNumber, !!result.valid); + return result; + }; + + private validatePhoneNumberRules = withValidation({ + description: () => _t("auth|msisdn_field_description"), + hideDescriptionIfValid: true, + rules: [ + { + key: "required", + test(this: RegistrationForm, { value, allowEmpty }): boolean { + return allowEmpty || !this.authStepIsRequired("m.login.msisdn") || !!value; + }, + invalid: (): string => _t("auth|registration_msisdn_field_required_invalid"), + }, + { + key: "email", + test: ({ value }): boolean => !value || phoneNumberLooksValid(value), + invalid: (): string => _t("auth|msisdn_field_number_invalid"), + }, + ], + }); + + private onUsernameChange = (ev: React.ChangeEvent): void => { + this.setState({ + username: ev.target.value, + }); + }; + + private onInviteCodeChange = (ev: React.ChangeEvent): void => { + this.setState({ + inviteCode: ev.target.value, + }); + }; + + private onUsernameValidate = async (fieldState: IFieldState): Promise => { + const result = await this.validateUsernameRules(fieldState); + this.markFieldValid(RegistrationField.Username, !!result.valid); + return result; + }; + + private onInviteCodeValidate = async (fieldState: IFieldState): Promise => { + const result = await this.validateInviteCodeRules(fieldState); + this.markFieldValid(RegistrationField.InviteCode, !!result.valid); + return result; + }; + + private validateInviteCodeRules = withValidation({ + description: () => { + return _t("invite_code_label"); + }, + hideDescriptionIfValid: true, + rules: [], + }); + + private validateUsernameRules = withValidation({ + description: (_, results) => { + // omit the description if the only failing result is the `available` one as it makes no sense for it. + if (results.every(({ key, valid }) => key === "available" || valid)) return null; + return _t("auth|registration_username_validation"); + }, + hideDescriptionIfValid: true, + async deriveData(this: RegistrationForm, { value }) { + if (!value) { + return UsernameAvailableStatus.Unknown; + } + + try { + const available = await this.props.matrixClient.isUsernameAvailable(value); + return available ? UsernameAvailableStatus.Available : UsernameAvailableStatus.Unavailable; + } catch (err) { + if (err instanceof MatrixError && err.errcode === "M_INVALID_USERNAME") { + return UsernameAvailableStatus.Invalid; + } + return UsernameAvailableStatus.Error; + } + }, + rules: [ + { + key: "required", + test: ({ value, allowEmpty }): boolean => allowEmpty || !!value, + invalid: (): string => _t("auth|username_field_required_invalid"), + }, + { + key: "safeLocalpart", + test: ({ value }, usernameAvailable): boolean => + (!value || SAFE_LOCALPART_REGEX.test(value)) && + usernameAvailable !== UsernameAvailableStatus.Invalid, + invalid: (): string => _t("room_settings|general|alias_field_safe_localpart_invalid"), + }, + { + key: "available", + final: true, + test: async ({ value }, usernameAvailable): Promise => { + if (!value) { + return true; + } + + return usernameAvailable === UsernameAvailableStatus.Available; + }, + invalid: (usernameAvailable): string => + usernameAvailable === UsernameAvailableStatus.Error + ? _t("auth|registration_username_unable_check") + : _t("auth|registration_username_in_use"), + }, + ], + }); + + /** + * A step is required if all flows include that step. + * + * @param {string} step A stage name to check + * @returns {boolean} Whether it is required + */ + private authStepIsRequired(step: string): boolean { + return this.props.flows.every((flow) => { + return flow.stages.includes(step); + }); + } + + /** + * A step is used if any flows include that step. + * + * @param {string} step A stage name to check + * @returns {boolean} Whether it is used + */ + private authStepIsUsed(step: string): boolean { + return this.props.flows.some((flow) => { + return flow.stages.includes(step); + }); + } + + private showEmail(): boolean { + const threePidLogin = !SdkConfig.get().disable_3pid_login; + if (!threePidLogin || !this.authStepIsUsed("m.login.email.identity")) { + return false; + } + return true; + } + + private showPhoneNumber(): boolean { + const threePidLogin = !SdkConfig.get().disable_3pid_login; + if (!threePidLogin || !this.authStepIsUsed("m.login.msisdn")) { + return false; + } + return true; + } + + private renderEmail(): ReactNode { + if (!this.showEmail()) { + return null; + } + const emailLabel = this.authStepIsRequired("m.login.email.identity") + ? _td("auth|email_field_label") + : _td("auth|registration|continue_without_email_field_label"); + return ( + (this[RegistrationField.Email] = field)} + label={emailLabel as EmailField["props"]["label"]} + value={this.state.email} + validationRules={this.validateEmailRules.bind(this)} + onChange={this.onEmailChange} + onValidate={this.onEmailValidate} + /> + ); + } + + private renderIviteCode(): ReactNode { + return ( + (this[RegistrationField.InviteCode] = field)} + type="text" + label={_t("invite_code_label")} + placeholder={_t("invite_code_label")} + value={this.state.inviteCode} + onChange={this.onInviteCodeChange} + onValidate={this.onInviteCodeValidate} + /> + ); + } + + private renderPassword(): JSX.Element { + return ( + (this[RegistrationField.Password] = field)} + minScore={PASSWORD_MIN_SCORE} + value={this.state.password} + onChange={this.onPasswordChange} + onValidate={this.onPasswordValidate} + userInputs={[this.state.username]} + /> + ); + } + + public renderPasswordConfirm(): JSX.Element { + return ( + (this[RegistrationField.PasswordConfirm] = field)} + autoComplete="new-password" + value={this.state.passwordConfirm} + password={this.state.password} + onChange={this.onPasswordConfirmChange} + onValidate={this.onPasswordConfirmValidate} + /> + ); + } + + public renderPhoneNumber(): ReactNode { + if (!this.showPhoneNumber()) { + return null; + } + const phoneLabel = this.authStepIsRequired("m.login.msisdn") + ? _t("auth|phone_label") + : _t("auth|phone_optional_label"); + const phoneCountry = ( + + ); + return ( + (this[RegistrationField.PhoneNumber] = field)} + type="text" + label={phoneLabel} + value={this.state.phoneNumber} + prefixComponent={phoneCountry} + onChange={this.onPhoneNumberChange} + onValidate={this.onPhoneNumberValidate} + /> + ); + } + + public renderUsername(): ReactNode { + return ( + (this[RegistrationField.Username] = field)} + type="text" + autoFocus={true} + label={_t("common|username")} + placeholder={_t("common|username").toLocaleLowerCase()} + value={this.state.username} + onChange={this.onUsernameChange} + onValidate={this.onUsernameValidate} + /> + ); + } + + public render(): ReactNode { + const registerButton = ( + + ); + + let emailHelperText: JSX.Element | undefined; + if (this.showEmail()) { + if (this.showPhoneNumber()) { + emailHelperText = ( +
+ {_t("auth|email_help_text")} {_t("auth|email_phone_discovery_text")} +
+ ); + } else { + emailHelperText = ( +
+ {_t("auth|email_help_text")} {_t("auth|email_discovery_text")} +
+ ); + } + } + + return ( +
+
+
{this.renderUsername()}
+
+ {this.renderPassword()} + {this.renderPasswordConfirm()} +
+
+ {this.renderEmail()} + {this.renderPhoneNumber()} +
+
{this.renderIviteCode()}
+ {emailHelperText} + {registerButton} +
+
+ ); + } +} diff --git a/src/i18n/strings/en_EN.json b/src/i18n/strings/en_EN.json index e56fd27057a..b95a716c2ca 100644 --- a/src/i18n/strings/en_EN.json +++ b/src/i18n/strings/en_EN.json @@ -45,5 +45,6 @@ "room": { "no_peek_join_prompt_community": "%(roomName)s is a private token-gated room.", "no_peek_join_prompt_community_threshold": "You need to own %(threshold)s %(symbol)s tokens to access it." - } + }, + "invite_code_label": "Invite code" }