{ completions }
diff --git a/src/autocomplete/UserProvider.tsx b/src/autocomplete/UserProvider.tsx
index 182743abb3..48854657de 100644
--- a/src/autocomplete/UserProvider.tsx
+++ b/src/autocomplete/UserProvider.tsx
@@ -181,7 +181,7 @@ export default class UserProvider extends AutocompleteProvider {
return (
{ completions }
diff --git a/src/components/structures/InteractiveAuth.js b/src/components/structures/InteractiveAuth.js
deleted file mode 100644
index 61ae1882df..0000000000
--- a/src/components/structures/InteractiveAuth.js
+++ /dev/null
@@ -1,300 +0,0 @@
-/*
-Copyright 2017 Vector Creations Ltd.
-Copyright 2019, 2020 The Matrix.org Foundation C.I.C.
-
-Licensed under the Apache License, Version 2.0 (the "License");
-you may not use this file except in compliance with the License.
-You may obtain a copy of the License at
-
- http://www.apache.org/licenses/LICENSE-2.0
-
-Unless required by applicable law or agreed to in writing, software
-distributed under the License is distributed on an "AS IS" BASIS,
-WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-See the License for the specific language governing permissions and
-limitations under the License.
-*/
-
-import { InteractiveAuth } from "matrix-js-sdk/src/interactive-auth";
-import React, { createRef } from 'react';
-import PropTypes from 'prop-types';
-
-import getEntryComponentForLoginType from '../views/auth/InteractiveAuthEntryComponents';
-
-import * as sdk from '../../index';
-import { replaceableComponent } from "../../utils/replaceableComponent";
-
-export const ERROR_USER_CANCELLED = new Error("User cancelled auth session");
-
-@replaceableComponent("structures.InteractiveAuthComponent")
-export default class InteractiveAuthComponent extends React.Component {
- static propTypes = {
- // matrix client to use for UI auth requests
- matrixClient: PropTypes.object.isRequired,
-
- // response from initial request. If not supplied, will do a request on
- // mount.
- authData: PropTypes.shape({
- flows: PropTypes.array,
- params: PropTypes.object,
- session: PropTypes.string,
- }),
-
- // callback
- makeRequest: PropTypes.func.isRequired,
-
- // callback called when the auth process has finished,
- // successfully or unsuccessfully.
- // @param {bool} status True if the operation requiring
- // auth was completed sucessfully, false if canceled.
- // @param {object} result The result of the authenticated call
- // if successful, otherwise the error object.
- // @param {object} extra Additional information about the UI Auth
- // process:
- // * emailSid {string} If email auth was performed, the sid of
- // the auth session.
- // * clientSecret {string} The client secret used in auth
- // sessions with the identity server.
- onAuthFinished: PropTypes.func.isRequired,
-
- // Inputs provided by the user to the auth process
- // and used by various stages. As passed to js-sdk
- // interactive-auth
- inputs: PropTypes.object,
-
- // As js-sdk interactive-auth
- requestEmailToken: PropTypes.func,
- sessionId: PropTypes.string,
- clientSecret: PropTypes.string,
- emailSid: PropTypes.string,
-
- // If true, poll to see if the auth flow has been completed
- // out-of-band
- poll: PropTypes.bool,
-
- // If true, components will be told that the 'Continue' button
- // is managed by some other party and should not be managed by
- // the component itself.
- continueIsManaged: PropTypes.bool,
-
- // Called when the stage changes, or the stage's phase changes. First
- // argument is the stage, second is the phase. Some stages do not have
- // phases and will be counted as 0 (numeric).
- onStagePhaseChange: PropTypes.func,
-
- // continueText and continueKind are passed straight through to the AuthEntryComponent.
- continueText: PropTypes.string,
- continueKind: PropTypes.string,
- };
-
- constructor(props) {
- super(props);
-
- this.state = {
- authStage: null,
- busy: false,
- errorText: null,
- stageErrorText: null,
- submitButtonEnabled: false,
- };
-
- this._unmounted = false;
- this._authLogic = new InteractiveAuth({
- authData: this.props.authData,
- doRequest: this._requestCallback,
- busyChanged: this._onBusyChanged,
- inputs: this.props.inputs,
- stateUpdated: this._authStateUpdated,
- matrixClient: this.props.matrixClient,
- sessionId: this.props.sessionId,
- clientSecret: this.props.clientSecret,
- emailSid: this.props.emailSid,
- requestEmailToken: this._requestEmailToken,
- });
-
- this._intervalId = null;
- if (this.props.poll) {
- this._intervalId = setInterval(() => {
- this._authLogic.poll();
- }, 2000);
- }
-
- this._stageComponent = createRef();
- }
-
- // TODO: [REACT-WARNING] Replace component with real class, use constructor for refs
- UNSAFE_componentWillMount() { // eslint-disable-line camelcase
- this._authLogic.attemptAuth().then((result) => {
- const extra = {
- emailSid: this._authLogic.getEmailSid(),
- clientSecret: this._authLogic.getClientSecret(),
- };
- this.props.onAuthFinished(true, result, extra);
- }).catch((error) => {
- this.props.onAuthFinished(false, error);
- console.error("Error during user-interactive auth:", error);
- if (this._unmounted) {
- return;
- }
-
- const msg = error.message || error.toString();
- this.setState({
- errorText: msg,
- });
- });
- }
-
- componentWillUnmount() {
- this._unmounted = true;
-
- if (this._intervalId !== null) {
- clearInterval(this._intervalId);
- }
- }
-
- _requestEmailToken = async (...args) => {
- this.setState({
- busy: true,
- });
- try {
- return await this.props.requestEmailToken(...args);
- } finally {
- this.setState({
- busy: false,
- });
- }
- };
-
- tryContinue = () => {
- if (this._stageComponent.current && this._stageComponent.current.tryContinue) {
- this._stageComponent.current.tryContinue();
- }
- };
-
- _authStateUpdated = (stageType, stageState) => {
- const oldStage = this.state.authStage;
- this.setState({
- busy: false,
- authStage: stageType,
- stageState: stageState,
- errorText: stageState.error,
- }, () => {
- if (oldStage !== stageType) {
- this._setFocus();
- } else if (
- !stageState.error && this._stageComponent.current &&
- this._stageComponent.current.attemptFailed
- ) {
- this._stageComponent.current.attemptFailed();
- }
- });
- };
-
- _requestCallback = (auth) => {
- // This wrapper just exists because the js-sdk passes a second
- // 'busy' param for backwards compat. This throws the tests off
- // so discard it here.
- return this.props.makeRequest(auth);
- };
-
- _onBusyChanged = (busy) => {
- // if we've started doing stuff, reset the error messages
- if (busy) {
- this.setState({
- busy: true,
- errorText: null,
- stageErrorText: null,
- });
- }
- // The JS SDK eagerly reports itself as "not busy" right after any
- // immediate work has completed, but that's not really what we want at
- // the UI layer, so we ignore this signal and show a spinner until
- // there's a new screen to show the user. This is implemented by setting
- // `busy: false` in `_authStateUpdated`.
- // See also https://github.com/vector-im/element-web/issues/12546
- };
-
- _setFocus() {
- if (this._stageComponent.current && this._stageComponent.current.focus) {
- this._stageComponent.current.focus();
- }
- }
-
- _submitAuthDict = authData => {
- this._authLogic.submitAuthDict(authData);
- };
-
- _onPhaseChange = newPhase => {
- if (this.props.onStagePhaseChange) {
- this.props.onStagePhaseChange(this.state.authStage, newPhase || 0);
- }
- };
-
- _onStageCancel = () => {
- this.props.onAuthFinished(false, ERROR_USER_CANCELLED);
- };
-
- _renderCurrentStage() {
- const stage = this.state.authStage;
- if (!stage) {
- if (this.state.busy) {
- const Loader = sdk.getComponent("elements.Spinner");
- return
;
- } else {
- return null;
- }
- }
-
- const StageComponent = getEntryComponentForLoginType(stage);
- return (
-
- );
- }
-
- _onAuthStageFailed = e => {
- this.props.onAuthFinished(false, e);
- };
-
- _setEmailSid = sid => {
- this._authLogic.setEmailSid(sid);
- };
-
- render() {
- let error = null;
- if (this.state.errorText) {
- error = (
-
- { this.state.errorText }
-
- );
- }
-
- return (
-
-
- { this._renderCurrentStage() }
- { error }
-
-
- );
- }
-}
diff --git a/src/components/structures/InteractiveAuth.tsx b/src/components/structures/InteractiveAuth.tsx
new file mode 100644
index 0000000000..869cd29cba
--- /dev/null
+++ b/src/components/structures/InteractiveAuth.tsx
@@ -0,0 +1,300 @@
+/*
+Copyright 2017 - 2021 The Matrix.org Foundation C.I.C.
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+import {
+ AuthType,
+ IAuthData,
+ IAuthDict,
+ IInputs,
+ InteractiveAuth,
+ IStageStatus,
+} from "matrix-js-sdk/src/interactive-auth";
+import { MatrixClient } from "matrix-js-sdk/src/client";
+import React, { createRef } from 'react';
+
+import getEntryComponentForLoginType, { IStageComponent } from '../views/auth/InteractiveAuthEntryComponents';
+import Spinner from "../views/elements/Spinner";
+import { replaceableComponent } from "../../utils/replaceableComponent";
+
+export const ERROR_USER_CANCELLED = new Error("User cancelled auth session");
+
+interface IProps {
+ // matrix client to use for UI auth requests
+ matrixClient: MatrixClient;
+ // response from initial request. If not supplied, will do a request on mount.
+ authData?: IAuthData;
+ // Inputs provided by the user to the auth process
+ // and used by various stages. As passed to js-sdk
+ // interactive-auth
+ inputs?: IInputs;
+ sessionId?: string;
+ clientSecret?: string;
+ emailSid?: string;
+ // If true, poll to see if the auth flow has been completed out-of-band
+ poll?: boolean;
+ // If true, components will be told that the 'Continue' button
+ // is managed by some other party and should not be managed by
+ // the component itself.
+ continueIsManaged?: boolean;
+ // continueText and continueKind are passed straight through to the AuthEntryComponent.
+ continueText?: string;
+ continueKind?: string;
+ // callback
+ makeRequest(auth: IAuthData): Promise
;
+ // callback called when the auth process has finished,
+ // successfully or unsuccessfully.
+ // @param {boolean} status True if the operation requiring
+ // auth was completed successfully, false if canceled.
+ // @param {object} result The result of the authenticated call
+ // if successful, otherwise the error object.
+ // @param {object} extra Additional information about the UI Auth
+ // process:
+ // * emailSid {string} If email auth was performed, the sid of
+ // the auth session.
+ // * clientSecret {string} The client secret used in auth
+ // sessions with the ID server.
+ onAuthFinished(
+ status: boolean,
+ result: IAuthData | Error,
+ extra?: { emailSid?: string, clientSecret?: string },
+ ): void;
+ // As js-sdk interactive-auth
+ requestEmailToken?(email: string, secret: string, attempt: number, session: string): Promise<{ sid: string }>;
+ // Called when the stage changes, or the stage's phase changes. First
+ // argument is the stage, second is the phase. Some stages do not have
+ // phases and will be counted as 0 (numeric).
+ onStagePhaseChange?(stage: string, phase: string | number): void;
+}
+
+interface IState {
+ authStage?: AuthType;
+ stageState?: IStageStatus;
+ busy: boolean;
+ errorText?: string;
+ stageErrorText?: string;
+ submitButtonEnabled: boolean;
+}
+
+@replaceableComponent("structures.InteractiveAuthComponent")
+export default class InteractiveAuthComponent extends React.Component {
+ private readonly authLogic: InteractiveAuth;
+ private readonly intervalId: number = null;
+ private readonly stageComponent = createRef();
+
+ private unmounted = false;
+
+ constructor(props) {
+ super(props);
+
+ this.state = {
+ authStage: null,
+ busy: false,
+ errorText: null,
+ stageErrorText: null,
+ submitButtonEnabled: false,
+ };
+
+ this.authLogic = new InteractiveAuth({
+ authData: this.props.authData,
+ doRequest: this.requestCallback,
+ busyChanged: this.onBusyChanged,
+ inputs: this.props.inputs,
+ stateUpdated: this.authStateUpdated,
+ matrixClient: this.props.matrixClient,
+ sessionId: this.props.sessionId,
+ clientSecret: this.props.clientSecret,
+ emailSid: this.props.emailSid,
+ requestEmailToken: this.requestEmailToken,
+ });
+
+ if (this.props.poll) {
+ this.intervalId = setInterval(() => {
+ this.authLogic.poll();
+ }, 2000);
+ }
+ }
+
+ // TODO: [REACT-WARNING] Replace component with real class, use constructor for refs
+ UNSAFE_componentWillMount() { // eslint-disable-line @typescript-eslint/naming-convention, camelcase
+ this.authLogic.attemptAuth().then((result) => {
+ const extra = {
+ emailSid: this.authLogic.getEmailSid(),
+ clientSecret: this.authLogic.getClientSecret(),
+ };
+ this.props.onAuthFinished(true, result, extra);
+ }).catch((error) => {
+ this.props.onAuthFinished(false, error);
+ console.error("Error during user-interactive auth:", error);
+ if (this.unmounted) {
+ return;
+ }
+
+ const msg = error.message || error.toString();
+ this.setState({
+ errorText: msg,
+ });
+ });
+ }
+
+ componentWillUnmount() {
+ this.unmounted = true;
+
+ if (this.intervalId !== null) {
+ clearInterval(this.intervalId);
+ }
+ }
+
+ private requestEmailToken = async (
+ email: string,
+ secret: string,
+ attempt: number,
+ session: string,
+ ): Promise<{sid: string}> => {
+ this.setState({
+ busy: true,
+ });
+ try {
+ return await this.props.requestEmailToken(email, secret, attempt, session);
+ } finally {
+ this.setState({
+ busy: false,
+ });
+ }
+ };
+
+ private tryContinue = (): void => {
+ this.stageComponent.current?.tryContinue?.();
+ };
+
+ private authStateUpdated = (stageType: AuthType, stageState: IStageStatus): void => {
+ const oldStage = this.state.authStage;
+ this.setState({
+ busy: false,
+ authStage: stageType,
+ stageState: stageState,
+ errorText: stageState.error,
+ }, () => {
+ if (oldStage !== stageType) {
+ this.setFocus();
+ } else if (!stageState.error) {
+ this.stageComponent.current?.attemptFailed?.();
+ }
+ });
+ };
+
+ private requestCallback = (auth: IAuthData, background: boolean): Promise => {
+ // This wrapper just exists because the js-sdk passes a second
+ // 'busy' param for backwards compat. This throws the tests off
+ // so discard it here.
+ return this.props.makeRequest(auth);
+ };
+
+ private onBusyChanged = (busy: boolean): void => {
+ // if we've started doing stuff, reset the error messages
+ if (busy) {
+ this.setState({
+ busy: true,
+ errorText: null,
+ stageErrorText: null,
+ });
+ }
+ // The JS SDK eagerly reports itself as "not busy" right after any
+ // immediate work has completed, but that's not really what we want at
+ // the UI layer, so we ignore this signal and show a spinner until
+ // there's a new screen to show the user. This is implemented by setting
+ // `busy: false` in `authStateUpdated`.
+ // See also https://github.com/vector-im/element-web/issues/12546
+ };
+
+ private setFocus(): void {
+ this.stageComponent.current?.focus?.();
+ }
+
+ private submitAuthDict = (authData: IAuthDict): void => {
+ this.authLogic.submitAuthDict(authData);
+ };
+
+ private onPhaseChange = (newPhase: number): void => {
+ this.props.onStagePhaseChange?.(this.state.authStage, newPhase || 0);
+ };
+
+ private onStageCancel = (): void => {
+ this.props.onAuthFinished(false, ERROR_USER_CANCELLED);
+ };
+
+ private renderCurrentStage(): JSX.Element {
+ const stage = this.state.authStage;
+ if (!stage) {
+ if (this.state.busy) {
+ return ;
+ } else {
+ return null;
+ }
+ }
+
+ const StageComponent = getEntryComponentForLoginType(stage);
+ return (
+
+ );
+ }
+
+ private onAuthStageFailed = (e: Error): void => {
+ this.props.onAuthFinished(false, e);
+ };
+
+ private setEmailSid = (sid: string): void => {
+ this.authLogic.setEmailSid(sid);
+ };
+
+ render() {
+ let error = null;
+ if (this.state.errorText) {
+ error = (
+
+ { this.state.errorText }
+
+ );
+ }
+
+ return (
+
+
+ { this.renderCurrentStage() }
+ { error }
+
+
+ );
+ }
+}
diff --git a/src/components/structures/LoggedInView.tsx b/src/components/structures/LoggedInView.tsx
index d496c4ad21..2392a8b28d 100644
--- a/src/components/structures/LoggedInView.tsx
+++ b/src/components/structures/LoggedInView.tsx
@@ -529,24 +529,24 @@ class LoggedInView extends React.Component {
}
const isModifier = ev.key === Key.ALT || ev.key === Key.CONTROL || ev.key === Key.META || ev.key === Key.SHIFT;
- if (!isModifier && !ev.altKey && !ev.ctrlKey && !ev.metaKey) {
+ if (!isModifier && !ev.ctrlKey && !ev.metaKey) {
// The above condition is crafted to _allow_ characters with Shift
// already pressed (but not the Shift key down itself).
-
const isClickShortcut = ev.target !== document.body &&
(ev.key === Key.SPACE || ev.key === Key.ENTER);
- // Do not capture the context menu key to improve keyboard accessibility
- if (ev.key === Key.CONTEXT_MENU) {
- return;
- }
+ // We explicitly allow alt to be held due to it being a common accent modifier.
+ // XXX: Forwarding Dead keys in this way does not work as intended but better to at least
+ // move focus to the composer so the user can re-type the dead key correctly.
+ const isPrintable = ev.key.length === 1 || ev.key === "Dead";
- if (!isClickShortcut && ev.key !== Key.TAB && !canElementReceiveInput(ev.target)) {
+ // If the user is entering a printable character outside of an input field
+ // redirect it to the composer for them.
+ if (!isClickShortcut && isPrintable && !canElementReceiveInput(ev.target)) {
// synchronous dispatch so we focus before key generates input
dis.fire(Action.FocusSendMessageComposer, true);
ev.stopPropagation();
- // we should *not* preventDefault() here as
- // that would prevent typing in the now-focussed composer
+ // we should *not* preventDefault() here as that would prevent typing in the now-focused composer
}
}
};
diff --git a/src/components/views/auth/InteractiveAuthEntryComponents.tsx b/src/components/views/auth/InteractiveAuthEntryComponents.tsx
index d9db140645..423738acb8 100644
--- a/src/components/views/auth/InteractiveAuthEntryComponents.tsx
+++ b/src/components/views/auth/InteractiveAuthEntryComponents.tsx
@@ -17,6 +17,7 @@ limitations under the License.
import React, { ChangeEvent, createRef, FormEvent, MouseEvent } from 'react';
import classNames from 'classnames';
import { MatrixClient } from "matrix-js-sdk/src/client";
+import { AuthType, IAuthDict, IInputs, IStageStatus } from 'matrix-js-sdk/src/interactive-auth';
import { _t } from '../../../languageHandler';
import SettingsStore from "../../../settings/SettingsStore";
@@ -74,33 +75,6 @@ import CaptchaForm from "./CaptchaForm";
* 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;
interface IAuthEntryProps {
@@ -835,7 +809,26 @@ export class FallbackAuthEntry extends React.Component {
}
}
-export default function getEntryComponentForLoginType(loginType: AuthType): typeof React.Component {
+export interface IStageComponentProps extends IAuthEntryProps {
+ clientSecret?: string;
+ stageParams?: Record;
+ inputs?: IInputs;
+ stageState?: IStageStatus;
+ showContinue?: boolean;
+ continueText?: string;
+ continueKind?: string;
+ fail?(e: Error): void;
+ setEmailSid?(sid: string): void;
+ onCancel?(): void;
+}
+
+export interface IStageComponent extends React.ComponentClass> {
+ tryContinue?(): void;
+ attemptFailed?(): void;
+ focus?(): void;
+}
+
+export default function getEntryComponentForLoginType(loginType: AuthType): IStageComponent {
switch (loginType) {
case AuthType.Password:
return PasswordAuthEntry;
diff --git a/src/components/views/dialogs/DeactivateAccountDialog.tsx b/src/components/views/dialogs/DeactivateAccountDialog.tsx
index 7221df222f..6548bd78fc 100644
--- a/src/components/views/dialogs/DeactivateAccountDialog.tsx
+++ b/src/components/views/dialogs/DeactivateAccountDialog.tsx
@@ -16,6 +16,7 @@ limitations under the License.
*/
import React from 'react';
+import { AuthType, IAuthData } from 'matrix-js-sdk/src/interactive-auth';
import Analytics from '../../../Analytics';
import { MatrixClientPeg } from '../../../MatrixClientPeg';
@@ -65,7 +66,7 @@ export default class DeactivateAccountDialog extends React.Component {
+ private onStagePhaseChange = (stage: AuthType, phase: string): void => {
const dialogAesthetics = {
[SSOAuthEntry.PHASE_PREAUTH]: {
body: _t("Confirm your account deactivation by using Single Sign On to prove your identity."),
@@ -115,7 +116,10 @@ export default class DeactivateAccountDialog extends React.Component {
+ private onUIAuthComplete = (auth: IAuthData): void => {
+ // XXX: this should be returning a promise to maintain the state inside the state machine correct
+ // but given that a deactivation is followed by a local logout and all object instances being thrown away
+ // this isn't done.
MatrixClientPeg.get().deactivateAccount(auth, this.state.shouldErase).then(r => {
// Deactivation worked - logout & close this dialog
Analytics.trackEvent('Account', 'Deactivate Account');
@@ -180,7 +184,9 @@ export default class DeactivateAccountDialog extends React.Component {
} else if (hangupReason === CallErrorCode.InviteTimeout) {
return (
- { _t("Missed call") }
+ { _t("No answer") }
{ this.renderCallBackButton(_t("Call back")) }
);
@@ -249,10 +249,9 @@ export default class CallEvent extends React.PureComponent {
mx_CallEvent_voice: isVoice,
mx_CallEvent_video: !isVoice,
mx_CallEvent_narrow: this.state.narrow,
- mx_CallEvent_missed: (
- callState === CustomCallState.Missed ||
- (callState === CallState.Ended && hangupReason === CallErrorCode.InviteTimeout)
- ),
+ mx_CallEvent_missed: callState === CustomCallState.Missed,
+ mx_CallEvent_noAnswer: callState === CallState.Ended && hangupReason === CallErrorCode.InviteTimeout,
+ mx_CallEvent_rejected: callState === CallState.Ended && this.props.callEventGrouper.gotRejected,
});
let silenceIcon;
if (this.state.narrow && this.state.callState === CallState.Ringing) {
diff --git a/src/components/views/rooms/Autocomplete.tsx b/src/components/views/rooms/Autocomplete.tsx
index 6b5edcf91b..34909baef1 100644
--- a/src/components/views/rooms/Autocomplete.tsx
+++ b/src/components/views/rooms/Autocomplete.tsx
@@ -25,7 +25,6 @@ import SettingsStore from "../../../settings/SettingsStore";
import Autocompleter from '../../../autocomplete/Autocompleter';
import { replaceableComponent } from "../../../utils/replaceableComponent";
-const COMPOSER_SELECTED = 0;
const MAX_PROVIDER_MATCHES = 20;
export const generateCompletionDomId = (number) => `mx_Autocomplete_Completion_${number}`;
@@ -34,9 +33,9 @@ interface IProps {
// the query string for which to show autocomplete suggestions
query: string;
// method invoked with range and text content when completion is confirmed
- onConfirm: (ICompletion) => void;
+ onConfirm: (completion: ICompletion) => void;
// method invoked when selected (if any) completion changes
- onSelectionChange?: (ICompletion, number) => void;
+ onSelectionChange?: (partIndex: number) => void;
selection: ISelectionRange;
// The room in which we're autocompleting
room: Room;
@@ -71,7 +70,7 @@ export default class Autocomplete extends React.PureComponent {
completionList: [],
// how far down the completion list we are (THIS IS 1-INDEXED!)
- selectionOffset: COMPOSER_SELECTED,
+ selectionOffset: 1,
// whether we should show completions if they're available
shouldShowCompletions: true,
@@ -86,7 +85,7 @@ export default class Autocomplete extends React.PureComponent {
this.applyNewProps();
}
- private applyNewProps(oldQuery?: string, oldRoom?: Room) {
+ private applyNewProps(oldQuery?: string, oldRoom?: Room): void {
if (oldRoom && this.props.room.roomId !== oldRoom.roomId) {
this.autocompleter.destroy();
this.autocompleter = new Autocompleter(this.props.room);
@@ -104,7 +103,7 @@ export default class Autocomplete extends React.PureComponent {
this.autocompleter.destroy();
}
- complete(query: string, selection: ISelectionRange) {
+ private complete(query: string, selection: ISelectionRange): Promise {
this.queryRequested = query;
if (this.debounceCompletionsRequest) {
clearTimeout(this.debounceCompletionsRequest);
@@ -115,7 +114,7 @@ export default class Autocomplete extends React.PureComponent {
completions: [],
completionList: [],
// Reset selected completion
- selectionOffset: COMPOSER_SELECTED,
+ selectionOffset: 1,
// Hide the autocomplete box
hide: true,
});
@@ -135,7 +134,7 @@ export default class Autocomplete extends React.PureComponent {
});
}
- processQuery(query: string, selection: ISelectionRange) {
+ private processQuery(query: string, selection: ISelectionRange): Promise {
return this.autocompleter.getCompletions(
query, selection, this.state.forceComplete, MAX_PROVIDER_MATCHES,
).then((completions) => {
@@ -147,30 +146,35 @@ export default class Autocomplete extends React.PureComponent {
});
}
- processCompletions(completions: IProviderCompletions[]) {
+ private processCompletions(completions: IProviderCompletions[]): void {
const completionList = flatMap(completions, (provider) => provider.completions);
// Reset selection when completion list becomes empty.
- let selectionOffset = COMPOSER_SELECTED;
+ let selectionOffset = 1;
if (completionList.length > 0) {
/* If the currently selected completion is still in the completion list,
try to find it and jump to it. If not, select composer.
*/
- const currentSelection = this.state.selectionOffset === 0 ? null :
+ const currentSelection = this.state.selectionOffset <= 1 ? null :
this.state.completionList[this.state.selectionOffset - 1].completion;
selectionOffset = completionList.findIndex(
(completion) => completion.completion === currentSelection);
if (selectionOffset === -1) {
- selectionOffset = COMPOSER_SELECTED;
+ selectionOffset = 1;
} else {
selectionOffset++; // selectionOffset is 1-indexed!
}
}
- let hide = this.state.hide;
+ let hide = true;
// If `completion.command.command` is truthy, then a provider has matched with the query
const anyMatches = completions.some((completion) => !!completion.command.command);
- hide = !anyMatches;
+ if (anyMatches) {
+ hide = false;
+ if (this.props.onSelectionChange) {
+ this.props.onSelectionChange(selectionOffset - 1);
+ }
+ }
this.setState({
completions,
@@ -182,25 +186,25 @@ export default class Autocomplete extends React.PureComponent {
});
}
- hasSelection(): boolean {
+ public hasSelection(): boolean {
return this.countCompletions() > 0 && this.state.selectionOffset !== 0;
}
- countCompletions(): number {
+ public countCompletions(): number {
return this.state.completionList.length;
}
// called from MessageComposerInput
- moveSelection(delta: number) {
+ public moveSelection(delta: number): void {
const completionCount = this.countCompletions();
if (completionCount === 0) return; // there are no items to move the selection through
// Note: selectionOffset 0 represents the unsubstituted text, while 1 means first pill selected
- const index = (this.state.selectionOffset + delta + completionCount + 1) % (completionCount + 1);
- this.setSelection(index);
+ const index = (this.state.selectionOffset + delta + completionCount - 1) % completionCount;
+ this.setSelection(1 + index);
}
- onEscape(e: KeyboardEvent): boolean {
+ public onEscape(e: KeyboardEvent): boolean {
const completionCount = this.countCompletions();
if (completionCount === 0) {
// autocomplete is already empty, so don't preventDefault
@@ -213,16 +217,16 @@ export default class Autocomplete extends React.PureComponent {
this.hide();
}
- hide = () => {
+ private hide = (): void => {
this.setState({
hide: true,
- selectionOffset: 0,
+ selectionOffset: 1,
completions: [],
completionList: [],
});
};
- forceComplete() {
+ public forceComplete(): Promise {
return new Promise((resolve) => {
this.setState({
forceComplete: true,
@@ -235,8 +239,13 @@ export default class Autocomplete extends React.PureComponent {
});
}
- onCompletionClicked = (selectionOffset: number): boolean => {
- if (this.countCompletions() === 0 || selectionOffset === COMPOSER_SELECTED) {
+ public onConfirmCompletion = (): void => {
+ this.onCompletionClicked(this.state.selectionOffset);
+ };
+
+ private onCompletionClicked = (selectionOffset: number): boolean => {
+ const count = this.countCompletions();
+ if (count === 0 || selectionOffset < 1 || selectionOffset > count) {
return false;
}
@@ -246,10 +255,10 @@ export default class Autocomplete extends React.PureComponent {
return true;
};
- setSelection(selectionOffset: number) {
+ private setSelection(selectionOffset: number): void {
this.setState({ selectionOffset, hide: false });
if (this.props.onSelectionChange) {
- this.props.onSelectionChange(this.state.completionList[selectionOffset - 1], selectionOffset - 1);
+ this.props.onSelectionChange(selectionOffset - 1);
}
}
@@ -292,7 +301,7 @@ export default class Autocomplete extends React.PureComponent {
});
return completions.length > 0 ? (
-
+
{ completionResult.provider.getName() }
{ completionResult.provider.renderCompletions(completions) }
@@ -300,7 +309,7 @@ export default class Autocomplete extends React.PureComponent
{
}).filter((completion) => !!completion);
return !this.state.hide && renderedCompletions.length > 0 ? (
-
+
{ renderedCompletions }
) : null;
diff --git a/src/components/views/rooms/BasicMessageComposer.tsx b/src/components/views/rooms/BasicMessageComposer.tsx
index 3f98d5d5e4..48f2e2a39b 100644
--- a/src/components/views/rooms/BasicMessageComposer.tsx
+++ b/src/components/views/rooms/BasicMessageComposer.tsx
@@ -133,6 +133,7 @@ export default class BasicMessageEditor extends React.Component
this.state = {
showPillAvatar: SettingsStore.getValue("Pill.shouldShowPillAvatar"),
surroundWith: SettingsStore.getValue("MessageComposerInput.surroundWith"),
+ showVisualBell: false,
};
this.emoticonSettingHandle = SettingsStore.watchSetting('MessageComposerInput.autoReplaceEmoji', null,
@@ -215,7 +216,11 @@ export default class BasicMessageEditor extends React.Component
if (isEmpty) {
this.formatBarRef.current.hide();
}
- this.setState({ autoComplete: this.props.model.autoComplete });
+ this.setState({
+ autoComplete: this.props.model.autoComplete,
+ // if a change is happening then clear the showVisualBell
+ showVisualBell: diff ? false : this.state.showVisualBell,
+ });
this.historyManager.tryPush(this.props.model, selection, inputType, diff);
let isTyping = !this.props.model.isEmpty;
@@ -435,7 +440,7 @@ export default class BasicMessageEditor extends React.Component
const model = this.props.model;
let handled = false;
- if (this.state.surroundWith && document.getSelection().type != "Caret") {
+ if (this.state.surroundWith && document.getSelection().type !== "Caret") {
// This surrounds the selected text with a character. This is
// intentionally left out of the keybinding manager as the keybinds
// here shouldn't be changeable
@@ -456,6 +461,44 @@ export default class BasicMessageEditor extends React.Component
}
}
+ const autocompleteAction = getKeyBindingsManager().getAutocompleteAction(event);
+ if (model.autoComplete?.hasCompletions()) {
+ const autoComplete = model.autoComplete;
+ switch (autocompleteAction) {
+ case AutocompleteAction.ForceComplete:
+ case AutocompleteAction.Complete:
+ autoComplete.confirmCompletion();
+ handled = true;
+ break;
+ case AutocompleteAction.PrevSelection:
+ autoComplete.selectPreviousSelection();
+ handled = true;
+ break;
+ case AutocompleteAction.NextSelection:
+ autoComplete.selectNextSelection();
+ handled = true;
+ break;
+ case AutocompleteAction.Cancel:
+ autoComplete.onEscape(event);
+ handled = true;
+ break;
+ default:
+ return; // don't preventDefault on anything else
+ }
+ } else if (autocompleteAction === AutocompleteAction.ForceComplete && !this.state.showVisualBell) {
+ // there is no current autocomplete window, try to open it
+ this.tabCompleteName();
+ handled = true;
+ } else if (event.key === Key.BACKSPACE || event.key === Key.DELETE) {
+ this.formatBarRef.current.hide();
+ }
+
+ if (handled) {
+ event.preventDefault();
+ event.stopPropagation();
+ return;
+ }
+
const action = getKeyBindingsManager().getMessageComposerAction(event);
switch (action) {
case MessageComposerAction.FormatBold:
@@ -507,42 +550,6 @@ export default class BasicMessageEditor extends React.Component
handled = true;
break;
}
- if (handled) {
- event.preventDefault();
- event.stopPropagation();
- return;
- }
-
- const autocompleteAction = getKeyBindingsManager().getAutocompleteAction(event);
- if (model.autoComplete && model.autoComplete.hasCompletions()) {
- const autoComplete = model.autoComplete;
- switch (autocompleteAction) {
- case AutocompleteAction.CompleteOrPrevSelection:
- case AutocompleteAction.PrevSelection:
- autoComplete.selectPreviousSelection();
- handled = true;
- break;
- case AutocompleteAction.CompleteOrNextSelection:
- case AutocompleteAction.NextSelection:
- autoComplete.selectNextSelection();
- handled = true;
- break;
- case AutocompleteAction.Cancel:
- autoComplete.onEscape(event);
- handled = true;
- break;
- default:
- return; // don't preventDefault on anything else
- }
- } else if (autocompleteAction === AutocompleteAction.CompleteOrPrevSelection
- || autocompleteAction === AutocompleteAction.CompleteOrNextSelection) {
- // there is no current autocomplete window, try to open it
- this.tabCompleteName();
- handled = true;
- } else if (event.key === Key.BACKSPACE || event.key === Key.DELETE) {
- this.formatBarRef.current.hide();
- }
-
if (handled) {
event.preventDefault();
event.stopPropagation();
@@ -577,6 +584,8 @@ export default class BasicMessageEditor extends React.Component
this.setState({ showVisualBell: true });
model.autoComplete.close();
}
+ } else {
+ this.setState({ showVisualBell: true });
}
} catch (err) {
console.error(err);
@@ -592,9 +601,8 @@ export default class BasicMessageEditor extends React.Component
this.props.model.autoComplete.onComponentConfirm(completion);
};
- private onAutoCompleteSelectionChange = (completion: ICompletion, completionIndex: number): void => {
+ private onAutoCompleteSelectionChange = (completionIndex: number): void => {
this.modifiedFlag = true;
- this.props.model.autoComplete.onComponentSelectionChange(completion);
this.setState({ completionIndex });
};
@@ -718,6 +726,11 @@ export default class BasicMessageEditor extends React.Component
};
const { completionIndex } = this.state;
+ const hasAutocomplete = Boolean(this.state.autoComplete);
+ let activeDescendant;
+ if (hasAutocomplete && completionIndex >= 0) {
+ activeDescendant = generateCompletionDomId(completionIndex);
+ }
return (
{ autoComplete }
@@ -736,10 +749,11 @@ export default class BasicMessageEditor extends React.Component
aria-label={this.props.label}
role="textbox"
aria-multiline="true"
- aria-autocomplete="both"
+ aria-autocomplete="list"
aria-haspopup="listbox"
- aria-expanded={Boolean(this.state.autoComplete)}
- aria-activedescendant={completionIndex >= 0 ? generateCompletionDomId(completionIndex) : undefined}
+ aria-expanded={hasAutocomplete}
+ aria-owns="mx_Autocomplete"
+ aria-activedescendant={activeDescendant}
dir="auto"
aria-disabled={this.props.disabled}
/>
diff --git a/src/components/views/rooms/EventTile.tsx b/src/components/views/rooms/EventTile.tsx
index 884d004551..301e33ec42 100644
--- a/src/components/views/rooms/EventTile.tsx
+++ b/src/components/views/rooms/EventTile.tsx
@@ -932,8 +932,11 @@ export default class EventTile extends React.Component {
} else if (this.props.layout == Layout.IRC) {
avatarSize = 14;
needsSenderProfile = true;
- } else if (this.props.continuation && this.props.tileShape !== TileShape.FileGrid) {
- // no avatar or sender profile for continuation messages
+ } else if (
+ (this.props.continuation && this.props.tileShape !== TileShape.FileGrid) ||
+ this.props.mxEvent.getType() === EventType.CallInvite
+ ) {
+ // no avatar or sender profile for continuation messages and call tiles
avatarSize = 0;
needsSenderProfile = false;
} else {
diff --git a/src/editor/autocomplete.ts b/src/editor/autocomplete.ts
index bf8f457d0c..10e1c60695 100644
--- a/src/editor/autocomplete.ts
+++ b/src/editor/autocomplete.ts
@@ -32,7 +32,6 @@ export type GetAutocompleterComponent = () => Autocomplete;
export type UpdateQuery = (test: string) => Promise;
export default class AutocompleteWrapperModel {
- private queryPart: Part;
private partIndex: number;
constructor(
@@ -45,10 +44,6 @@ export default class AutocompleteWrapperModel {
public onEscape(e: KeyboardEvent): void {
this.getAutocompleterComponent().onEscape(e);
- this.updateCallback({
- replaceParts: [this.partCreator.plain(this.queryPart.text)],
- close: true,
- });
}
public close(): void {
@@ -64,7 +59,8 @@ export default class AutocompleteWrapperModel {
return ac && ac.countCompletions() > 0;
}
- public onEnter(): void {
+ public async confirmCompletion(): Promise {
+ await this.getAutocompleterComponent().onConfirmCompletion();
this.updateCallback({ close: true });
}
@@ -76,8 +72,6 @@ export default class AutocompleteWrapperModel {
if (acComponent.countCompletions() === 0) {
// Force completions to show for the text currently entered
await acComponent.forceComplete();
- // Select the first item by moving "down"
- await acComponent.moveSelection(+1);
}
}
@@ -90,25 +84,10 @@ export default class AutocompleteWrapperModel {
}
public onPartUpdate(part: Part, pos: DocumentPosition): Promise {
- // cache the typed value and caret here
- // so we can restore it in onComponentSelectionChange when the value is undefined (meaning it should be the typed text)
- this.queryPart = part;
this.partIndex = pos.index;
return this.updateQuery(part.text);
}
- public onComponentSelectionChange(completion: ICompletion): void {
- if (!completion) {
- this.updateCallback({
- replaceParts: [this.queryPart],
- });
- } else {
- this.updateCallback({
- replaceParts: this.partForCompletion(completion),
- });
- }
- }
-
public onComponentConfirm(completion: ICompletion): void {
this.updateCallback({
replaceParts: this.partForCompletion(completion),
diff --git a/src/editor/model.ts b/src/editor/model.ts
index da1c2f47f5..212a7d17c0 100644
--- a/src/editor/model.ts
+++ b/src/editor/model.ts
@@ -237,7 +237,7 @@ export default class EditorModel {
}
}
}
- // not _autoComplete, only there if active part is autocomplete part
+ // not autoComplete, only there if active part is autocomplete part
if (this.autoComplete) {
return this.autoComplete.onPartUpdate(part, pos);
}
diff --git a/src/i18n/strings/en_EN.json b/src/i18n/strings/en_EN.json
index c9dbc00a78..33746e3f8b 100644
--- a/src/i18n/strings/en_EN.json
+++ b/src/i18n/strings/en_EN.json
@@ -1887,13 +1887,14 @@
"Connected": "Connected",
"Call declined": "Call declined",
"Call back": "Call back",
- "Missed call": "Missed call",
+ "No answer": "No answer",
"Could not connect media": "Could not connect media",
"Connection failed": "Connection failed",
"Their device couldn't start the camera or microphone": "Their device couldn't start the camera or microphone",
"An unknown error occurred": "An unknown error occurred",
"Unknown failure: %(reason)s)": "Unknown failure: %(reason)s)",
"Retry": "Retry",
+ "Missed call": "Missed call",
"The call is in an unknown state!": "The call is in an unknown state!",
"Sunday": "Sunday",
"Monday": "Monday",
diff --git a/src/settings/controllers/NotificationControllers.ts b/src/settings/controllers/NotificationControllers.ts
index cc5c040a89..09e4e1dd1a 100644
--- a/src/settings/controllers/NotificationControllers.ts
+++ b/src/settings/controllers/NotificationControllers.ts
@@ -21,6 +21,7 @@ import { SettingLevel } from "../SettingLevel";
// XXX: This feels wrong.
import { PushProcessor } from "matrix-js-sdk/src/pushprocessor";
+import { PushRuleActionName } from "matrix-js-sdk/src/@types/PushRules";
// .m.rule.master being enabled means all events match that push rule
// default action on this rule is dont_notify, but it could be something else
@@ -35,7 +36,7 @@ export function isPushNotifyDisabled(): boolean {
}
// If the rule is enabled then check it does not notify on everything
- return masterRule.enabled && !masterRule.actions.includes("notify");
+ return masterRule.enabled && !masterRule.actions.includes(PushRuleActionName.Notify);
}
function getNotifier(): any { // TODO: [TS] Formal type that doesn't cause a cyclical reference.
diff --git a/src/utils/EventUtils.ts b/src/utils/EventUtils.ts
index e2af1c7464..7aef05c523 100644
--- a/src/utils/EventUtils.ts
+++ b/src/utils/EventUtils.ts
@@ -116,14 +116,14 @@ export function getEventDisplayInfo(mxEvent: MatrixEvent): {
(eventType === EventType.RoomMessage && msgtype && msgtype.startsWith("m.key.verification")) ||
(eventType === EventType.RoomCreate) ||
(eventType === EventType.RoomEncryption) ||
- (eventType === EventType.CallInvite) ||
(tileHandler === "messages.MJitsiWidgetEvent")
);
let isInfoMessage = (
!isBubbleMessage &&
eventType !== EventType.RoomMessage &&
eventType !== EventType.Sticker &&
- eventType !== EventType.RoomCreate
+ eventType !== EventType.RoomCreate &&
+ eventType !== EventType.CallInvite
);
// If we're showing hidden events in the timeline, we should use the