* Introduce i18nContext * Adds a context that holds the module i1n API * Switches shared components to use that instead of importing it directly * Adds the context to MatrixChat and BaseDalog so it should be available most places in EW This is a relatively small PR but does change the way the shared components do i18n so just doing this one by itself (it stands by itself anyway). This will allow shared components to use i18n when used in modules. * Add the file * Fix import lint * Name the translate function _t Then it should continue to get picked up by the script This seems a bit flaky and ew but I'm not sure I want to get into changing this in this PR. * Put humanize back to calling something called _t too * Missed one * Add i18n context wrapper to stories * Unused import * Fix imports * wrap richitem * Wrap other richitem & richlist * One day I will get my head around this syntax * Fix import spacing * Add wrapper to test * unused import * Hack around dependency cycle * Make a moduleapi instance for tests * Add i18n wrapper to jest-matrix-react * Simple test for i18napi * Import type * Move i18n context wrapper to storybook template * Unused imports & fix pill story * Move i18n to its own provider * Add i18ncontext wrapper to jest tests * imports * Bump module api to 1.7.0 * tsdoc
2285 lines
95 KiB
TypeScript
2285 lines
95 KiB
TypeScript
/*
|
|
Copyright 2024 New Vector Ltd.
|
|
Copyright 2015-2024 The Matrix.org Foundation C.I.C.
|
|
|
|
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
|
|
Please see LICENSE files in the repository root for full details.
|
|
*/
|
|
|
|
import React, { type JSX, createRef, lazy } from "react";
|
|
import {
|
|
ClientEvent,
|
|
createClient,
|
|
EventType,
|
|
HttpApiEvent,
|
|
type MatrixClient,
|
|
MatrixEvent,
|
|
MsgType,
|
|
type RoomType,
|
|
SyncState,
|
|
type SyncStateData,
|
|
type TimelineEvents,
|
|
} from "matrix-js-sdk/src/matrix";
|
|
import { type QueryDict } from "matrix-js-sdk/src/utils";
|
|
import { logger } from "matrix-js-sdk/src/logger";
|
|
import { throttle } from "lodash";
|
|
import { CryptoEvent, type KeyBackupInfo } from "matrix-js-sdk/src/crypto-api";
|
|
import { TooltipProvider } from "@vector-im/compound-web";
|
|
// what-input helps improve keyboard accessibility
|
|
import "what-input";
|
|
import sanitizeHtml from "sanitize-html";
|
|
import { I18nContext } from "@element-hq/web-shared-components";
|
|
|
|
import PosthogTrackers from "../../PosthogTrackers";
|
|
import { DecryptionFailureTracker } from "../../DecryptionFailureTracker";
|
|
import { type IMatrixClientCreds, MatrixClientPeg } from "../../MatrixClientPeg";
|
|
import PlatformPeg from "../../PlatformPeg";
|
|
import SdkConfig, { type ConfigOptions } from "../../SdkConfig";
|
|
import dis from "../../dispatcher/dispatcher";
|
|
import Notifier from "../../Notifier";
|
|
import Modal from "../../Modal";
|
|
import { showRoomInviteDialog, showStartChatInviteDialog } from "../../RoomInvite";
|
|
import * as Rooms from "../../Rooms";
|
|
import * as Lifecycle from "../../Lifecycle";
|
|
// LifecycleStore is not used but does listen to and dispatch actions
|
|
import "../../stores/LifecycleStore";
|
|
import "../../stores/AutoRageshakeStore";
|
|
import PageType from "../../PageTypes";
|
|
import createRoom, { type IOpts } from "../../createRoom";
|
|
import { _t, _td } from "../../languageHandler";
|
|
import SettingsStore from "../../settings/SettingsStore";
|
|
import { startAnyRegistrationFlow } from "../../Registration";
|
|
import AutoDiscoveryUtils from "../../utils/AutoDiscoveryUtils";
|
|
import { calculateRoomVia, makeRoomPermalink } from "../../utils/permalinks/Permalinks";
|
|
import ThemeWatcher, { ThemeWatcherEvent } from "../../settings/watchers/ThemeWatcher";
|
|
import { FontWatcher } from "../../settings/watchers/FontWatcher";
|
|
import { storeRoomAliasInCache } from "../../RoomAliasCache";
|
|
import ToastStore from "../../stores/ToastStore";
|
|
import * as StorageManager from "../../utils/StorageManager";
|
|
import type LoggedInViewType from "./LoggedInView";
|
|
import LoggedInView from "./LoggedInView";
|
|
import { Action } from "../../dispatcher/actions";
|
|
import { hideToast as hideAnalyticsToast, showToast as showAnalyticsToast } from "../../toasts/AnalyticsToast";
|
|
import { showToast as showNotificationsToast } from "../../toasts/DesktopNotificationsToast";
|
|
import { type OpenToTabPayload } from "../../dispatcher/payloads/OpenToTabPayload";
|
|
import ErrorDialog from "../views/dialogs/ErrorDialog";
|
|
import {
|
|
RoomNotificationStateStore,
|
|
UPDATE_STATUS_INDICATOR,
|
|
} from "../../stores/notifications/RoomNotificationStateStore";
|
|
import { SettingLevel } from "../../settings/SettingLevel";
|
|
import ThreepidInviteStore, {
|
|
type IThreepidInvite,
|
|
type IThreepidInviteWireFormat,
|
|
} from "../../stores/ThreepidInviteStore";
|
|
import { UIFeature } from "../../settings/UIFeature";
|
|
import DialPadModal from "../views/voip/DialPadModal";
|
|
import { showToast as showMobileGuideToast } from "../../toasts/MobileGuideToast";
|
|
import { shouldUseLoginForWelcome } from "../../utils/pages";
|
|
import RoomListStore from "../../stores/room-list/RoomListStore";
|
|
import { RoomUpdateCause } from "../../stores/room-list/models";
|
|
import { ModuleRunner } from "../../modules/ModuleRunner";
|
|
import Spinner from "../views/elements/Spinner";
|
|
import QuestionDialog from "../views/dialogs/QuestionDialog";
|
|
import UserSettingsDialog from "../views/dialogs/UserSettingsDialog";
|
|
import CreateRoomDialog from "../views/dialogs/CreateRoomDialog";
|
|
import IncomingSasDialog from "../views/dialogs/IncomingSasDialog";
|
|
import CompleteSecurity from "./auth/CompleteSecurity";
|
|
import Welcome from "../views/auth/Welcome";
|
|
import ForgotPassword from "./auth/ForgotPassword";
|
|
import E2eSetup from "./auth/E2eSetup";
|
|
import Registration from "./auth/Registration";
|
|
import Login from "./auth/Login";
|
|
import ErrorBoundary from "../views/elements/ErrorBoundary";
|
|
import VerificationRequestToast from "../views/toasts/VerificationRequestToast";
|
|
import PerformanceMonitor, { PerformanceEntryNames } from "../../performance";
|
|
import UIStore, { UI_EVENTS } from "../../stores/UIStore";
|
|
import SoftLogout from "./auth/SoftLogout";
|
|
import { copyPlaintext } from "../../utils/strings";
|
|
import { PosthogAnalytics } from "../../PosthogAnalytics";
|
|
import { initSentry } from "../../sentry";
|
|
import LegacyCallHandler from "../../LegacyCallHandler";
|
|
import { showSpaceInvite } from "../../utils/space";
|
|
import { type ButtonEvent } from "../views/elements/AccessibleButton";
|
|
import { type ActionPayload } from "../../dispatcher/payloads";
|
|
import { type SummarizedNotificationState } from "../../stores/notifications/SummarizedNotificationState";
|
|
import Views from "../../Views";
|
|
import { type FocusNextType, type ViewRoomPayload } from "../../dispatcher/payloads/ViewRoomPayload";
|
|
import { type ViewHomePagePayload } from "../../dispatcher/payloads/ViewHomePagePayload";
|
|
import { type AfterLeaveRoomPayload } from "../../dispatcher/payloads/AfterLeaveRoomPayload";
|
|
import { type AfterForgetRoomPayload } from "../../dispatcher/payloads/AfterForgetRoomPayload";
|
|
import { type DoAfterSyncPreparedPayload } from "../../dispatcher/payloads/DoAfterSyncPreparedPayload";
|
|
import { type ViewStartChatOrReusePayload } from "../../dispatcher/payloads/ViewStartChatOrReusePayload";
|
|
import { leaveRoomBehaviour } from "../../utils/leave-behaviour";
|
|
import { CallStore } from "../../stores/CallStore";
|
|
import { type IRoomStateEventsActionPayload } from "../../actions/MatrixActionCreators";
|
|
import { type ShowThreadPayload } from "../../dispatcher/payloads/ShowThreadPayload";
|
|
import { RightPanelPhases } from "../../stores/right-panel/RightPanelStorePhases";
|
|
import RightPanelStore from "../../stores/right-panel/RightPanelStore";
|
|
import { TimelineRenderingType } from "../../contexts/RoomContext";
|
|
import { type ValidatedServerConfig } from "../../utils/ValidatedServerConfig";
|
|
import { isLocalRoom } from "../../utils/localRoom/isLocalRoom";
|
|
import { SDKContext, SdkContextClass } from "../../contexts/SDKContext";
|
|
import { viewUserDeviceSettings } from "../../actions/handlers/viewUserDeviceSettings";
|
|
import GenericToast from "../views/toasts/GenericToast";
|
|
import RovingSpotlightDialog from "../views/dialogs/spotlight/SpotlightDialog";
|
|
import { findDMForUser } from "../../utils/dm/findDMForUser";
|
|
import { getHtmlText, Linkify } from "../../HtmlUtils";
|
|
import { NotificationLevel } from "../../stores/notifications/NotificationLevel";
|
|
import { type UserTab } from "../views/dialogs/UserTab";
|
|
import { shouldSkipSetupEncryption } from "../../utils/crypto/shouldSkipSetupEncryption";
|
|
import { Filter } from "../views/dialogs/spotlight/Filter";
|
|
import { SessionLockStolenView } from "./auth/SessionLockStolenView";
|
|
import { ConfirmSessionLockTheftView } from "./auth/ConfirmSessionLockTheftView";
|
|
import { LoginSplashView } from "./auth/LoginSplashView";
|
|
import { cleanUpDraftsIfRequired } from "../../DraftCleaner";
|
|
import { InitialCryptoSetupStore } from "../../stores/InitialCryptoSetupStore";
|
|
import { setTheme } from "../../theme";
|
|
import { type OpenForwardDialogPayload } from "../../dispatcher/payloads/OpenForwardDialogPayload";
|
|
import { ShareFormat, type SharePayload } from "../../dispatcher/payloads/SharePayload";
|
|
import Markdown from "../../Markdown";
|
|
import { sanitizeHtmlParams } from "../../Linkify";
|
|
import { isOnlyAdmin } from "../../utils/membership";
|
|
import { ModuleApi } from "../../modules/Api.ts";
|
|
|
|
// legacy export
|
|
export { default as Views } from "../../Views";
|
|
|
|
const AUTH_SCREENS = ["register", "mobile_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, Action.CreateChat, Action.CreateRoom];
|
|
|
|
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.
|
|
*
|
|
* This represents the state of a state machine: see the documentation on {@link Views} for a transition diagram.
|
|
*
|
|
* TODO: this doesn't work well, because updates to React state are not instantaneous, meaning that if several
|
|
* events or {@link Action}s happen in quick succession, we may end up following the wrong transition.
|
|
* We should probably move the view into a separate object (like a ViewModel) and have the React state subscribe
|
|
* to updates.
|
|
*/
|
|
view: Views;
|
|
|
|
// What the LoggedInView would be showing if visible.
|
|
// A member of the enum for standard pages or a string for those provided by
|
|
// a module.
|
|
// eslint-disable-next-line camelcase
|
|
page_type?: PageType | string;
|
|
// 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;
|
|
isMobileRegistration?: boolean;
|
|
// 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;
|
|
serverConfig?: ValidatedServerConfig;
|
|
|
|
/** Has our MatrixClient started? */
|
|
ready: boolean;
|
|
|
|
threepidInvite?: IThreepidInvite;
|
|
roomOobData?: object;
|
|
pendingInitialSync?: boolean;
|
|
justRegistered?: boolean;
|
|
roomJustCreatedOpts?: IOpts;
|
|
forceTimeline?: boolean; // see props
|
|
}
|
|
|
|
export default class MatrixChat extends React.PureComponent<IProps, IState> {
|
|
public static displayName = "MatrixChat";
|
|
|
|
public static defaultProps = {
|
|
realQueryParams: {},
|
|
startingFragmentQueryParams: {},
|
|
config: {},
|
|
onTokenLoginCompleted: (): void => {},
|
|
};
|
|
|
|
private firstSyncComplete = false;
|
|
private firstSyncPromise: PromiseWithResolvers<void>;
|
|
|
|
private screenAfterLogin?: IScreen;
|
|
|
|
/** True if we have successfully completed an OIDC or token login.
|
|
*
|
|
* XXX it's unclear if this is ever cleared, so what happens if the user logs out and then logs back in?
|
|
*/
|
|
private tokenLogin?: boolean;
|
|
|
|
// What to focus on next component update, if anything
|
|
private focusNext: FocusNextType;
|
|
private subTitleStatus: string;
|
|
private prevWindowWidth: number;
|
|
|
|
private readonly loggedInView = createRef<LoggedInViewType>();
|
|
private dispatcherRef?: string;
|
|
private themeWatcher?: ThemeWatcher;
|
|
private fontWatcher?: FontWatcher;
|
|
private readonly stores: SdkContextClass;
|
|
private loadSessionAbortController = new AbortController();
|
|
|
|
private sessionLoadStarted = false;
|
|
|
|
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,
|
|
isMobileRegistration: false,
|
|
|
|
syncError: null, // If the current syncing status is ERROR, the error object, otherwise null.
|
|
ready: false,
|
|
};
|
|
|
|
SdkConfig.put(this.props.config);
|
|
|
|
// Used by _viewRoom before getting state from sync
|
|
this.firstSyncComplete = false;
|
|
this.firstSyncPromise = Promise.withResolvers();
|
|
|
|
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;
|
|
|
|
// 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 = "";
|
|
}
|
|
|
|
/**
|
|
* Kick off a call to {@link initSession}, and handle any errors
|
|
*/
|
|
private startInitSession = (): void => {
|
|
const initProm = this.initSession();
|
|
|
|
initProm.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<void> {
|
|
// 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).
|
|
const platform = PlatformPeg.get();
|
|
if (platform && !(await platform.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({ abortSignal: this.loadSessionAbortController.signal });
|
|
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.restoreSessionFromStorage({ 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<void> {
|
|
// 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<void>((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();
|
|
}
|
|
|
|
/**
|
|
* Perform actions that are specific to a user that has just logged in.
|
|
*
|
|
* Called when:
|
|
*
|
|
* - We successfully completed an OIDC or token login, via {@link initSession}.
|
|
* - The {@link Login} or {@link Register} components notify us that we successfully completed a non-OIDC login or
|
|
* registration.
|
|
*
|
|
* In both cases, {@link Action.OnLoggedIn} will already have been emitted, but the call to {@link onShowPostLoginScreen} will
|
|
* have been suppressed (by either {@link tokenLogin} being set, or the view being set to {@link Views.LOGIN} or
|
|
* {@link Views.REGISTER}).
|
|
*
|
|
* {@link onWillStartClient} and {@link onClientStarted} will already have been called (but not necessarily
|
|
* completed).
|
|
*
|
|
* This method either calls {@link onLiggedIn} directly, or switches to {@link Views.E2E_SETUP} or
|
|
* {@link Views.COMPLETE_SECURITY}, which will later call {@link onCompleteSecurityE2eSetupFinished}.
|
|
*/
|
|
private async postLoginSetup(): Promise<void> {
|
|
const cli = MatrixClientPeg.safeGet();
|
|
const cryptoEnabled = Boolean(cli.getCrypto());
|
|
if (!cryptoEnabled) {
|
|
this.onShowPostLoginScreen();
|
|
}
|
|
|
|
const promisesList: Promise<any>[] = [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<void> => {
|
|
crossSigningIsSetUp = Boolean(await cli.getCrypto()?.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.
|
|
|
|
const cryptoExtension = ModuleRunner.instance.extensions.cryptoSetup;
|
|
if (cryptoExtension.SHOW_ENCRYPTION_SETUP_UI == false) {
|
|
this.onShowPostLoginScreen();
|
|
} else {
|
|
this.setStateForNewView({ view: Views.COMPLETE_SECURITY });
|
|
}
|
|
} else if (!(await shouldSkipSetupEncryption(cli))) {
|
|
// if cross-signing is not yet set up, do so now if possible.
|
|
InitialCryptoSetupStore.sharedInstance().startInitialCryptoSetup(
|
|
cli,
|
|
this.onCompleteSecurityE2eSetupFinished,
|
|
);
|
|
this.setStateForNewView({ view: Views.E2E_SETUP });
|
|
} else {
|
|
this.onShowPostLoginScreen();
|
|
}
|
|
this.setState({ pendingInitialSync: false });
|
|
}
|
|
|
|
public setState<K extends keyof IState>(
|
|
state:
|
|
| ((prevState: Readonly<IState>, props: Readonly<IProps>) => Pick<IState, K> | IState | null)
|
|
| (Pick<IState, K> | IState | null),
|
|
callback?: () => void,
|
|
): void {
|
|
if (state && "view" in state) {
|
|
logger.debug(`MatrixChat: Queuing change of view from ${Views[this.state.view]} to ${Views[state.view]}`);
|
|
}
|
|
if (this.shouldTrackPageChange(this.state, { ...this.state, ...state })) {
|
|
this.startPageChangeTimer();
|
|
}
|
|
super.setState<K>(state, callback);
|
|
}
|
|
|
|
public componentDidMount(): void {
|
|
UIStore.instance.on(UI_EVENTS.Resize, this.handleResize);
|
|
|
|
// For PersistentElement
|
|
this.stores.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.themeWatcher.on(ThemeWatcherEvent.Change, setTheme);
|
|
this.fontWatcher.start();
|
|
|
|
initSentry(SdkConfig.get("sentry"));
|
|
window.addEventListener("resize", this.onWindowResized);
|
|
|
|
// Once we start loading the MatrixClient, we can't stop, even if MatrixChat gets unmounted (as it does
|
|
// in React's Strict Mode). So, start loading the session now, but only if this MatrixChat was not previously
|
|
// mounted.
|
|
if (!this.sessionLoadStarted) {
|
|
this.sessionLoadStarted = true;
|
|
const platform = PlatformPeg.get();
|
|
if (platform && !platform.checkSessionLockFree()) {
|
|
// another instance holds the lock; confirm its theft before proceeding
|
|
setTimeout(() => this.setState({ view: Views.CONFIRM_LOCK_THEFT }), 0);
|
|
} else {
|
|
this.startInitSession();
|
|
}
|
|
}
|
|
}
|
|
|
|
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.focusNext === "composer") {
|
|
dis.fire(Action.FocusSendMessageComposer);
|
|
this.focusNext = undefined;
|
|
} else if (this.focusNext === "threadsPanel") {
|
|
dis.fire(Action.FocusThreadsPanel);
|
|
}
|
|
}
|
|
|
|
public componentWillUnmount(): void {
|
|
Lifecycle.stopMatrixClient();
|
|
dis.unregister(this.dispatcherRef);
|
|
this.themeWatcher?.off(ThemeWatcherEvent.Change, setTheme);
|
|
this.themeWatcher?.stop();
|
|
this.fontWatcher?.stop();
|
|
UIStore.destroy();
|
|
this.stores.resizeNotifier.removeListener("middlePanelResized", this.dispatchTimelineResize);
|
|
window.removeEventListener("resize", this.onWindowResized);
|
|
}
|
|
|
|
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<boolean> {
|
|
// 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,
|
|
abortSignal: this.loadSessionAbortController.signal,
|
|
});
|
|
})
|
|
.then((loadedSession) => {
|
|
if (!loadedSession) {
|
|
// fall back to showing the welcome screen... unless we have a 3pid invite pending
|
|
if (
|
|
ThreepidInviteStore.instance.pickBestInvite() &&
|
|
SettingsStore.getValue(UIFeature.Registration)
|
|
) {
|
|
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 isLoggedInViewPageDisplayed(): boolean {
|
|
return this.loggedInView.current !== null && this.state.page_type !== undefined;
|
|
}
|
|
|
|
private setStateForNewView(state: Partial<IState>): 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) {
|
|
logger.warn(`MatrixChat: ignoring action ${payload.action} as session lock has been stolen`);
|
|
return;
|
|
}
|
|
|
|
// Exclude some rather spammy actions from being logged.
|
|
if (payload.action != Action.UserActivity) {
|
|
logger.debug(`MatrixChat: handling action ${payload.action}`);
|
|
}
|
|
|
|
// Start the onboarding process for certain actions
|
|
if (
|
|
MatrixClientPeg.get()?.isGuest() &&
|
|
ONBOARDING_FLOW_STARTERS.includes(payload.action as unknown as 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.connectedCalls].map((call) => call.disconnect())]).finally(() =>
|
|
Lifecycle.logout(this.stores.oidcClientStore),
|
|
);
|
|
break;
|
|
case "require_registration":
|
|
startAnyRegistrationFlow(payload as any);
|
|
break;
|
|
case "start_mobile_registration":
|
|
this.startRegistration(payload.params || {}, true);
|
|
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 "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,
|
|
{ ...payload.props, 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 Action.CreateRoom:
|
|
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.Share:
|
|
this.viewShare(payload.format, payload.msg);
|
|
break;
|
|
case Action.ViewStartChatOrReuse:
|
|
this.chatCreateOrReuse(payload.user_id);
|
|
break;
|
|
case Action.CreateChat:
|
|
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 "hide_left_panel":
|
|
this.setState(
|
|
{
|
|
collapseLhs: true,
|
|
},
|
|
() => {
|
|
this.stores.resizeNotifier.notifyLeftHandleResized();
|
|
},
|
|
);
|
|
break;
|
|
case "show_left_panel":
|
|
this.setState(
|
|
{
|
|
collapseLhs: false,
|
|
},
|
|
() => {
|
|
this.stores.resizeNotifier.notifyLeftHandleResized();
|
|
},
|
|
);
|
|
break;
|
|
case Action.OpenDialPad:
|
|
Modal.createDialog(DialPadModal, {}, "mx_Dialog_dialPadWrapper");
|
|
break;
|
|
case Action.OnLoggedIn:
|
|
this.onLoggedIn();
|
|
break;
|
|
case Action.ClientNotViable:
|
|
this.onSoftLogout();
|
|
break;
|
|
case Action.OnLoggedOut:
|
|
this.onLoggedOut();
|
|
break;
|
|
case Action.WillStartClient:
|
|
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 Action.ClientStarted:
|
|
// No need to make this handler async to wait for the result of this
|
|
this.onClientStarted().catch((e) => {
|
|
logger.error("Exception in onClientStarted", e);
|
|
});
|
|
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 }, isMobileRegistration?: boolean): Promise<void> {
|
|
// If registration is disabled or mobile registration is requested but not enabled in settings redirect to the welcome screen
|
|
if (
|
|
!SettingsStore.getValue(UIFeature.Registration) ||
|
|
(isMobileRegistration && !SettingsStore.getValue("Registration.mobileRegistrationHelper"))
|
|
) {
|
|
this.showScreen("welcome");
|
|
return;
|
|
}
|
|
|
|
const newState: Partial<IState> = {
|
|
view: Views.REGISTER,
|
|
};
|
|
|
|
if (isMobileRegistration && params.hs_url) {
|
|
try {
|
|
const config = await AutoDiscoveryUtils.validateServerConfigWithStaticUrls(params.hs_url);
|
|
newState.serverConfig = config;
|
|
} catch {
|
|
logger.warn("Failed to load hs_url param:", params.hs_url);
|
|
}
|
|
} else if (params.client_secret && params.session_id && params.hs_url && params.is_url && params.sid) {
|
|
// Only honour params if they are all present, otherwise we reset
|
|
// HS and IS URLs when switching to registration.
|
|
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;
|
|
}
|
|
|
|
newState.isMobileRegistration = isMobileRegistration;
|
|
|
|
this.setStateForNewView(newState);
|
|
this.notifyNewScreen(isMobileRegistration ? "mobile_register" : "register");
|
|
}
|
|
|
|
// switch view to the given room
|
|
private async viewRoom(roomInfo: ViewRoomPayload): Promise<void> {
|
|
this.focusNext = roomInfo.focusNext ?? "composer";
|
|
|
|
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) {
|
|
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, calculateRoomVia(room));
|
|
}
|
|
|
|
// 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,
|
|
},
|
|
() => {
|
|
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.isLoggedInViewPageDisplayed()) {
|
|
this.viewHome();
|
|
}
|
|
}
|
|
|
|
private viewWelcome(): void {
|
|
if (shouldUseLoginForWelcome(SdkConfig.get())) {
|
|
return this.viewLogin();
|
|
}
|
|
this.setStateForNewView({
|
|
view: Views.WELCOME,
|
|
});
|
|
this.notifyNewScreen("welcome");
|
|
}
|
|
|
|
private viewLogin(otherState?: any): void {
|
|
this.setStateForNewView({
|
|
view: Views.LOGIN,
|
|
...otherState,
|
|
});
|
|
this.notifyNewScreen("login");
|
|
}
|
|
|
|
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");
|
|
}
|
|
|
|
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
|
|
this.firstSyncPromise.promise.then(() => {
|
|
if (subAction === "chat") {
|
|
this.chatCreateOrReuse(userId);
|
|
return;
|
|
}
|
|
this.notifyNewScreen("user/" + userId);
|
|
this.setState({ currentUserId: userId });
|
|
this.setPage(PageType.UserView);
|
|
});
|
|
}
|
|
|
|
private viewShare(format: ShareFormat, msg: string): void {
|
|
// Wait for the first sync so we can present possible rooms to share into
|
|
this.firstSyncPromise.promise.then(() => {
|
|
this.notifyNewScreen("share");
|
|
let rawEvent;
|
|
switch (format) {
|
|
case ShareFormat.Html: {
|
|
rawEvent = {
|
|
type: "m.room.message",
|
|
content: {
|
|
msgtype: MsgType.Text,
|
|
body: getHtmlText(msg),
|
|
format: "org.matrix.custom.html",
|
|
formatted_body: sanitizeHtml(msg, sanitizeHtmlParams),
|
|
},
|
|
origin_server_ts: Date.now(),
|
|
};
|
|
break;
|
|
}
|
|
case ShareFormat.Markdown: {
|
|
const html = new Markdown(msg).toHTML({ externalLinks: true });
|
|
rawEvent = {
|
|
type: "m.room.message",
|
|
content: {
|
|
msgtype: MsgType.Text,
|
|
body: msg,
|
|
format: "org.matrix.custom.html",
|
|
formatted_body: html,
|
|
},
|
|
origin_server_ts: Date.now(),
|
|
};
|
|
break;
|
|
}
|
|
default:
|
|
rawEvent = {
|
|
type: "m.room.message",
|
|
content: {
|
|
msgtype: MsgType.Text,
|
|
body: msg,
|
|
},
|
|
origin_server_ts: Date.now(),
|
|
};
|
|
}
|
|
const event = new MatrixEvent(rawEvent);
|
|
dis.dispatch<OpenForwardDialogPayload>({
|
|
action: Action.OpenForwardDialog,
|
|
event: event,
|
|
permalinkCreator: null,
|
|
});
|
|
});
|
|
}
|
|
|
|
private async createRoom(defaultPublic = false, defaultName?: string, type?: RoomType): Promise<void> {
|
|
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 {
|
|
// Use a deferred action to reshow the dialog once the user has registered
|
|
if (MatrixClientPeg.safeGet().isGuest()) {
|
|
dis.dispatch<DoAfterSyncPreparedPayload<ViewStartChatOrReusePayload>>({
|
|
action: Action.DoAfterSyncPrepared,
|
|
deferred_action: {
|
|
action: Action.ViewStartChatOrReuse,
|
|
user_id: userId,
|
|
},
|
|
});
|
|
return;
|
|
}
|
|
|
|
// TODO: Immutable DMs replaces this
|
|
|
|
const client = MatrixClientPeg.safeGet();
|
|
const dmRoom = findDMForUser(client, userId);
|
|
|
|
if (dmRoom) {
|
|
dis.dispatch<ViewRoomPayload>({
|
|
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(
|
|
<strong className="warning" key="only_member_warning">
|
|
{" " /* Whitespace, otherwise the sentences get smashed together */}
|
|
{_t("leave_room_dialog|last_person_warning")}
|
|
</strong>,
|
|
);
|
|
|
|
return warnings;
|
|
}
|
|
|
|
const joinRules = roomToLeave?.currentState.getStateEvents("m.room.join_rules", "");
|
|
if (joinRules) {
|
|
const rule = joinRules.getContent().join_rule;
|
|
if (rule !== "public") {
|
|
warnings.push(
|
|
<strong className="warning" key="non_public_warning">
|
|
{" " /* Whitespace, otherwise the sentences get smashed together */}
|
|
{isSpace
|
|
? _t("leave_room_dialog|space_rejoin_warning")
|
|
: _t("leave_room_dialog|room_rejoin_warning")}
|
|
</strong>,
|
|
);
|
|
}
|
|
}
|
|
|
|
const client = MatrixClientPeg.get();
|
|
if (client && roomToLeave) {
|
|
// If the user is the only user with highest power level
|
|
if (isOnlyAdmin(roomToLeave)) {
|
|
const userLevelValues = roomToLeave.getJoinedMembers().map((m) => m.powerLevel);
|
|
|
|
const maxUserLevel = Math.max(...(userLevelValues as number[]));
|
|
|
|
const warning =
|
|
maxUserLevel >= 100
|
|
? _t("leave_room_dialog|room_leave_admin_warning")
|
|
: _t("leave_room_dialog|room_leave_mod_warning");
|
|
warnings.push(
|
|
<strong className="warning" key="last_admin_warning">
|
|
{" " /* Whitespace, otherwise the sentences get smashed together */}
|
|
{warning}
|
|
</strong>,
|
|
);
|
|
}
|
|
}
|
|
|
|
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();
|
|
const { finished } = Modal.createDialog(QuestionDialog, {
|
|
title: isSpace ? _t("space|leave_dialog_action") : _t("action|leave_room"),
|
|
description: (
|
|
<span>
|
|
{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}
|
|
</span>
|
|
),
|
|
button: _t("action|leave"),
|
|
danger: warnings.length > 0,
|
|
});
|
|
|
|
finished.then(async ([shouldLeave]) => {
|
|
if (shouldLeave) {
|
|
await leaveRoomBehaviour(cli, roomId);
|
|
|
|
dis.dispatch<AfterLeaveRoomPayload>({
|
|
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 });
|
|
}
|
|
|
|
if (room) {
|
|
// Legacy room list store needs to be told to manually remove this room
|
|
RoomListStore.instance.manualRoomUpdate(room, RoomUpdateCause.RoomRemoved);
|
|
// New room list store will remove the room on the following dispatch
|
|
dis.dispatch<AfterForgetRoomPayload>({ action: Action.AfterForgetRoom, room });
|
|
}
|
|
})
|
|
.catch((err) => {
|
|
const errCode = err.errcode || _td("error|unknown_error_code");
|
|
Modal.createDialog(ErrorDialog, {
|
|
title: _t("error_dialog|forget_room_failed", { errCode }),
|
|
description: err?.message ?? _t("invite|failed_generic"),
|
|
});
|
|
});
|
|
}
|
|
|
|
private async copyRoom(roomId: string): Promise<void> {
|
|
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"),
|
|
});
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Returns true if the user must go through the device verification process before they
|
|
* can use the app.
|
|
* @returns true if the user must verify
|
|
*/
|
|
private async shouldForceVerification(): Promise<boolean> {
|
|
if (!SdkConfig.get("force_verification")) return false;
|
|
const mustVerifyFlag = localStorage.getItem("must_verify_device");
|
|
if (!mustVerifyFlag) return false;
|
|
|
|
const client = MatrixClientPeg.safeGet();
|
|
if (client.isGuest()) return false;
|
|
|
|
const crypto = client.getCrypto();
|
|
const crossSigningReady = await crypto?.isCrossSigningReady();
|
|
|
|
return !crossSigningReady;
|
|
}
|
|
|
|
/**
|
|
* Show the first screen after the application is successfully loaded in a logged-in state.
|
|
*
|
|
* Called:
|
|
*
|
|
* - on {@link Action.OnLoggedIn}, but only when we don't expect a separate call to {@link postLoginSetup}.
|
|
* - from {@link postLoginSetup}, when we don't have crypto setup tasks to perform after the login.
|
|
* - by {@link onCompleteSecurityE2eSetupFinished}
|
|
*
|
|
* In other words, whenever we think we have completed the login and E2E setup tasks.
|
|
*/
|
|
private onShowPostLoginScreen(): void {
|
|
logger.debug("onShowPostLoginScreen: Transitioning to logged in view.");
|
|
|
|
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) {
|
|
logger.debug(`onShowPostLoginScreen: showing screen ${this.screenAfterLogin.screen}`);
|
|
this.showScreen(this.screenAfterLogin.screen, this.screenAfterLogin.params);
|
|
this.screenAfterLogin = undefined;
|
|
} else if (MatrixClientPeg.currentUserIsJustRegistered()) {
|
|
MatrixClientPeg.setJustRegisteredUserId(null);
|
|
|
|
if (ThreepidInviteStore.instance.pickBestInvite()) {
|
|
// The user has a 3pid invite pending - show them that
|
|
const threepidInvite = ThreepidInviteStore.instance.pickBestInvite();
|
|
logger.debug(`onShowPostLoginScreen: showing room ${threepidInvite.roomId} after registration`);
|
|
|
|
// 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.
|
|
logger.debug("onShowPostLoginScreen: Showing home page after registration");
|
|
dis.dispatch<ViewHomePagePayload>({ action: Action.ViewHomePage, justRegistered: true });
|
|
}
|
|
} else {
|
|
logger.debug("onShowPostLoginScreen: showScreenAfterLogin");
|
|
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: <Linkify>{userNotice.description}</Linkify>,
|
|
primaryLabel: _t("action|ok"),
|
|
onPrimaryClick: () => {
|
|
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) {
|
|
logger.debug(`showScreenAfterLogin: showing screen ${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
|
|
logger.debug("showScreenAfterLogin: showing last room");
|
|
this.viewLastRoom();
|
|
} else {
|
|
if (MatrixClientPeg.safeGet().isGuest()) {
|
|
logger.debug("showScreenAfterLogin: showing guest welcome page");
|
|
dis.dispatch({ action: "view_welcome_page" });
|
|
} else {
|
|
logger.debug("showScreenAfterLogin: showing home page");
|
|
dis.dispatch({ action: Action.ViewHomePage });
|
|
}
|
|
}
|
|
}
|
|
|
|
private viewLastRoom(): void {
|
|
dis.dispatch<ViewRoomPayload>({
|
|
action: Action.ViewRoom,
|
|
room_id: localStorage.getItem("mx_last_room_id") ?? undefined,
|
|
metricsTrigger: undefined, // other
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Handle an {@link Action.OnLoggedIn} action (i.e, we now have a client with working credentials).
|
|
*/
|
|
private onLoggedIn(): void {
|
|
this.stores.client = MatrixClientPeg.safeGet();
|
|
StorageManager.tryPersistStorage();
|
|
|
|
// If we're loading the app for the first time, we can now transition to a splash screen while we wait for the
|
|
// client to start. The exceptions are:
|
|
//
|
|
// - If there is a token login in flight: in that case we wait for the login to complete (which hits
|
|
// `postLoginSetup`).
|
|
//
|
|
// - Lifecycle emits an `Action.OnLoggedIn` event during startup even if the localstorage flag indicating a
|
|
// previous soft logout is set. In that situation we actually want to wait for the `Action.ClientNotViable`
|
|
// event, which will transition us into Views.SOFT_LOGOUT. We therefore have to check for !isSoftLogout().
|
|
// There will be a subsequent `Action.OnLoggedIn` event once the reauthentication completes.
|
|
//
|
|
// XXX: fix this properly by having Lifecycle not emit OnLoggedIn when it knows it is about to emit a
|
|
// ClientNotViable.
|
|
//
|
|
// If we're already in the SOFT_LOGOUT view, that means that reauthentication has succeeded, and we can
|
|
// transition to the splash screen.
|
|
if (
|
|
(this.state.view === Views.LOADING && !Lifecycle.isSoftLogout() && !this.tokenLogin) ||
|
|
this.state.view === Views.SOFT_LOGOUT
|
|
) {
|
|
this.setStateForNewView({ view: Views.PENDING_CLIENT_START });
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 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
|
|
// We resolve the existing promise with the new one to update any existing listeners
|
|
if (!this.firstSyncComplete) {
|
|
const firstSyncPromise = Promise.withResolvers<void>();
|
|
this.firstSyncPromise.resolve(firstSyncPromise.promise);
|
|
this.firstSyncPromise = firstSyncPromise;
|
|
} else {
|
|
this.firstSyncPromise = Promise.withResolvers();
|
|
}
|
|
this.firstSyncComplete = false;
|
|
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) {
|
|
this.setState({ syncError: data?.error ?? null });
|
|
} else if (this.state.syncError) {
|
|
this.setState({ syncError: null });
|
|
}
|
|
|
|
if (state === SyncState.Syncing && prevState === SyncState.Syncing) {
|
|
// We know we have performabed a live update and known rooms should be in a good state.
|
|
// Now is a good time to clean up drafts.
|
|
cleanUpDraftsIfRequired();
|
|
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);
|
|
});
|
|
|
|
cli.on(HttpApiEvent.SessionLoggedOut, (errObj) => {
|
|
this.loadSessionAbortController.abort(errObj);
|
|
this.loadSessionAbortController = new AbortController();
|
|
|
|
if (Lifecycle.isLoggingOut()) return;
|
|
|
|
// A modal might have been open when we were logged out by the server
|
|
Modal.forceCloseAllModals();
|
|
|
|
if (errObj.httpStatus === 401 && errObj.data?.["soft_logout"]) {
|
|
logger.warn("Soft logout issued by server - avoiding data deletion");
|
|
Lifecycle.softLogout();
|
|
return;
|
|
}
|
|
|
|
dis.dispatch(
|
|
{
|
|
action: "logout",
|
|
},
|
|
true,
|
|
);
|
|
|
|
// The above dispatch closes all modals, so open the modal after calling it synchronously
|
|
Modal.createDialog(ErrorDialog, {
|
|
title: _t("auth|session_logged_out_title"),
|
|
description: _t("auth|session_logged_out_description"),
|
|
});
|
|
});
|
|
cli.on(HttpApiEvent.NoConsent, function (message, consentUri) {
|
|
const { finished } = Modal.createDialog(
|
|
QuestionDialog,
|
|
{
|
|
title: _t("terms|tac_title"),
|
|
description: (
|
|
<div>
|
|
<p> {_t("terms|tac_description", { homeserverDomain: cli.getDomain() })}</p>
|
|
</div>
|
|
),
|
|
button: _t("terms|tac_button"),
|
|
cancelButton: _t("action|dismiss"),
|
|
},
|
|
undefined,
|
|
true,
|
|
);
|
|
finished.then(([confirmed]) => {
|
|
if (confirmed) {
|
|
const wnd = window.open(consentUri, "_blank")!;
|
|
wnd.opener = null;
|
|
}
|
|
});
|
|
});
|
|
|
|
DecryptionFailureTracker.instance
|
|
.start(cli)
|
|
.catch((e) => logger.error("Unable to start DecryptionFailureTracker", e));
|
|
|
|
cli.on(ClientEvent.Room, (room) => {
|
|
if (cli.getCrypto()) {
|
|
const blacklistEnabled = SettingsStore.getValueAt(
|
|
SettingLevel.ROOM_DEVICE,
|
|
"blacklistUnverifiedDevices",
|
|
room.roomId,
|
|
/*explicit=*/ true,
|
|
);
|
|
room.setBlacklistUnverifiedDevices(blacklistEnabled);
|
|
}
|
|
});
|
|
cli.on(CryptoEvent.KeyBackupFailed, async (errcode): Promise<void> => {
|
|
let haveNewVersion: boolean | undefined;
|
|
let newVersionInfo: KeyBackupInfo | null = null;
|
|
const keyBackupEnabled = Boolean(
|
|
cli.getCrypto() && (await cli.getCrypto()?.getActiveSessionBackupVersion()) !== null,
|
|
);
|
|
|
|
// if key backup is still enabled, there must be a new backup in place
|
|
if (keyBackupEnabled) {
|
|
haveNewVersion = true;
|
|
} else {
|
|
// otherwise check the server to see if there's a new one
|
|
try {
|
|
newVersionInfo = (await cli.getCrypto()?.getKeyBackupInfo()) ?? null;
|
|
if (newVersionInfo !== null) haveNewVersion = true;
|
|
} catch (e) {
|
|
logger.error("Saw key backup error but failed to check backup version!", e);
|
|
return;
|
|
}
|
|
}
|
|
|
|
if (haveNewVersion) {
|
|
Modal.createDialog(
|
|
lazy(() => import("../../async-components/views/dialogs/security/NewRecoveryMethodDialog")),
|
|
);
|
|
} else {
|
|
Modal.createDialog(
|
|
lazy(() => import("../../async-components/views/dialogs/security/RecoveryMethodRemovedDialog")),
|
|
);
|
|
}
|
|
});
|
|
|
|
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,
|
|
});
|
|
}
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Called shortly after the matrix client has started. Useful for
|
|
* setting up anything that requires the client to be started.
|
|
* @private
|
|
*/
|
|
private async onClientStarted(): Promise<void> {
|
|
const cli = MatrixClientPeg.safeGet();
|
|
|
|
const shouldForceVerification = await this.shouldForceVerification();
|
|
|
|
const crypto = cli.getCrypto();
|
|
if (crypto) {
|
|
const blacklistEnabled = SettingsStore.getValueAt(SettingLevel.DEVICE, "blacklistUnverifiedDevices");
|
|
crypto.globalBlacklistUnverifiedDevices = blacklistEnabled;
|
|
}
|
|
|
|
// 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();
|
|
}
|
|
|
|
this.setState({
|
|
ready: true,
|
|
});
|
|
|
|
// If the view is PENDING_CLIENT_START, that means we recovered the session from localstorage, or from
|
|
// soft-logout: we can now transition to the logged-in view.
|
|
//
|
|
// If the view is something else, that probably means it's a login or registration view; we handle that in
|
|
// `postLoginSetup`.
|
|
if (this.state.view === Views.PENDING_CLIENT_START) {
|
|
if (shouldForceVerification) {
|
|
this.setStateForNewView({ view: Views.COMPLETE_SECURITY });
|
|
} else {
|
|
this.onShowPostLoginScreen();
|
|
}
|
|
}
|
|
}
|
|
|
|
public showScreen(screen: string, params?: { [key: string]: any }): void {
|
|
logger.debug(`showScreen ${screen}`);
|
|
|
|
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
|
|
logger.info(
|
|
`showScreen: suppressing change to AuthScreen ${screen} for logged-in user, and going to home screen 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 === "mobile_register") {
|
|
dis.dispatch({
|
|
action: "start_mobile_registration",
|
|
params: params,
|
|
});
|
|
} 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: Action.CreateRoom,
|
|
});
|
|
} else if (screen === "dm") {
|
|
dis.dispatch({
|
|
action: Action.CreateChat,
|
|
});
|
|
} else if (screen === "share") {
|
|
if (params && params["msg"] !== undefined) {
|
|
dis.dispatch<SharePayload>({
|
|
action: Action.Share,
|
|
msg: params["msg"],
|
|
format: params["format"],
|
|
});
|
|
}
|
|
// if we weren't already coming at this from an existing screen
|
|
// and we're logged in, then explicitly default to home.
|
|
// if we're not logged in, then the login flow will do the right thing.
|
|
if (!this.state.currentRoomId && !this.state.currentUserId && !this.isLoggedInViewPageDisplayed()) {
|
|
this.viewHome();
|
|
}
|
|
} 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 === "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 if (ModuleApi.instance.navigation.locationRenderers.get(screen)) {
|
|
this.setState({ page_type: 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.stores.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): Promise<void> => {
|
|
return this.onUserCompletedLoginFlow(credentials);
|
|
};
|
|
|
|
// returns a promise which resolves to the new MatrixClient
|
|
private onRegistered(credentials: IMatrixClientCreds): Promise<MatrixClient> {
|
|
return Lifecycle.setLoggedIn(credentials);
|
|
}
|
|
|
|
private onSendEvent(roomId: string, event: MatrixEvent): void {
|
|
const cli = MatrixClientPeg.get();
|
|
if (!cli) return;
|
|
|
|
cli.sendEvent(roomId, event.getType() as keyof TimelineEvents, 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): Promise<void> => {
|
|
// Create and start the client
|
|
await Lifecycle.setLoggedIn(credentials);
|
|
await this.postLoginSetup();
|
|
|
|
PerformanceMonitor.instance.stop(PerformanceEntryNames.LOGIN);
|
|
PerformanceMonitor.instance.stop(PerformanceEntryNames.REGISTER);
|
|
};
|
|
|
|
/** Called when {@link Views.E2E_SETUP} or {@link Views.COMPLETE_SECURITY} have completed. */
|
|
private onCompleteSecurityE2eSetupFinished = async (): Promise<void> => {
|
|
const forceVerify = await this.shouldForceVerification();
|
|
if (forceVerify) {
|
|
const isVerified = await MatrixClientPeg.safeGet().getCrypto()?.isCrossSigningReady();
|
|
if (!isVerified) {
|
|
// We must verify but we haven't yet verified - don't continue logging in
|
|
return;
|
|
}
|
|
}
|
|
|
|
this.onShowPostLoginScreen();
|
|
};
|
|
|
|
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 = (
|
|
<div className="mx_MatrixChat_splash">
|
|
<Spinner />
|
|
</div>
|
|
);
|
|
} else if (this.state.view === Views.CONFIRM_LOCK_THEFT) {
|
|
view = (
|
|
<ConfirmSessionLockTheftView
|
|
onConfirm={() => {
|
|
this.setState({ view: Views.LOADING });
|
|
this.startInitSession();
|
|
}}
|
|
/>
|
|
);
|
|
} else if (this.state.view === Views.COMPLETE_SECURITY) {
|
|
view = <CompleteSecurity onFinished={this.onCompleteSecurityE2eSetupFinished} />;
|
|
} else if (this.state.view === Views.E2E_SETUP) {
|
|
view = <E2eSetup onCancelled={this.onCompleteSecurityE2eSetupFinished} />;
|
|
} else if (this.state.view === Views.PENDING_CLIENT_START) {
|
|
// we think we are logged in, but are still waiting for the /sync to complete
|
|
view = (
|
|
<LoginSplashView
|
|
matrixClient={MatrixClientPeg.safeGet()}
|
|
onLogoutClick={this.onLogoutClick}
|
|
syncError={this.state.syncError}
|
|
/>
|
|
);
|
|
} else if (this.state.view === Views.LOGGED_IN) {
|
|
// `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) {
|
|
/* 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 = (
|
|
<LoggedInView
|
|
{...this.props}
|
|
{...this.state}
|
|
ref={this.loggedInView}
|
|
matrixClient={MatrixClientPeg.safeGet()}
|
|
onRegistered={this.onRegistered}
|
|
currentRoomId={this.state.currentRoomId}
|
|
/>
|
|
);
|
|
} else {
|
|
// we think we are logged in, but are still waiting for the /sync to complete
|
|
view = (
|
|
<LoginSplashView
|
|
matrixClient={MatrixClientPeg.safeGet()}
|
|
onLogoutClick={this.onLogoutClick}
|
|
syncError={this.state.syncError}
|
|
/>
|
|
);
|
|
}
|
|
} else if (this.state.view === Views.WELCOME) {
|
|
view = <Welcome />;
|
|
} else if (this.state.view === Views.REGISTER && SettingsStore.getValue(UIFeature.Registration)) {
|
|
const email = ThreepidInviteStore.instance.pickBestInvite()?.toEmail;
|
|
view = (
|
|
<Registration
|
|
clientSecret={this.state.register_client_secret}
|
|
sessionId={this.state.register_session_id}
|
|
idSid={this.state.register_id_sid}
|
|
email={email}
|
|
brand={this.props.config.brand}
|
|
onLoggedIn={this.onRegisterFlowComplete}
|
|
onLoginClick={this.onLoginClick}
|
|
onServerConfigChange={this.onServerConfigChange}
|
|
defaultDeviceDisplayName={this.props.defaultDeviceDisplayName}
|
|
fragmentAfterLogin={fragmentAfterLogin}
|
|
mobileRegister={this.state.isMobileRegistration}
|
|
{...this.getServerProperties()}
|
|
/>
|
|
);
|
|
} else if (this.state.view === Views.FORGOT_PASSWORD && SettingsStore.getValue(UIFeature.PasswordReset)) {
|
|
view = (
|
|
<ForgotPassword
|
|
onComplete={this.onLoginClick}
|
|
onLoginClick={this.onLoginClick}
|
|
{...this.getServerProperties()}
|
|
/>
|
|
);
|
|
} else if (this.state.view === Views.LOGIN) {
|
|
const showPasswordReset = SettingsStore.getValue(UIFeature.PasswordReset);
|
|
view = (
|
|
<Login
|
|
isSyncing={this.state.pendingInitialSync}
|
|
onLoggedIn={this.onUserCompletedLoginFlow}
|
|
onRegisterClick={this.onRegisterClick}
|
|
fallbackHsUrl={this.getFallbackHsUrl()}
|
|
defaultDeviceDisplayName={this.props.defaultDeviceDisplayName}
|
|
onForgotPasswordClick={showPasswordReset ? this.onForgotPasswordClick : undefined}
|
|
onServerConfigChange={this.onServerConfigChange}
|
|
fragmentAfterLogin={fragmentAfterLogin}
|
|
defaultUsername={this.props.startingFragmentQueryParams?.defaultUsername as string | undefined}
|
|
{...this.getServerProperties()}
|
|
/>
|
|
);
|
|
} else if (this.state.view === Views.SOFT_LOGOUT) {
|
|
view = (
|
|
<SoftLogout
|
|
realQueryParams={this.props.realQueryParams}
|
|
onTokenLoginCompleted={this.props.onTokenLoginCompleted}
|
|
fragmentAfterLogin={fragmentAfterLogin}
|
|
/>
|
|
);
|
|
} else if (this.state.view === Views.LOCK_STOLEN) {
|
|
view = <SessionLockStolenView />;
|
|
} else {
|
|
logger.error(`Unknown view ${this.state.view}`);
|
|
return null;
|
|
}
|
|
|
|
return (
|
|
<ErrorBoundary>
|
|
<I18nContext.Provider value={ModuleApi.instance.i18n}>
|
|
<SDKContext.Provider value={this.stores}>
|
|
<TooltipProvider>{view}</TooltipProvider>
|
|
</SDKContext.Provider>
|
|
</I18nContext.Provider>
|
|
</ErrorBoundary>
|
|
);
|
|
}
|
|
}
|