Merge remote-tracking branch 'origin/develop' into dbkr/crosssigningpanel_ts

This commit is contained in:
David Baker 2021-08-13 18:11:54 +01:00
commit c79b6a412d
29 changed files with 499 additions and 488 deletions

View file

@ -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 <Loader />;
} else {
return null;
}
}
const StageComponent = getEntryComponentForLoginType(stage);
return (
<StageComponent
ref={this._stageComponent}
loginType={stage}
matrixClient={this.props.matrixClient}
authSessionId={this._authLogic.getSessionId()}
clientSecret={this._authLogic.getClientSecret()}
stageParams={this._authLogic.getStageParams(stage)}
submitAuthDict={this._submitAuthDict}
errorText={this.state.stageErrorText}
busy={this.state.busy}
inputs={this.props.inputs}
stageState={this.state.stageState}
fail={this._onAuthStageFailed}
setEmailSid={this._setEmailSid}
showContinue={!this.props.continueIsManaged}
onPhaseChange={this._onPhaseChange}
continueText={this.props.continueText}
continueKind={this.props.continueKind}
onCancel={this._onStageCancel}
/>
);
}
_onAuthStageFailed = e => {
this.props.onAuthFinished(false, e);
};
_setEmailSid = sid => {
this._authLogic.setEmailSid(sid);
};
render() {
let error = null;
if (this.state.errorText) {
error = (
<div className="error">
{ this.state.errorText }
</div>
);
}
return (
<div>
<div>
{ this._renderCurrentStage() }
{ error }
</div>
</div>
);
}
}

View file

@ -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<IAuthData>;
// 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<IProps, IState> {
private readonly authLogic: InteractiveAuth;
private readonly intervalId: number = null;
private readonly stageComponent = createRef<IStageComponent>();
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<IAuthData> => {
// 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 <Spinner />;
} else {
return null;
}
}
const StageComponent = getEntryComponentForLoginType(stage);
return (
<StageComponent
ref={this.stageComponent as any}
loginType={stage}
matrixClient={this.props.matrixClient}
authSessionId={this.authLogic.getSessionId()}
clientSecret={this.authLogic.getClientSecret()}
stageParams={this.authLogic.getStageParams(stage)}
submitAuthDict={this.submitAuthDict}
errorText={this.state.stageErrorText}
busy={this.state.busy}
inputs={this.props.inputs}
stageState={this.state.stageState}
fail={this.onAuthStageFailed}
setEmailSid={this.setEmailSid}
showContinue={!this.props.continueIsManaged}
onPhaseChange={this.onPhaseChange}
continueText={this.props.continueText}
continueKind={this.props.continueKind}
onCancel={this.onStageCancel}
/>
);
}
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 = (
<div className="error">
{ this.state.errorText }
</div>
);
}
return (
<div>
<div>
{ this.renderCurrentStage() }
{ error }
</div>
</div>
);
}
}

View file

@ -529,24 +529,24 @@ class LoggedInView extends React.Component<IProps, IState> {
}
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
}
}
};

View file

@ -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<IAuthEntryProps> {
}
}
export default function getEntryComponentForLoginType(loginType: AuthType): typeof React.Component {
export interface IStageComponentProps extends IAuthEntryProps {
clientSecret?: string;
stageParams?: Record<string, any>;
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<React.PropsWithRef<IStageComponentProps>> {
tryContinue?(): void;
attemptFailed?(): void;
focus?(): void;
}
export default function getEntryComponentForLoginType(loginType: AuthType): IStageComponent {
switch (loginType) {
case AuthType.Password:
return PasswordAuthEntry;

View file

@ -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<IProps, ISt
this.initAuth(/* shouldErase= */false);
}
private onStagePhaseChange = (stage: string, phase: string): void => {
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<IProps, ISt
this.setState({ errStr: _t("There was a problem communicating with the server. Please try again.") });
};
private onUIAuthComplete = (auth: any): void => {
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<IProps, ISt
<InteractiveAuth
matrixClient={MatrixClientPeg.get()}
authData={this.state.authData}
makeRequest={this.onUIAuthComplete}
// XXX: onUIAuthComplete breaches the expected method contract, it gets away with it because it
// knows the entire app is about to die as a result of the account deactivation.
makeRequest={this.onUIAuthComplete as any}
onAuthFinished={this.onUIAuthFinished}
onStagePhaseChange={this.onStagePhaseChange}
continueText={this.state.continueText}

View file

@ -175,7 +175,7 @@ export default class CallEvent extends React.PureComponent<IProps, IState> {
} else if (hangupReason === CallErrorCode.InviteTimeout) {
return (
<div className="mx_CallEvent_content">
{ _t("Missed call") }
{ _t("No answer") }
{ this.renderCallBackButton(_t("Call back")) }
</div>
);
@ -249,10 +249,9 @@ export default class CallEvent extends React.PureComponent<IProps, IState> {
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) {

View file

@ -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<IProps, IState> {
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<IProps, IState> {
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<IProps, IState> {
this.autocompleter.destroy();
}
complete(query: string, selection: ISelectionRange) {
private complete(query: string, selection: ISelectionRange): Promise<void> {
this.queryRequested = query;
if (this.debounceCompletionsRequest) {
clearTimeout(this.debounceCompletionsRequest);
@ -115,7 +114,7 @@ export default class Autocomplete extends React.PureComponent<IProps, IState> {
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<IProps, IState> {
});
}
processQuery(query: string, selection: ISelectionRange) {
private processQuery(query: string, selection: ISelectionRange): Promise<void> {
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<IProps, IState> {
});
}
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<IProps, IState> {
});
}
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<IProps, IState> {
this.hide();
}
hide = () => {
private hide = (): void => {
this.setState({
hide: true,
selectionOffset: 0,
selectionOffset: 1,
completions: [],
completionList: [],
});
};
forceComplete() {
public forceComplete(): Promise<number> {
return new Promise((resolve) => {
this.setState({
forceComplete: true,
@ -235,8 +239,13 @@ export default class Autocomplete extends React.PureComponent<IProps, IState> {
});
}
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<IProps, IState> {
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<IProps, IState> {
});
return completions.length > 0 ? (
<div key={i} className="mx_Autocomplete_ProviderSection">
<div key={i} className="mx_Autocomplete_ProviderSection" role="presentation">
<div className="mx_Autocomplete_provider_name">{ completionResult.provider.getName() }</div>
{ completionResult.provider.renderCompletions(completions) }
</div>
@ -300,7 +309,7 @@ export default class Autocomplete extends React.PureComponent<IProps, IState> {
}).filter((completion) => !!completion);
return !this.state.hide && renderedCompletions.length > 0 ? (
<div className="mx_Autocomplete" ref={this.containerRef}>
<div id="mx_Autocomplete" className="mx_Autocomplete" ref={this.containerRef} role="listbox">
{ renderedCompletions }
</div>
) : null;

View file

@ -133,6 +133,7 @@ export default class BasicMessageEditor extends React.Component<IProps, IState>
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<IProps, IState>
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<IProps, IState>
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<IProps, IState>
}
}
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<IProps, IState>
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<IProps, IState>
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<IProps, IState>
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<IProps, IState>
};
const { completionIndex } = this.state;
const hasAutocomplete = Boolean(this.state.autoComplete);
let activeDescendant;
if (hasAutocomplete && completionIndex >= 0) {
activeDescendant = generateCompletionDomId(completionIndex);
}
return (<div className={wrapperClasses}>
{ autoComplete }
@ -736,10 +749,11 @@ export default class BasicMessageEditor extends React.Component<IProps, IState>
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}
/>

View file

@ -932,8 +932,11 @@ export default class EventTile extends React.Component<IProps, IState> {
} 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 {