diff --git a/src/Terms.ts b/src/Terms.ts index 1bdff36cbc..1b1c152fdd 100644 --- a/src/Terms.ts +++ b/src/Terms.ts @@ -36,14 +36,18 @@ export class Service { } } -interface Policy { +export interface LocalisedPolicy { + name: string; + url: string; +} + +export interface Policy { // @ts-ignore: No great way to express indexed types together with other keys version: string; - [lang: string]: { - url: string; - }; + [lang: string]: LocalisedPolicy; } -type Policies = { + +export type Policies = { [policy: string]: Policy, }; diff --git a/src/components/structures/UserMenu.tsx b/src/components/structures/UserMenu.tsx index c05f74a436..fb4829f879 100644 --- a/src/components/structures/UserMenu.tsx +++ b/src/components/structures/UserMenu.tsx @@ -69,7 +69,7 @@ interface IState { contextMenuPosition: PartialDOMRect; isDarkTheme: boolean; selectedSpace?: Room; - pendingRoomJoin: string[] + pendingRoomJoin: Set; } @replaceableComponent("structures.UserMenu") @@ -86,7 +86,7 @@ export default class UserMenu extends React.Component { this.state = { contextMenuPosition: null, isDarkTheme: this.isUserOnDarkTheme(), - pendingRoomJoin: [], + pendingRoomJoin: new Set(), }; OwnProfileStore.instance.on(UPDATE_EVENT, this.onProfileUpdate); @@ -106,6 +106,7 @@ export default class UserMenu extends React.Component { this.dispatcherRef = defaultDispatcher.register(this.onAction); this.themeWatcherRef = SettingsStore.watchSetting("theme", null, this.onThemeChanged); this.tagStoreRef = GroupFilterOrderStore.addListener(this.onTagStoreUpdate); + MatrixClientPeg.get().on("Room", this.onRoom); } public componentWillUnmount() { @@ -117,6 +118,11 @@ export default class UserMenu extends React.Component { if (SettingsStore.getValue("feature_spaces")) { SpaceStore.instance.off(UPDATE_SELECTED_SPACE, this.onSelectedSpaceUpdate); } + MatrixClientPeg.get().removeListener("Room", this.onRoom); + } + + private onRoom = (room: Room): void => { + this.removePendingJoinRoom(room.roomId); } private onTagStoreUpdate = () => { @@ -168,30 +174,21 @@ export default class UserMenu extends React.Component { } }; - private addPendingJoinRoom(roomId) { + private addPendingJoinRoom(roomId: string): void { this.setState({ - pendingRoomJoin: [ - ...this.state.pendingRoomJoin, - roomId, - ], + pendingRoomJoin: new Set(this.state.pendingRoomJoin) + .add(roomId), }); } - private removePendingJoinRoom(roomId) { - const newPendingRoomJoin = this.state.pendingRoomJoin.filter(pendingJoinRoomId => { - return pendingJoinRoomId !== roomId; - }); - if (newPendingRoomJoin.length !== this.state.pendingRoomJoin.length) { + private removePendingJoinRoom(roomId: string): void { + if (this.state.pendingRoomJoin.delete(roomId)) { this.setState({ - pendingRoomJoin: newPendingRoomJoin, + pendingRoomJoin: new Set(this.state.pendingRoomJoin), }) } } - get hasPendingActions(): boolean { - return this.state.pendingRoomJoin.length > 0; - } - private onOpenMenuClick = (ev: React.MouseEvent) => { ev.preventDefault(); ev.stopPropagation(); @@ -653,11 +650,11 @@ export default class UserMenu extends React.Component { /> {name} - {this.hasPendingActions && ( + {this.state.pendingRoomJoin.size > 0 && ( )} diff --git a/src/components/views/auth/InteractiveAuthEntryComponents.js b/src/components/views/auth/InteractiveAuthEntryComponents.tsx similarity index 72% rename from src/components/views/auth/InteractiveAuthEntryComponents.js rename to src/components/views/auth/InteractiveAuthEntryComponents.tsx index e34349c474..e819e1e59c 100644 --- a/src/components/views/auth/InteractiveAuthEntryComponents.js +++ b/src/components/views/auth/InteractiveAuthEntryComponents.tsx @@ -1,7 +1,5 @@ /* -Copyright 2016 OpenMarket Ltd -Copyright 2017 Vector Creations Ltd -Copyright 2019, 2020 The Matrix.org Foundation C.I.C. +Copyright 2016-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. @@ -16,9 +14,9 @@ See the License for the specific language governing permissions and limitations under the License. */ -import React, {createRef} from 'react'; -import PropTypes from 'prop-types'; -import classnames from 'classnames'; +import React, { ChangeEvent, createRef, FormEvent, MouseEvent } from 'react'; +import classNames from 'classnames'; +import { MatrixClient } from "matrix-js-sdk/src/client"; import * as sdk from '../../../index'; import { _t } from '../../../languageHandler'; @@ -27,6 +25,7 @@ import AccessibleButton from "../elements/AccessibleButton"; import Spinner from "../elements/Spinner"; import CountlyAnalytics from "../../../CountlyAnalytics"; import {replaceableComponent} from "../../../utils/replaceableComponent"; +import { LocalisedPolicy, Policies } from '../../../Terms'; /* This file contains a collection of components which are used by the * InteractiveAuth to prompt the user to enter the information needed @@ -74,36 +73,72 @@ import {replaceableComponent} from "../../../utils/replaceableComponent"; * focus: set the input focus appropriately in the form. */ +enum AuthType { + Password = "m.login.password", + Recaptcha = "m.login.recaptcha", + Terms = "m.login.terms", + Email = "m.login.email.identity", + Msisdn = "m.login.msisdn", + Sso = "m.login.sso", + SsoUnstable = "org.matrix.login.sso", +} + +/* eslint-disable camelcase */ +interface IAuthDict { + type?: AuthType; + // TODO: Remove `user` once servers support proper UIA + // See https://github.com/vector-im/element-web/issues/10312 + user?: string; + identifier?: any; + password?: string; + response?: string; + // TODO: Remove `threepid_creds` once servers support proper UIA + // See https://github.com/vector-im/element-web/issues/10312 + // See https://github.com/matrix-org/matrix-doc/issues/2220 + threepid_creds?: any; + threepidCreds?: any; +} +/* eslint-enable camelcase */ + export const DEFAULT_PHASE = 0; -@replaceableComponent("views.auth.PasswordAuthEntry") -export class PasswordAuthEntry extends React.Component { - static LOGIN_TYPE = "m.login.password"; +interface IAuthEntryProps { + matrixClient: MatrixClient; + loginType: string; + authSessionId: string; + errorText?: string; + // Is the auth logic currently waiting for something to happen? + busy?: boolean; + onPhaseChange: (phase: number) => void; + submitAuthDict: (auth: IAuthDict) => void; +} - static propTypes = { - matrixClient: PropTypes.object.isRequired, - submitAuthDict: PropTypes.func.isRequired, - errorText: PropTypes.string, - // is the auth logic currently waiting for something to - // happen? - busy: PropTypes.bool, - onPhaseChange: PropTypes.func.isRequired, - }; +interface IPasswordAuthEntryState { + password: string; +} + +@replaceableComponent("views.auth.PasswordAuthEntry") +export class PasswordAuthEntry extends React.Component { + static LOGIN_TYPE = AuthType.Password; + + constructor(props) { + super(props); + + this.state = { + password: "", + }; + } componentDidMount() { this.props.onPhaseChange(DEFAULT_PHASE); } - state = { - password: "", - }; - - _onSubmit = e => { + private onSubmit = (e: FormEvent) => { e.preventDefault(); if (this.props.busy) return; this.props.submitAuthDict({ - type: PasswordAuthEntry.LOGIN_TYPE, + type: AuthType.Password, // TODO: Remove `user` once servers support proper UIA // See https://github.com/vector-im/element-web/issues/10312 user: this.props.matrixClient.credentials.userId, @@ -115,7 +150,7 @@ export class PasswordAuthEntry extends React.Component { }); }; - _onPasswordFieldChange = ev => { + private onPasswordFieldChange = (ev: ChangeEvent) => { // enable the submit button iff the password is non-empty this.setState({ password: ev.target.value, @@ -123,7 +158,7 @@ export class PasswordAuthEntry extends React.Component { }; render() { - const passwordBoxClass = classnames({ + const passwordBoxClass = classNames({ "error": this.props.errorText, }); @@ -155,7 +190,7 @@ export class PasswordAuthEntry extends React.Component { return (

{ _t("Confirm your identity by entering your account password below.") }

-
+
{ submitButtonOrSpinner } @@ -175,26 +210,26 @@ export class PasswordAuthEntry extends React.Component { } } -@replaceableComponent("views.auth.RecaptchaAuthEntry") -export class RecaptchaAuthEntry extends React.Component { - static LOGIN_TYPE = "m.login.recaptcha"; - - static propTypes = { - submitAuthDict: PropTypes.func.isRequired, - stageParams: PropTypes.object.isRequired, - errorText: PropTypes.string, - busy: PropTypes.bool, - onPhaseChange: PropTypes.func.isRequired, +/* eslint-disable camelcase */ +interface IRecaptchaAuthEntryProps extends IAuthEntryProps { + stageParams?: { + public_key?: string; }; +} +/* eslint-enable camelcase */ + +@replaceableComponent("views.auth.RecaptchaAuthEntry") +export class RecaptchaAuthEntry extends React.Component { + static LOGIN_TYPE = AuthType.Recaptcha; componentDidMount() { this.props.onPhaseChange(DEFAULT_PHASE); } - _onCaptchaResponse = response => { + private onCaptchaResponse = (response: string) => { CountlyAnalytics.instance.track("onboarding_grecaptcha_submit"); this.props.submitAuthDict({ - type: RecaptchaAuthEntry.LOGIN_TYPE, + type: AuthType.Recaptcha, response: response, }); }; @@ -230,7 +265,7 @@ export class RecaptchaAuthEntry extends React.Component { return (
{ errorSection }
@@ -238,18 +273,28 @@ export class RecaptchaAuthEntry extends React.Component { } } -@replaceableComponent("views.auth.TermsAuthEntry") -export class TermsAuthEntry extends React.Component { - static LOGIN_TYPE = "m.login.terms"; - - static propTypes = { - submitAuthDict: PropTypes.func.isRequired, - stageParams: PropTypes.object.isRequired, - errorText: PropTypes.string, - busy: PropTypes.bool, - showContinue: PropTypes.bool, - onPhaseChange: PropTypes.func.isRequired, +interface ITermsAuthEntryProps extends IAuthEntryProps { + stageParams?: { + policies?: Policies; }; + showContinue: boolean; +} + +interface LocalisedPolicyWithId extends LocalisedPolicy { + id: string; +} + +interface ITermsAuthEntryState { + policies: LocalisedPolicyWithId[]; + toggledPolicies: { + [policy: string]: boolean; + }; + errorText?: string; +} + +@replaceableComponent("views.auth.TermsAuthEntry") +export class TermsAuthEntry extends React.Component { + static LOGIN_TYPE = AuthType.Terms; constructor(props) { super(props); @@ -294,8 +339,11 @@ export class TermsAuthEntry extends React.Component { initToggles[policyId] = false; - langPolicy.id = policyId; - pickedPolicies.push(langPolicy); + pickedPolicies.push({ + id: policyId, + name: langPolicy.name, + url: langPolicy.url, + }); } this.state = { @@ -311,11 +359,11 @@ export class TermsAuthEntry extends React.Component { this.props.onPhaseChange(DEFAULT_PHASE); } - tryContinue = () => { - this._trySubmit(); + public tryContinue = () => { + this.trySubmit(); }; - _togglePolicy(policyId) { + private togglePolicy(policyId: string) { const newToggles = {}; for (const policy of this.state.policies) { let checked = this.state.toggledPolicies[policy.id]; @@ -326,7 +374,7 @@ export class TermsAuthEntry extends React.Component { this.setState({"toggledPolicies": newToggles}); } - _trySubmit = () => { + private trySubmit = () => { let allChecked = true; for (const policy of this.state.policies) { const checked = this.state.toggledPolicies[policy.id]; @@ -334,7 +382,7 @@ export class TermsAuthEntry extends React.Component { } if (allChecked) { - this.props.submitAuthDict({type: TermsAuthEntry.LOGIN_TYPE}); + this.props.submitAuthDict({type: AuthType.Terms}); CountlyAnalytics.instance.track("onboarding_terms_complete"); } else { this.setState({errorText: _t("Please review and accept all of the homeserver's policies")}); @@ -356,7 +404,7 @@ export class TermsAuthEntry extends React.Component { checkboxes.push( // XXX: replace with StyledCheckbox , ); @@ -375,7 +423,7 @@ export class TermsAuthEntry extends React.Component { if (this.props.showContinue !== false) { // XXX: button classes submitButton = ; + onClick={this.trySubmit} disabled={!allChecked}>{_t("Accept")}; } return ( @@ -389,21 +437,18 @@ export class TermsAuthEntry extends React.Component { } } -@replaceableComponent("views.auth.EmailIdentityAuthEntry") -export class EmailIdentityAuthEntry extends React.Component { - static LOGIN_TYPE = "m.login.email.identity"; - - static propTypes = { - matrixClient: PropTypes.object.isRequired, - submitAuthDict: PropTypes.func.isRequired, - authSessionId: PropTypes.string.isRequired, - clientSecret: PropTypes.string.isRequired, - inputs: PropTypes.object.isRequired, - stageState: PropTypes.object.isRequired, - fail: PropTypes.func.isRequired, - setEmailSid: PropTypes.func.isRequired, - onPhaseChange: PropTypes.func.isRequired, +interface IEmailIdentityAuthEntryProps extends IAuthEntryProps { + inputs?: { + emailAddress?: string; }; + stageState?: { + emailSid: string; + }; +} + +@replaceableComponent("views.auth.EmailIdentityAuthEntry") +export class EmailIdentityAuthEntry extends React.Component { + static LOGIN_TYPE = AuthType.Email; componentDidMount() { this.props.onPhaseChange(DEFAULT_PHASE); @@ -427,7 +472,7 @@ export class EmailIdentityAuthEntry extends React.Component { return (

{ _t("A confirmation email has been sent to %(emailAddress)s", - { emailAddress: (sub) => { this.props.inputs.emailAddress } }, + { emailAddress: { this.props.inputs.emailAddress } }, ) }

{ _t("Open the link in the email to continue registration.") }

@@ -437,37 +482,44 @@ export class EmailIdentityAuthEntry extends React.Component { } } +interface IMsisdnAuthEntryProps extends IAuthEntryProps { + inputs: { + phoneCountry: string; + phoneNumber: string; + }; + clientSecret: string; + fail: (error: Error) => void; +} + +interface IMsisdnAuthEntryState { + token: string; + requestingToken: boolean; + errorText: string; +} + @replaceableComponent("views.auth.MsisdnAuthEntry") -export class MsisdnAuthEntry extends React.Component { - static LOGIN_TYPE = "m.login.msisdn"; +export class MsisdnAuthEntry extends React.Component { + static LOGIN_TYPE = AuthType.Msisdn; - static propTypes = { - inputs: PropTypes.shape({ - phoneCountry: PropTypes.string, - phoneNumber: PropTypes.string, - }), - fail: PropTypes.func, - clientSecret: PropTypes.func, - submitAuthDict: PropTypes.func.isRequired, - matrixClient: PropTypes.object, - onPhaseChange: PropTypes.func.isRequired, - }; + private submitUrl: string; + private sid: string; + private msisdn: string; - state = { - token: '', - requestingToken: false, - }; + constructor(props) { + super(props); + + this.state = { + token: '', + requestingToken: false, + errorText: '', + }; + } componentDidMount() { this.props.onPhaseChange(DEFAULT_PHASE); - this._submitUrl = null; - this._sid = null; - this._msisdn = null; - this._tokenBox = null; - this.setState({requestingToken: true}); - this._requestMsisdnToken().catch((e) => { + this.requestMsisdnToken().catch((e) => { this.props.fail(e); }).finally(() => { this.setState({requestingToken: false}); @@ -477,26 +529,26 @@ export class MsisdnAuthEntry extends React.Component { /* * Requests a verification token by SMS. */ - _requestMsisdnToken() { + private requestMsisdnToken(): Promise { return this.props.matrixClient.requestRegisterMsisdnToken( this.props.inputs.phoneCountry, this.props.inputs.phoneNumber, this.props.clientSecret, 1, // TODO: Multiple send attempts? ).then((result) => { - this._submitUrl = result.submit_url; - this._sid = result.sid; - this._msisdn = result.msisdn; + this.submitUrl = result.submit_url; + this.sid = result.sid; + this.msisdn = result.msisdn; }); } - _onTokenChange = e => { + private onTokenChange = (e: ChangeEvent) => { this.setState({ token: e.target.value, }); }; - _onFormSubmit = async e => { + private onFormSubmit = async (e: FormEvent) => { e.preventDefault(); if (this.state.token == '') return; @@ -506,20 +558,20 @@ export class MsisdnAuthEntry extends React.Component { try { let result; - if (this._submitUrl) { + if (this.submitUrl) { result = await this.props.matrixClient.submitMsisdnTokenOtherUrl( - this._submitUrl, this._sid, this.props.clientSecret, this.state.token, + this.submitUrl, this.sid, this.props.clientSecret, this.state.token, ); } else { throw new Error("The registration with MSISDN flow is misconfigured"); } if (result.success) { const creds = { - sid: this._sid, + sid: this.sid, client_secret: this.props.clientSecret, }; this.props.submitAuthDict({ - type: MsisdnAuthEntry.LOGIN_TYPE, + type: AuthType.Msisdn, // TODO: Remove `threepid_creds` once servers support proper UIA // See https://github.com/vector-im/element-web/issues/10312 // See https://github.com/matrix-org/matrix-doc/issues/2220 @@ -543,7 +595,7 @@ export class MsisdnAuthEntry extends React.Component { return ; } else { const enableSubmit = Boolean(this.state.token); - const submitClasses = classnames({ + const submitClasses = classNames({ mx_InteractiveAuthEntryComponents_msisdnSubmit: true, mx_GeneralButton: true, }); @@ -558,16 +610,16 @@ export class MsisdnAuthEntry extends React.Component { return (

{ _t("A text message has been sent to %(msisdn)s", - { msisdn: { this._msisdn } }, + { msisdn: { this.msisdn } }, ) }

{ _t("Please enter the code it contains:") }

- +
@@ -584,40 +636,40 @@ export class MsisdnAuthEntry extends React.Component { } } -@replaceableComponent("views.auth.SSOAuthEntry") -export class SSOAuthEntry extends React.Component { - static propTypes = { - matrixClient: PropTypes.object.isRequired, - authSessionId: PropTypes.string.isRequired, - loginType: PropTypes.string.isRequired, - submitAuthDict: PropTypes.func.isRequired, - errorText: PropTypes.string, - onPhaseChange: PropTypes.func.isRequired, - continueText: PropTypes.string, - continueKind: PropTypes.string, - onCancel: PropTypes.func, - }; +interface ISSOAuthEntryProps extends IAuthEntryProps { + continueText?: string; + continueKind?: string; + onCancel?: () => void; +} - static LOGIN_TYPE = "m.login.sso"; - static UNSTABLE_LOGIN_TYPE = "org.matrix.login.sso"; +interface ISSOAuthEntryState { + phase: number; + attemptFailed: boolean; +} + +@replaceableComponent("views.auth.SSOAuthEntry") +export class SSOAuthEntry extends React.Component { + static LOGIN_TYPE = AuthType.Sso; + static UNSTABLE_LOGIN_TYPE = AuthType.SsoUnstable; static PHASE_PREAUTH = 1; // button to start SSO static PHASE_POSTAUTH = 2; // button to confirm SSO completed - _ssoUrl: string; + private ssoUrl: string; + private popupWindow: Window; constructor(props) { super(props); // We actually send the user through fallback auth so we don't have to // deal with a redirect back to us, losing application context. - this._ssoUrl = props.matrixClient.getFallbackAuthUrl( + this.ssoUrl = props.matrixClient.getFallbackAuthUrl( this.props.loginType, this.props.authSessionId, ); - this._popupWindow = null; - window.addEventListener("message", this._onReceiveMessage); + this.popupWindow = null; + window.addEventListener("message", this.onReceiveMessage); this.state = { phase: SSOAuthEntry.PHASE_PREAUTH, @@ -625,44 +677,44 @@ export class SSOAuthEntry extends React.Component { }; } - componentDidMount(): void { + componentDidMount() { this.props.onPhaseChange(SSOAuthEntry.PHASE_PREAUTH); } componentWillUnmount() { - window.removeEventListener("message", this._onReceiveMessage); - if (this._popupWindow) { - this._popupWindow.close(); - this._popupWindow = null; + window.removeEventListener("message", this.onReceiveMessage); + if (this.popupWindow) { + this.popupWindow.close(); + this.popupWindow = null; } } - attemptFailed = () => { + public attemptFailed = () => { this.setState({ attemptFailed: true, }); }; - _onReceiveMessage = event => { + private onReceiveMessage = (event: MessageEvent) => { if (event.data === "authDone" && event.origin === this.props.matrixClient.getHomeserverUrl()) { - if (this._popupWindow) { - this._popupWindow.close(); - this._popupWindow = null; + if (this.popupWindow) { + this.popupWindow.close(); + this.popupWindow = null; } } }; - onStartAuthClick = () => { + private onStartAuthClick = () => { // Note: We don't use PlatformPeg's startSsoAuth functions because we almost // certainly will need to open the thing in a new tab to avoid losing application // context. - this._popupWindow = window.open(this._ssoUrl, "_blank"); + this.popupWindow = window.open(this.ssoUrl, "_blank"); this.setState({phase: SSOAuthEntry.PHASE_POSTAUTH}); this.props.onPhaseChange(SSOAuthEntry.PHASE_POSTAUTH); }; - onConfirmClick = () => { + private onConfirmClick = () => { this.props.submitAuthDict({}); }; @@ -716,46 +768,37 @@ export class SSOAuthEntry extends React.Component { } @replaceableComponent("views.auth.FallbackAuthEntry") -export class FallbackAuthEntry extends React.Component { - static propTypes = { - matrixClient: PropTypes.object.isRequired, - authSessionId: PropTypes.string.isRequired, - loginType: PropTypes.string.isRequired, - submitAuthDict: PropTypes.func.isRequired, - errorText: PropTypes.string, - onPhaseChange: PropTypes.func.isRequired, - }; +export class FallbackAuthEntry extends React.Component { + private popupWindow: Window; + private fallbackButton = createRef(); constructor(props) { super(props); // we have to make the user click a button, as browsers will block // the popup if we open it immediately. - this._popupWindow = null; - window.addEventListener("message", this._onReceiveMessage); - - this._fallbackButton = createRef(); + this.popupWindow = null; + window.addEventListener("message", this.onReceiveMessage); } - componentDidMount() { this.props.onPhaseChange(DEFAULT_PHASE); } componentWillUnmount() { - window.removeEventListener("message", this._onReceiveMessage); - if (this._popupWindow) { - this._popupWindow.close(); + window.removeEventListener("message", this.onReceiveMessage); + if (this.popupWindow) { + this.popupWindow.close(); } } - focus = () => { - if (this._fallbackButton.current) { - this._fallbackButton.current.focus(); + public focus = () => { + if (this.fallbackButton.current) { + this.fallbackButton.current.focus(); } }; - _onShowFallbackClick = e => { + private onShowFallbackClick = (e: MouseEvent) => { e.preventDefault(); e.stopPropagation(); @@ -763,10 +806,10 @@ export class FallbackAuthEntry extends React.Component { this.props.loginType, this.props.authSessionId, ); - this._popupWindow = window.open(url, "_blank"); + this.popupWindow = window.open(url, "_blank"); }; - _onReceiveMessage = event => { + private onReceiveMessage = (event: MessageEvent) => { if ( event.data === "authDone" && event.origin === this.props.matrixClient.getHomeserverUrl() @@ -786,27 +829,31 @@ export class FallbackAuthEntry extends React.Component { } return ( ); } } -const AuthEntryComponents = [ - PasswordAuthEntry, - RecaptchaAuthEntry, - EmailIdentityAuthEntry, - MsisdnAuthEntry, - TermsAuthEntry, - SSOAuthEntry, -]; - -export default function getEntryComponentForLoginType(loginType) { - for (const c of AuthEntryComponents) { - if (c.LOGIN_TYPE === loginType || c.UNSTABLE_LOGIN_TYPE === loginType) { - return c; - } +export default function getEntryComponentForLoginType(loginType: AuthType): typeof React.Component { + switch (loginType) { + case AuthType.Password: + return PasswordAuthEntry; + case AuthType.Recaptcha: + return RecaptchaAuthEntry; + case AuthType.Email: + return EmailIdentityAuthEntry; + case AuthType.Msisdn: + return MsisdnAuthEntry; + case AuthType.Terms: + return TermsAuthEntry; + case AuthType.Sso: + case AuthType.SsoUnstable: + return SSOAuthEntry; + default: + return FallbackAuthEntry; } - return FallbackAuthEntry; } diff --git a/src/components/views/dialogs/DevtoolsDialog.js b/src/components/views/dialogs/DevtoolsDialog.tsx similarity index 77% rename from src/components/views/dialogs/DevtoolsDialog.js rename to src/components/views/dialogs/DevtoolsDialog.tsx index 8a035263cc..0c37ea9599 100644 --- a/src/components/views/dialogs/DevtoolsDialog.js +++ b/src/components/views/dialogs/DevtoolsDialog.tsx @@ -1,5 +1,6 @@ /* Copyright 2017 Michael Telatynski <7t3chguy@gmail.com> +Copyright 2018-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. @@ -14,14 +15,13 @@ See the License for the specific language governing permissions and limitations under the License. */ -import React, {useState, useEffect} from 'react'; -import PropTypes from 'prop-types'; +import React, { useState, useEffect, ChangeEvent, MouseEvent } from 'react'; import * as sdk from '../../../index'; import SyntaxHighlight from '../elements/SyntaxHighlight'; import { _t } from '../../../languageHandler'; import Field from "../elements/Field"; import MatrixClientContext from "../../../contexts/MatrixClientContext"; -import {useEventEmitter} from "../../../hooks/useEventEmitter"; +import { useEventEmitter } from "../../../hooks/useEventEmitter"; import { PHASE_UNSENT, @@ -30,27 +30,33 @@ import { PHASE_DONE, PHASE_STARTED, PHASE_CANCELLED, + VerificationRequest, } from "matrix-js-sdk/src/crypto/verification/request/VerificationRequest"; -import WidgetStore from "../../../stores/WidgetStore"; -import {UPDATE_EVENT} from "../../../stores/AsyncStore"; -import {SETTINGS} from "../../../settings/Settings"; -import SettingsStore, {LEVEL_ORDER} from "../../../settings/SettingsStore"; +import WidgetStore, { IApp } from "../../../stores/WidgetStore"; +import { UPDATE_EVENT } from "../../../stores/AsyncStore"; +import { SETTINGS } from "../../../settings/Settings"; +import SettingsStore, { LEVEL_ORDER } from "../../../settings/SettingsStore"; import Modal from "../../../Modal"; import ErrorDialog from "./ErrorDialog"; -import {replaceableComponent} from "../../../utils/replaceableComponent"; -import {Room} from "matrix-js-sdk/src/models/room"; -import {MatrixEvent} from "matrix-js-sdk/src/models/event"; +import { replaceableComponent } from "../../../utils/replaceableComponent"; +import { Room } from "matrix-js-sdk/src/models/room"; +import { MatrixEvent } from "matrix-js-sdk/src/models/event"; +import { SettingLevel } from '../../../settings/SettingLevel'; -class GenericEditor extends React.PureComponent { - // static propTypes = {onBack: PropTypes.func.isRequired}; +interface IGenericEditorProps { + onBack: () => void; +} - constructor(props) { - super(props); - this._onChange = this._onChange.bind(this); - this.onBack = this.onBack.bind(this); - } +interface IGenericEditorState { + message?: string; + [inputId: string]: boolean | string; +} - onBack() { +abstract class GenericEditor< + P extends IGenericEditorProps = IGenericEditorProps, + S extends IGenericEditorState = IGenericEditorState, +> extends React.PureComponent { + protected onBack = () => { if (this.state.message) { this.setState({ message: null }); } else { @@ -58,47 +64,60 @@ class GenericEditor extends React.PureComponent { } } - _onChange(e) { + protected onChange = (e: ChangeEvent) => { + // @ts-ignore: Unsure how to convince TS this is okay when the state + // type can be extended. this.setState({[e.target.id]: e.target.type === 'checkbox' ? e.target.checked : e.target.value}); } - _buttons() { + protected abstract send(); + + protected buttons(): React.ReactNode { return
- { !this.state.message && } + { !this.state.message && }
; } - textInput(id, label) { + protected textInput(id: string, label: string): React.ReactNode { return ; } } -export class SendCustomEvent extends GenericEditor { - static getLabel() { return _t('Send Custom Event'); } - - static propTypes = { - onBack: PropTypes.func.isRequired, - room: PropTypes.instanceOf(Room).isRequired, - forceStateEvent: PropTypes.bool, - forceGeneralEvent: PropTypes.bool, - inputs: PropTypes.object, +interface ISendCustomEventProps extends IGenericEditorProps { + room: Room; + forceStateEvent?: boolean; + forceGeneralEvent?: boolean; + inputs?: { + eventType?: string; + stateKey?: string; + evContent?: string; }; +} + +interface ISendCustomEventState extends IGenericEditorState { + isStateEvent: boolean; + eventType: string; + stateKey: string; + evContent: string; +} + +export class SendCustomEvent extends GenericEditor { + static getLabel() { return _t('Send Custom Event'); } static contextType = MatrixClientContext; constructor(props) { super(props); - this._send = this._send.bind(this); const {eventType, stateKey, evContent} = Object.assign({ eventType: '', @@ -115,7 +134,7 @@ export class SendCustomEvent extends GenericEditor { }; } - send(content) { + private doSend(content: object): Promise { const cli = this.context; if (this.state.isStateEvent) { return cli.sendStateEvent(this.props.room.roomId, this.state.eventType, content, this.state.stateKey); @@ -124,7 +143,7 @@ export class SendCustomEvent extends GenericEditor { } } - async _send() { + protected send = async () => { if (this.state.eventType === '') { this.setState({ message: _t('You must specify an event type!') }); return; @@ -133,7 +152,7 @@ export class SendCustomEvent extends GenericEditor { let message; try { const content = JSON.parse(this.state.evContent); - await this.send(content); + await this.doSend(content); message = _t('Event sent!'); } catch (e) { message = _t('Failed to send custom event.') + ' (' + e.toString() + ')'; @@ -147,7 +166,7 @@ export class SendCustomEvent extends GenericEditor {
{ this.state.message }
- { this._buttons() } + { this.buttons() }
; } @@ -163,35 +182,51 @@ export class SendCustomEvent extends GenericEditor {
+ autoComplete="off" value={this.state.evContent} onChange={this.onChange} element="textarea" />
- { !this.state.message && } + { !this.state.message && } { showTglFlip &&
- -
}
; } } -class SendAccountData extends GenericEditor { - static getLabel() { return _t('Send Account Data'); } - - static propTypes = { - room: PropTypes.instanceOf(Room).isRequired, - isRoomAccountData: PropTypes.bool, - forceMode: PropTypes.bool, - inputs: PropTypes.object, +interface ISendAccountDataProps extends IGenericEditorProps { + room: Room; + isRoomAccountData: boolean; + forceMode: boolean; + inputs?: { + eventType?: string; + evContent?: string; }; +} + +interface ISendAccountDataState extends IGenericEditorState { + isRoomAccountData: boolean; + eventType: string; + evContent: string; +} + +class SendAccountData extends GenericEditor { + static getLabel() { return _t('Send Account Data'); } static contextType = MatrixClientContext; constructor(props) { super(props); - this._send = this._send.bind(this); const {eventType, evContent} = Object.assign({ eventType: '', @@ -206,7 +241,7 @@ class SendAccountData extends GenericEditor { }; } - send(content) { + private doSend(content: object): Promise { const cli = this.context; if (this.state.isRoomAccountData) { return cli.setRoomAccountData(this.props.room.roomId, this.state.eventType, content); @@ -214,7 +249,7 @@ class SendAccountData extends GenericEditor { return cli.setAccountData(this.state.eventType, content); } - async _send() { + protected send = async () => { if (this.state.eventType === '') { this.setState({ message: _t('You must specify an event type!') }); return; @@ -223,7 +258,7 @@ class SendAccountData extends GenericEditor { let message; try { const content = JSON.parse(this.state.evContent); - await this.send(content); + await this.doSend(content); message = _t('Event sent!'); } catch (e) { message = _t('Failed to send custom event.') + ' (' + e.toString() + ')'; @@ -237,7 +272,7 @@ class SendAccountData extends GenericEditor {
{ this.state.message }
- { this._buttons() } + { this.buttons() }
; } @@ -247,14 +282,23 @@ class SendAccountData extends GenericEditor {
+ autoComplete="off" value={this.state.evContent} onChange={this.onChange} element="textarea" />
- { !this.state.message && } + { !this.state.message && } { !this.state.message &&
- -
}
; @@ -264,17 +308,22 @@ class SendAccountData extends GenericEditor { const INITIAL_LOAD_TILES = 20; const LOAD_TILES_STEP_SIZE = 50; -class FilteredList extends React.PureComponent { - static propTypes = { - children: PropTypes.any, - query: PropTypes.string, - onChange: PropTypes.func, - }; +interface IFilteredListProps { + children: React.ReactElement[]; + query: string; + onChange: (value: string) => void; +} - static filterChildren(children, query) { +interface IFilteredListState { + filteredChildren: React.ReactElement[]; + truncateAt: number; +} + +class FilteredList extends React.PureComponent { + static filterChildren(children: React.ReactElement[], query: string): React.ReactElement[] { if (!query) return children; const lcQuery = query.toLowerCase(); - return children.filter((child) => child.key.toLowerCase().includes(lcQuery)); + return children.filter((child) => child.key.toString().toLowerCase().includes(lcQuery)); } constructor(props) { @@ -295,27 +344,27 @@ class FilteredList extends React.PureComponent { }); } - showAll = () => { + private showAll = () => { this.setState({ truncateAt: this.state.truncateAt + LOAD_TILES_STEP_SIZE, }); }; - createOverflowElement = (overflowCount: number, totalCount: number) => { + private createOverflowElement = (overflowCount: number, totalCount: number) => { return ; }; - onQuery = (ev) => { + private onQuery = (ev: ChangeEvent) => { if (this.props.onChange) this.props.onChange(ev.target.value); }; - getChildren = (start: number, end: number) => { + private getChildren = (start: number, end: number): React.ReactElement[] => { return this.state.filteredChildren.slice(start, end); }; - getChildCount = (): number => { + private getChildCount = (): number => { return this.state.filteredChildren.length; }; @@ -336,28 +385,31 @@ class FilteredList extends React.PureComponent { } } -class RoomStateExplorer extends React.PureComponent { - static getLabel() { return _t('Explore Room State'); } +interface IExplorerProps { + room: Room; + onBack: () => void; +} - static propTypes = { - onBack: PropTypes.func.isRequired, - room: PropTypes.instanceOf(Room).isRequired, - }; +interface IRoomStateExplorerState { + eventType?: string; + event?: MatrixEvent; + editing: boolean; + queryEventType: string; + queryStateKey: string; +} + +class RoomStateExplorer extends React.PureComponent { + static getLabel() { return _t('Explore Room State'); } static contextType = MatrixClientContext; - roomStateEvents: Map>; + private roomStateEvents: Map>; constructor(props) { super(props); this.roomStateEvents = this.props.room.currentState.events; - this.onBack = this.onBack.bind(this); - this.editEv = this.editEv.bind(this); - this.onQueryEventType = this.onQueryEventType.bind(this); - this.onQueryStateKey = this.onQueryStateKey.bind(this); - this.state = { eventType: null, event: null, @@ -368,19 +420,19 @@ class RoomStateExplorer extends React.PureComponent { }; } - browseEventType(eventType) { + private browseEventType(eventType: string) { return () => { this.setState({ eventType }); }; } - onViewSourceClick(event) { + private onViewSourceClick(event: MatrixEvent) { return () => { this.setState({ event }); }; } - onBack() { + private onBack = () => { if (this.state.editing) { this.setState({ editing: false }); } else if (this.state.event) { @@ -392,15 +444,15 @@ class RoomStateExplorer extends React.PureComponent { } } - editEv() { + private editEv = () => { this.setState({ editing: true }); } - onQueryEventType(filterEventType) { + private onQueryEventType = (filterEventType: string) => { this.setState({ queryEventType: filterEventType }); } - onQueryStateKey(filterStateKey) { + private onQueryStateKey = (filterStateKey: string) => { this.setState({ queryStateKey: filterStateKey }); } @@ -472,24 +524,22 @@ class RoomStateExplorer extends React.PureComponent { } } -class AccountDataExplorer extends React.PureComponent { - static getLabel() { return _t('Explore Account Data'); } +interface IAccountDataExplorerState { + isRoomAccountData: boolean; + event?: MatrixEvent; + editing: boolean; + queryEventType: string; + [inputId: string]: boolean | string; +} - static propTypes = { - onBack: PropTypes.func.isRequired, - room: PropTypes.instanceOf(Room).isRequired, - }; +class AccountDataExplorer extends React.PureComponent { + static getLabel() { return _t('Explore Account Data'); } static contextType = MatrixClientContext; constructor(props) { super(props); - this.onBack = this.onBack.bind(this); - this.editEv = this.editEv.bind(this); - this._onChange = this._onChange.bind(this); - this.onQueryEventType = this.onQueryEventType.bind(this); - this.state = { isRoomAccountData: false, event: null, @@ -499,20 +549,20 @@ class AccountDataExplorer extends React.PureComponent { }; } - getData() { + private getData(): Record { if (this.state.isRoomAccountData) { return this.props.room.accountData; } return this.context.store.accountData; } - onViewSourceClick(event) { + private onViewSourceClick(event: MatrixEvent) { return () => { this.setState({ event }); }; } - onBack() { + private onBack = () => { if (this.state.editing) { this.setState({ editing: false }); } else if (this.state.event) { @@ -522,15 +572,15 @@ class AccountDataExplorer extends React.PureComponent { } } - _onChange(e) { + private onChange = (e: ChangeEvent) => { this.setState({[e.target.id]: e.target.type === 'checkbox' ? e.target.checked : e.target.value}); } - editEv() { + private editEv = () => { this.setState({ editing: true }); } - onQueryEventType(queryEventType) { + private onQueryEventType = (queryEventType: string) => { this.setState({ queryEventType }); } @@ -580,30 +630,39 @@ class AccountDataExplorer extends React.PureComponent {
- { !this.state.message &&
- -
} +
+ +
; } } -class ServersInRoomList extends React.PureComponent { +interface IServersInRoomListState { + query: string; +} + +class ServersInRoomList extends React.PureComponent { static getLabel() { return _t('View Servers in Room'); } - static propTypes = { - onBack: PropTypes.func.isRequired, - room: PropTypes.instanceOf(Room).isRequired, - }; - static contextType = MatrixClientContext; + private servers: React.ReactElement[]; + constructor(props) { super(props); const room = this.props.room; - const servers = new Set(); + const servers = new Set(); room.currentState.getStateEvents("m.room.member").forEach(ev => servers.add(ev.getSender().split(":")[1])); this.servers = Array.from(servers).map(s => + @@ -1091,7 +1179,11 @@ class SettingsExplorer extends React.Component { } } -const Entries = [ +type DevtoolsDialogEntry = React.JSXElementConstructor & { + getLabel: () => string; +}; + +const Entries: DevtoolsDialogEntry[] = [ SendCustomEvent, RoomStateExplorer, SendAccountData, @@ -1102,43 +1194,36 @@ const Entries = [ SettingsExplorer, ]; -@replaceableComponent("views.dialogs.DevtoolsDialog") -export default class DevtoolsDialog extends React.PureComponent { - static propTypes = { - roomId: PropTypes.string.isRequired, - onFinished: PropTypes.func.isRequired, - }; +interface IProps { + roomId: string; + onFinished: (finished: boolean) => void; +} +interface IState { + mode?: DevtoolsDialogEntry; +} + +@replaceableComponent("views.dialogs.DevtoolsDialog") +export default class DevtoolsDialog extends React.PureComponent { constructor(props) { super(props); - this.onBack = this.onBack.bind(this); - this.onCancel = this.onCancel.bind(this); this.state = { mode: null, }; } - componentWillUnmount() { - this._unmounted = true; - } - - _setMode(mode) { + private setMode(mode: DevtoolsDialogEntry) { return () => { this.setState({ mode }); }; } - onBack() { - if (this.prevMode) { - this.setState({ mode: this.prevMode }); - this.prevMode = null; - } else { - this.setState({ mode: null }); - } + private onBack = () => { + this.setState({ mode: null }); } - onCancel() { + private onCancel = () => { this.props.onFinished(false); } @@ -1165,7 +1250,7 @@ export default class DevtoolsDialog extends React.PureComponent {
{ Entries.map((Entry) => { const label = Entry.getLabel(); - const onClick = this._setMode(Entry); + const onClick = this.setMode(Entry); return ; }) }
diff --git a/src/components/views/messages/MVoiceMessageBody.tsx b/src/components/views/messages/MVoiceMessageBody.tsx index 4a2a83465d..d65de7697a 100644 --- a/src/components/views/messages/MVoiceMessageBody.tsx +++ b/src/components/views/messages/MVoiceMessageBody.tsx @@ -71,10 +71,14 @@ export default class MVoiceMessageBody extends React.PureComponent { constructor(props, context) { super(props, context); - if (props.reactions) { - props.reactions.on("Relations.add", this.onReactionsChange); - props.reactions.on("Relations.remove", this.onReactionsChange); - props.reactions.on("Relations.redaction", this.onReactionsChange); - } - this.state = { myReactions: this.getMyReactions(), showAll: false, }; } - componentDidUpdate(prevProps) { + componentDidMount() { + const { mxEvent, reactions } = this.props; + + if (mxEvent.isBeingDecrypted() || mxEvent.shouldAttemptDecryption()) { + mxEvent.once("Event.decrypted", this.onDecrypted); + } + + if (reactions) { + reactions.on("Relations.add", this.onReactionsChange); + reactions.on("Relations.remove", this.onReactionsChange); + reactions.on("Relations.redaction", this.onReactionsChange); + } + } + + componentWillUnmount() { + const { mxEvent, reactions } = this.props; + + mxEvent.off("Event.decrypted", this.onDecrypted); + + if (reactions) { + reactions.off("Relations.add", this.onReactionsChange); + reactions.off("Relations.remove", this.onReactionsChange); + reactions.off("Relations.redaction", this.onReactionsChange); + } + } + + componentDidUpdate(prevProps: IProps) { if (prevProps.reactions !== this.props.reactions) { this.props.reactions.on("Relations.add", this.onReactionsChange); this.props.reactions.on("Relations.remove", this.onReactionsChange); @@ -102,24 +122,12 @@ export default class ReactionsRow extends React.PureComponent { } } - componentWillUnmount() { - if (this.props.reactions) { - this.props.reactions.removeListener( - "Relations.add", - this.onReactionsChange, - ); - this.props.reactions.removeListener( - "Relations.remove", - this.onReactionsChange, - ); - this.props.reactions.removeListener( - "Relations.redaction", - this.onReactionsChange, - ); - } + private onDecrypted = () => { + // Decryption changes whether the event is actionable + this.forceUpdate(); } - onReactionsChange = () => { + private onReactionsChange = () => { // TODO: Call `onHeightChanged` as needed this.setState({ myReactions: this.getMyReactions(), @@ -130,7 +138,7 @@ export default class ReactionsRow extends React.PureComponent { this.forceUpdate(); } - getMyReactions() { + private getMyReactions() { const reactions = this.props.reactions; if (!reactions) { return null; @@ -143,7 +151,7 @@ export default class ReactionsRow extends React.PureComponent { return [...myReactions.values()]; } - onShowAllClick = () => { + private onShowAllClick = () => { this.setState({ showAll: true, }); diff --git a/src/components/views/rooms/EventTile.tsx b/src/components/views/rooms/EventTile.tsx index 54f5be7f21..a9b8a26305 100644 --- a/src/components/views/rooms/EventTile.tsx +++ b/src/components/views/rooms/EventTile.tsx @@ -812,13 +812,6 @@ export default class EventTile extends React.Component { return null; } const eventId = this.props.mxEvent.getId(); - if (!eventId) { - // XXX: Temporary diagnostic logging for https://github.com/vector-im/element-web/issues/11120 - console.error("EventTile attempted to get relations for an event without an ID"); - // Use event's special `toJSON` method to log key data. - console.log(JSON.stringify(this.props.mxEvent, null, 4)); - console.trace("Stacktrace for https://github.com/vector-im/element-web/issues/11120"); - } return this.props.getRelationsForEvent(eventId, "m.annotation", "m.reaction"); }; diff --git a/src/dispatcher/actions.ts b/src/dispatcher/actions.ts index 0c9a4160b5..9fc0b54eea 100644 --- a/src/dispatcher/actions.ts +++ b/src/dispatcher/actions.ts @@ -152,5 +152,5 @@ export enum Action { /** * Fired when joining a room failed */ - JoinRoomError = "join_room", + JoinRoomError = "join_room_error", } diff --git a/src/languageHandler.tsx b/src/languageHandler.tsx index 26c89afec6..16950dc008 100644 --- a/src/languageHandler.tsx +++ b/src/languageHandler.tsx @@ -105,12 +105,14 @@ function safeCounterpartTranslate(text: string, options?: object) { return translated; } +type SubstitutionValue = number | string | React.ReactNode | ((sub: string) => React.ReactNode); + export interface IVariables { count?: number; - [key: string]: number | string; + [key: string]: SubstitutionValue; } -type Tags = Record React.ReactNode>; +type Tags = Record; export type TranslatedString = string | React.ReactNode; @@ -247,7 +249,7 @@ export function replaceByRegexes(text: string, mapping: IVariables | Tags): stri let replaced; // If substitution is a function, call it if (mapping[regexpString] instanceof Function) { - replaced = (mapping as Tags)[regexpString].apply(null, capturedGroups); + replaced = ((mapping as Tags)[regexpString] as Function)(...capturedGroups); } else { replaced = mapping[regexpString]; } diff --git a/src/stores/room-list/MessagePreviewStore.ts b/src/stores/room-list/MessagePreviewStore.ts index 4de612c7bd..f5b9d9bc6a 100644 --- a/src/stores/room-list/MessagePreviewStore.ts +++ b/src/stores/room-list/MessagePreviewStore.ts @@ -176,7 +176,8 @@ export class MessagePreviewStore extends AsyncStoreWithClient { if (payload.action === 'MatrixActions.Room.timeline' || payload.action === 'MatrixActions.Event.decrypted') { const event = payload.event; // TODO: Type out the dispatcher - if (!this.previews.has(event.getRoomId()) || !event.isLiveEvent) return; // not important + const isHistoricalEvent = payload.hasOwnProperty("isLiveEvent") && !payload.isLiveEvent + if (!this.previews.has(event.getRoomId()) || isHistoricalEvent) return; // not important await this.generatePreview(this.matrixClient.getRoom(event.getRoomId()), TAG_ANY); } }