Merge remote-tracking branch 'origin/develop' into last-admin-leave-room-warning

This commit is contained in:
David Baker 2024-03-21 11:39:47 +00:00
commit 0fdb300858
2886 changed files with 393845 additions and 234022 deletions

View file

@ -14,43 +14,47 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import React, { JSXElementConstructor } from "react";
import { JSXElementConstructor } from "react";
// Based on https://stackoverflow.com/a/53229857/3532235
export type Without<T, U> = {[P in Exclude<keyof T, keyof U>]?: never};
export type XOR<T, U> = (T | U) extends object ? (Without<T, U> & U) | (Without<U, T> & T) : T | U;
export type Writeable<T> = { -readonly [P in keyof T]: T[P] };
export type { NonEmptyArray, XOR, Writeable } from "matrix-js-sdk/src/matrix";
export type ComponentClass = keyof JSX.IntrinsicElements | JSXElementConstructor<any>;
export type ReactAnyComponent = React.Component | React.ExoticComponent;
// Based on https://stackoverflow.com/a/58436959
type Join<K, P> = K extends string | number ?
P extends string | number ?
`${K}${"" extends P ? "" : "."}${P}`
: never : never;
type Prev = [never, 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, ...0[]];
export type Leaves<T, D extends number = 5> = [D] extends [never] ? never : T extends object ?
{ [K in keyof T]-?: Join<K, Leaves<T[K], Prev[D]>> }[keyof T] : "";
export type { Leaves } from "matrix-web-i18n";
export type RecursivePartial<T> = {
[P in keyof T]?:
T[P] extends (infer U)[] ? RecursivePartial<U>[] :
T[P] extends object ? RecursivePartial<T[P]> :
T[P];
[P in keyof T]?: T[P] extends (infer U)[]
? RecursivePartial<U>[]
: T[P] extends object
? RecursivePartial<T[P]>
: T[P];
};
// Inspired by https://stackoverflow.com/a/60206860
export type KeysWithObjectShape<Input> = {
[P in keyof Input]: Input[P] extends object
// Arrays are counted as objects - exclude them
? (Input[P] extends Array<unknown> ? never : P)
: never;
}[keyof Input];
export type KeysStartingWith<Input extends object, Str extends string> = {
// eslint-disable-next-line @typescript-eslint/no-unused-vars
[P in keyof Input]: P extends `${Str}${infer _X}` ? P : never; // we don't use _X
}[keyof Input];
export type Defaultize<P, D> = P extends any
? string extends keyof P
? P
: Pick<P, Exclude<keyof P, keyof D>> &
Partial<Pick<P, Extract<keyof P, keyof D>>> &
Partial<Pick<D, Exclude<keyof D, keyof P>>>
: never;
export type DeepReadonly<T> = T extends (infer R)[]
? DeepReadonlyArray<R>
: T extends Function
? T
: T extends object
? DeepReadonlyObject<T>
: T;
interface DeepReadonlyArray<T> extends ReadonlyArray<DeepReadonly<T>> {}
type DeepReadonlyObject<T> = {
readonly [P in keyof T]: DeepReadonly<T[P]>;
};
export type AtLeastOne<T, U = { [K in keyof T]: Pick<T, K> }> = Partial<T> & U[keyof U];

54
src/@types/commonmark.ts Normal file
View file

@ -0,0 +1,54 @@
/*
Copyright 2022 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import * as commonmark from "commonmark";
declare module "commonmark" {
export type Attr = [key: string, value: string];
/* eslint-disable @typescript-eslint/naming-convention */
export interface HtmlRenderer {
// As far as @types/commonmark is concerned, these are not public, so add them
// https://github.com/commonmark/commonmark.js/blob/master/lib/render/html.js#L272-L296
text: (this: commonmark.HtmlRenderer, node: commonmark.Node) => void;
html_inline: (this: commonmark.HtmlRenderer, node: commonmark.Node) => void;
html_block: (this: commonmark.HtmlRenderer, node: commonmark.Node) => void;
// softbreak: () => void; // This one can't be correctly specified as it is wrongly defined in @types/commonmark
linebreak: (this: commonmark.HtmlRenderer) => void;
link: (this: commonmark.HtmlRenderer, node: commonmark.Node, entering: boolean) => void;
image: (this: commonmark.HtmlRenderer, node: commonmark.Node, entering: boolean) => void;
emph: (this: commonmark.HtmlRenderer, node: commonmark.Node, entering: boolean) => void;
strong: (this: commonmark.HtmlRenderer, node: commonmark.Node, entering: boolean) => void;
paragraph: (this: commonmark.HtmlRenderer, node: commonmark.Node, entering: boolean) => void;
heading: (this: commonmark.HtmlRenderer, node: commonmark.Node, entering: boolean) => void;
code: (this: commonmark.HtmlRenderer, node: commonmark.Node) => void;
code_block: (this: commonmark.HtmlRenderer, node: commonmark.Node) => void;
thematic_break: (this: commonmark.HtmlRenderer, node: commonmark.Node) => void;
block_quote: (this: commonmark.HtmlRenderer, node: commonmark.Node, entering: boolean) => void;
list: (this: commonmark.HtmlRenderer, node: commonmark.Node, entering: boolean) => void;
item: (this: commonmark.HtmlRenderer, node: commonmark.Node, entering: boolean) => void;
custom_inline: (this: commonmark.HtmlRenderer, node: commonmark.Node, entering: boolean) => void;
custom_block: (this: commonmark.HtmlRenderer, node: commonmark.Node, entering: boolean) => void;
esc: (s: string) => string;
out: (this: commonmark.HtmlRenderer, text: string) => void;
tag: (this: commonmark.HtmlRenderer, name: string, attrs?: Attr[], selfClosing?: boolean) => void;
attrs: (this: commonmark.HtmlRenderer, node: commonmark.Node) => Attr[];
// These are inherited from the base Renderer
lit: (this: commonmark.HtmlRenderer, text: string) => void;
cr: (this: commonmark.HtmlRenderer) => void;
}
/* eslint-enable @typescript-eslint/naming-convention */
}

View file

@ -20,14 +20,13 @@ declare module "diff-dom" {
name: string;
text?: string;
route: number[];
value: string;
element: unknown;
oldValue: string;
newValue: string;
value: HTMLElement | string;
element: HTMLElement | string;
oldValue: HTMLElement | string;
newValue: HTMLElement | string;
}
interface IOpts {
}
interface IOpts {}
export class DiffDOM {
public constructor(opts?: IOpts);

View file

@ -14,9 +14,8 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
// eslint-disable-next-line no-restricted-imports
import "matrix-js-sdk/src/@types/global"; // load matrix-js-sdk's type extensions first
// Load types for the WG CSS Font Loading APIs https://github.com/Microsoft/TypeScript/issues/13569
import "@types/css-font-loading-module";
import "@types/modernizr";
import ContentMessages from "../ContentMessages";
@ -51,6 +50,7 @@ import ActiveWidgetStore from "../stores/ActiveWidgetStore";
import AutoRageshakeStore from "../stores/AutoRageshakeStore";
import { IConfigOptions } from "../IConfigOptions";
import { MatrixDispatcher } from "../dispatcher/dispatcher";
import { DeepReadonly } from "./common";
/* eslint-disable @typescript-eslint/naming-convention */
@ -58,10 +58,7 @@ declare global {
interface Window {
matrixChat: ReturnType<Renderer>;
mxMatrixClientPeg: IMatrixClientPeg;
Olm: {
init: () => Promise<void>;
};
mxReactSdkConfig: IConfigOptions;
mxReactSdkConfig: DeepReadonly<IConfigOptions>;
// Needed for Safari, unknown to TypeScript
webkitAudioContext: typeof AudioContext;
@ -73,7 +70,7 @@ declare global {
// https://github.com/microsoft/TypeScript-DOM-lib-generator/issues/1029#issuecomment-869224737
// https://developer.mozilla.org/en-US/docs/Web/API/OffscreenCanvas
OffscreenCanvas?: {
new(width: number, height: number): OffscreenCanvas;
new (width: number, height: number): OffscreenCanvas;
};
mxContentMessages: ContentMessages;
@ -149,28 +146,15 @@ declare global {
// https://developer.mozilla.org/en-US/docs/Web/API/OffscreenCanvas
interface OffscreenCanvas {
height: number;
width: number;
getContext: HTMLCanvasElement["getContext"];
convertToBlob(opts?: {
type?: string;
quality?: number;
}): Promise<Blob>;
transferToImageBitmap(): ImageBitmap;
convertToBlob(opts?: { type?: string; quality?: number }): Promise<Blob>;
}
interface HTMLAudioElement {
type?: string;
// sinkId & setSinkId are experimental and typescript doesn't know about them
sinkId: string;
setSinkId(outputId: string): void;
}
interface HTMLVideoElement {
type?: string;
// sinkId & setSinkId are experimental and typescript doesn't know about them
sinkId: string;
setSinkId(outputId: string): void;
}
// Add Chrome-specific `instant` ScrollBehaviour
@ -194,6 +178,11 @@ declare global {
}
interface Error {
// Standard
// https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Error/cause
cause?: unknown;
// Non-standard
// https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Error/fileName
fileName?: string;
// https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Error/lineNumber
@ -202,14 +191,26 @@ declare global {
columnNumber?: number;
}
// We can remove these pieces if we ever update to `target: "es2022"` in our
// TypeScript config which supports the new `cause` property, see
// https://github.com/vector-im/element-web/issues/24913
interface ErrorOptions {
// https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Error/cause
cause?: unknown;
}
interface ErrorConstructor {
new (message?: string, options?: ErrorOptions): Error;
(message?: string, options?: ErrorOptions): Error;
}
// eslint-disable-next-line no-var
var Error: ErrorConstructor;
// https://github.com/microsoft/TypeScript/issues/28308#issuecomment-650802278
interface AudioWorkletProcessor {
readonly port: MessagePort;
process(
inputs: Float32Array[][],
outputs: Float32Array[][],
parameters: Record<string, Float32Array>
): boolean;
process(inputs: Float32Array[][], outputs: Float32Array[][], parameters: Record<string, Float32Array>): boolean;
}
// https://github.com/microsoft/TypeScript/issues/28308#issuecomment-650802278
@ -226,12 +227,10 @@ declare global {
// https://github.com/microsoft/TypeScript/issues/28308#issuecomment-650802278
function registerProcessor(
name: string,
processorCtor: (new (
options?: AudioWorkletNodeOptions
) => AudioWorkletProcessor) & {
processorCtor: (new (options?: AudioWorkletNodeOptions) => AudioWorkletProcessor) & {
parameterDescriptors?: AudioParamDescriptor[];
}
);
},
): void;
// eslint-disable-next-line no-var
var grecaptcha:

53
src/@types/matrix-js-sdk.d.ts vendored Normal file
View file

@ -0,0 +1,53 @@
/*
Copyright 2024 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 type { IWidget } from "matrix-widget-api";
import type { BLURHASH_FIELD } from "../utils/image-media";
import type { JitsiCallMemberEventType, JitsiCallMemberContent } from "../call-types";
import type { ILayoutStateEvent, WIDGET_LAYOUT_EVENT_TYPE } from "../stores/widgets/types";
import type { VoiceBroadcastInfoEventContent, VoiceBroadcastInfoEventType } from "../voice-broadcast/types";
// Matrix JS SDK extensions
declare module "matrix-js-sdk/src/types" {
export interface FileInfo {
/**
* @see https://github.com/matrix-org/matrix-spec-proposals/pull/2448
*/
[BLURHASH_FIELD]?: string;
}
export interface StateEvents {
// Jitsi-backed video room state events
[JitsiCallMemberEventType]: JitsiCallMemberContent;
// Unstable widgets state events
"im.vector.modular.widgets": IWidget | {};
[WIDGET_LAYOUT_EVENT_TYPE]: ILayoutStateEvent;
// Unstable voice broadcast state events
[VoiceBroadcastInfoEventType]: VoiceBroadcastInfoEventContent;
// Element custom state events
"im.vector.web.settings": Record<string, any>;
"org.matrix.room.preview_urls": { disable: boolean };
// XXX unspecced usages of `m.room.*` events
"m.room.plumbing": {
status: string;
};
"m.room.bot.options": unknown;
}
}

65
src/@types/opus-recorder.d.ts vendored Normal file
View file

@ -0,0 +1,65 @@
/*
Copyright 2023 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.
*/
declare module "opus-recorder/dist/recorder.min.js" {
export default class Recorder {
public static isRecordingSupported(): boolean;
public constructor(config: {
bufferLength?: number;
encoderApplication?: number;
encoderFrameSize?: number;
encoderPath?: string;
encoderSampleRate?: number;
encoderBitRate?: number;
maxFramesPerPage?: number;
mediaTrackConstraints?: boolean;
monitorGain?: number;
numberOfChannels?: number;
recordingGain?: number;
resampleQuality?: number;
streamPages?: boolean;
wavBitDepth?: number;
sourceNode?: MediaStreamAudioSourceNode;
encoderComplexity?: number;
});
public ondataavailable?(data: ArrayBuffer): void;
public readonly encodedSamplePosition: number;
public start(): Promise<void>;
public stop(): Promise<void>;
public close(): void;
}
}
declare module "opus-recorder/dist/encoderWorker.min.js" {
const path: string;
export default path;
}
declare module "opus-recorder/dist/waveWorker.min.js" {
const path: string;
export default path;
}
declare module "opus-recorder/dist/decoderWorker.min.js" {
const path: string;
export default path;
}

View file

@ -15,22 +15,40 @@ limitations under the License.
*/
// This is intended to fix re-resizer because of its unguarded `instanceof TouchEvent` checks.
export function polyfillTouchEvent() {
export function polyfillTouchEvent(): void {
// Firefox doesn't have touch events without touch devices being present, so create a fake
// one we can rely on lying about.
if (!window.TouchEvent) {
// We have no intention of actually using this, so just lie.
window.TouchEvent = class TouchEvent extends UIEvent {
public get altKey(): boolean { return false; }
public get changedTouches(): any { return []; }
public get ctrlKey(): boolean { return false; }
public get metaKey(): boolean { return false; }
public get shiftKey(): boolean { return false; }
public get targetTouches(): any { return []; }
public get touches(): any { return []; }
public get rotation(): number { return 0.0; }
public get scale(): number { return 0.0; }
constructor(eventType: string, params?: any) {
public get altKey(): boolean {
return false;
}
public get changedTouches(): any {
return [];
}
public get ctrlKey(): boolean {
return false;
}
public get metaKey(): boolean {
return false;
}
public get shiftKey(): boolean {
return false;
}
public get targetTouches(): any {
return [];
}
public get touches(): any {
return [];
}
public get rotation(): number {
return 0.0;
}
public get scale(): number {
return 0.0;
}
public constructor(eventType: string, params?: any) {
super(eventType, params);
}
};

View file

@ -14,7 +14,7 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
declare module '!!raw-loader!*' {
declare module "!!raw-loader!*" {
const contents: string;
export default contents;
}

24
src/@types/react.d.ts vendored Normal file
View file

@ -0,0 +1,24 @@
/*
Copyright 2023 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import React, { PropsWithChildren } from "react";
declare module "react" {
// Fix forwardRef types for Generic components - https://stackoverflow.com/a/58473012
function forwardRef<T, P = {}>(
render: (props: PropsWithChildren<P>, ref: React.ForwardedRef<T>) => React.ReactElement | null,
): (props: P & React.RefAttributes<T>) => React.ReactElement | null;
}

View file

@ -14,7 +14,7 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import sanitizeHtml from 'sanitize-html';
import sanitizeHtml from "sanitize-html";
export interface IExtendedSanitizeOptions extends sanitizeHtml.IOptions {
// This option only exists in 2.x RCs so far, so not yet present in the

View file

@ -16,7 +16,7 @@ limitations under the License.
declare module "*.worker.ts" {
class WebpackWorker extends Worker {
constructor();
public constructor();
}
export default WebpackWorker;

View file

@ -16,19 +16,40 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import { IRequestMsisdnTokenResponse, IRequestTokenResponse } from "matrix-js-sdk/src/matrix";
import {
IAddThreePidOnlyBody,
IAuthData,
IRequestMsisdnTokenResponse,
IRequestTokenResponse,
MatrixClient,
MatrixError,
HTTPError,
IThreepid,
} from "matrix-js-sdk/src/matrix";
import { MatrixClientPeg } from './MatrixClientPeg';
import Modal from './Modal';
import { _t } from './languageHandler';
import IdentityAuthClient from './IdentityAuthClient';
import Modal from "./Modal";
import { _t, UserFriendlyError } from "./languageHandler";
import IdentityAuthClient from "./IdentityAuthClient";
import { SSOAuthEntry } from "./components/views/auth/InteractiveAuthEntryComponents";
import InteractiveAuthDialog from "./components/views/dialogs/InteractiveAuthDialog";
import InteractiveAuthDialog, { InteractiveAuthDialogProps } from "./components/views/dialogs/InteractiveAuthDialog";
function getIdServerDomain(): string {
return MatrixClientPeg.get().idBaseUrl.split("://")[1];
function getIdServerDomain(matrixClient: MatrixClient): string {
const idBaseUrl = matrixClient.getIdentityServerUrl(true);
if (!idBaseUrl) {
throw new UserFriendlyError("settings|general|identity_server_not_set");
}
return idBaseUrl;
}
export type Binding = {
bind: boolean;
label: string;
errorTitle: string;
};
// IThreepid modified stripping validated_at and added_at as they aren't necessary for our UI
export type ThirdPartyIdentifier = Omit<IThreepid, "validated_at" | "added_at">;
/**
* Allows a user to add a third party identifier to their homeserver and,
* optionally, the identity servers.
@ -42,13 +63,13 @@ function getIdServerDomain(): string {
* https://gist.github.com/jryans/839a09bf0c5a70e2f36ed990d50ed928
*/
export default class AddThreepid {
private sessionId: string;
private submitUrl: string;
private clientSecret: string;
private bind: boolean;
private sessionId?: string;
private submitUrl?: string;
private bind = false;
private readonly clientSecret: string;
constructor() {
this.clientSecret = MatrixClientPeg.get().generateClientSecret();
public constructor(private readonly matrixClient: MatrixClient) {
this.clientSecret = matrixClient.generateClientSecret();
}
/**
@ -57,18 +78,18 @@ export default class AddThreepid {
* @param {string} emailAddress The email address to add
* @return {Promise} Resolves when the email has been sent. Then call checkEmailLinkClicked().
*/
public addEmailAddress(emailAddress: string): Promise<IRequestTokenResponse> {
return MatrixClientPeg.get().requestAdd3pidEmailToken(emailAddress, this.clientSecret, 1).then((res) => {
public async addEmailAddress(emailAddress: string): Promise<IRequestTokenResponse> {
try {
const res = await this.matrixClient.requestAdd3pidEmailToken(emailAddress, this.clientSecret, 1);
this.sessionId = res.sid;
return res;
}, function(err) {
if (err.errcode === 'M_THREEPID_IN_USE') {
err.message = _t('This email address is already in use');
} else if (err.httpStatus) {
err.message = err.message + ` (Status ${err.httpStatus})`;
} catch (err) {
if (err instanceof MatrixError && err.errcode === "M_THREEPID_IN_USE") {
throw new UserFriendlyError("settings|general|email_address_in_use", { cause: err });
}
// Otherwise, just blurt out the same error
throw err;
});
}
}
/**
@ -79,27 +100,25 @@ export default class AddThreepid {
*/
public async bindEmailAddress(emailAddress: string): Promise<IRequestTokenResponse> {
this.bind = true;
if (await MatrixClientPeg.get().doesServerSupportSeparateAddAndBind()) {
// For separate bind, request a token directly from the IS.
const authClient = new IdentityAuthClient();
const identityAccessToken = await authClient.getAccessToken();
return MatrixClientPeg.get().requestEmailToken(
emailAddress, this.clientSecret, 1,
undefined, identityAccessToken,
).then((res) => {
this.sessionId = res.sid;
return res;
}, function(err) {
if (err.errcode === 'M_THREEPID_IN_USE') {
err.message = _t('This email address is already in use');
} else if (err.httpStatus) {
err.message = err.message + ` (Status ${err.httpStatus})`;
}
throw err;
});
} else {
// For tangled bind, request a token via the HS.
return this.addEmailAddress(emailAddress);
// For separate bind, request a token directly from the IS.
const authClient = new IdentityAuthClient();
const identityAccessToken = (await authClient.getAccessToken()) ?? undefined;
try {
const res = await this.matrixClient.requestEmailToken(
emailAddress,
this.clientSecret,
1,
undefined,
identityAccessToken,
);
this.sessionId = res.sid;
return res;
} catch (err) {
if (err instanceof MatrixError && err.errcode === "M_THREEPID_IN_USE") {
throw new UserFriendlyError("settings|general|email_address_in_use", { cause: err });
}
// Otherwise, just blurt out the same error
throw err;
}
}
@ -110,21 +129,24 @@ export default class AddThreepid {
* @param {string} phoneNumber The national or international formatted phone number to add
* @return {Promise} Resolves when the text message has been sent. Then call haveMsisdnToken().
*/
public addMsisdn(phoneCountry: string, phoneNumber: string): Promise<IRequestMsisdnTokenResponse> {
return MatrixClientPeg.get().requestAdd3pidMsisdnToken(
phoneCountry, phoneNumber, this.clientSecret, 1,
).then((res) => {
public async addMsisdn(phoneCountry: string, phoneNumber: string): Promise<IRequestMsisdnTokenResponse> {
try {
const res = await this.matrixClient.requestAdd3pidMsisdnToken(
phoneCountry,
phoneNumber,
this.clientSecret,
1,
);
this.sessionId = res.sid;
this.submitUrl = res.submit_url;
return res;
}, function(err) {
if (err.errcode === 'M_THREEPID_IN_USE') {
err.message = _t('This phone number is already in use');
} else if (err.httpStatus) {
err.message = err.message + ` (Status ${err.httpStatus})`;
} catch (err) {
if (err instanceof MatrixError && err.errcode === "M_THREEPID_IN_USE") {
throw new UserFriendlyError("settings|general|msisdn_in_use", { cause: err });
}
// Otherwise, just blurt out the same error
throw err;
});
}
}
/**
@ -136,27 +158,26 @@ export default class AddThreepid {
*/
public async bindMsisdn(phoneCountry: string, phoneNumber: string): Promise<IRequestMsisdnTokenResponse> {
this.bind = true;
if (await MatrixClientPeg.get().doesServerSupportSeparateAddAndBind()) {
// For separate bind, request a token directly from the IS.
const authClient = new IdentityAuthClient();
const identityAccessToken = await authClient.getAccessToken();
return MatrixClientPeg.get().requestMsisdnToken(
phoneCountry, phoneNumber, this.clientSecret, 1,
undefined, identityAccessToken,
).then((res) => {
this.sessionId = res.sid;
return res;
}, function(err) {
if (err.errcode === 'M_THREEPID_IN_USE') {
err.message = _t('This phone number is already in use');
} else if (err.httpStatus) {
err.message = err.message + ` (Status ${err.httpStatus})`;
}
throw err;
});
} else {
// For tangled bind, request a token via the HS.
return this.addMsisdn(phoneCountry, phoneNumber);
// For separate bind, request a token directly from the IS.
const authClient = new IdentityAuthClient();
const identityAccessToken = (await authClient.getAccessToken()) ?? undefined;
try {
const res = await this.matrixClient.requestMsisdnToken(
phoneCountry,
phoneNumber,
this.clientSecret,
1,
undefined,
identityAccessToken,
);
this.sessionId = res.sid;
return res;
} catch (err) {
if (err instanceof MatrixError && err.errcode === "M_THREEPID_IN_USE") {
throw new UserFriendlyError("settings|general|msisdn_in_use", { cause: err });
}
// Otherwise, just blurt out the same error
throw err;
}
}
@ -166,85 +187,79 @@ export default class AddThreepid {
* with a "message" property which contains a human-readable message detailing why
* the request failed.
*/
public async checkEmailLinkClicked(): Promise<any[]> {
public async checkEmailLinkClicked(): Promise<[success?: boolean, result?: IAuthData | Error | null]> {
try {
if (await MatrixClientPeg.get().doesServerSupportSeparateAddAndBind()) {
if (this.bind) {
const authClient = new IdentityAuthClient();
const identityAccessToken = await authClient.getAccessToken();
await MatrixClientPeg.get().bindThreePid({
sid: this.sessionId,
client_secret: this.clientSecret,
id_server: getIdServerDomain(),
id_access_token: identityAccessToken,
});
} else {
try {
await this.makeAddThreepidOnlyRequest();
// The spec has always required this to use UI auth but synapse briefly
// implemented it without, so this may just succeed and that's OK.
return;
} catch (e) {
if (e.httpStatus !== 401 || !e.data || !e.data.flows) {
// doesn't look like an interactive-auth failure
throw e;
}
const dialogAesthetics = {
[SSOAuthEntry.PHASE_PREAUTH]: {
title: _t("Use Single Sign On to continue"),
body: _t("Confirm adding this email address by using " +
"Single Sign On to prove your identity."),
continueText: _t("Single Sign On"),
continueKind: "primary",
},
[SSOAuthEntry.PHASE_POSTAUTH]: {
title: _t("Confirm adding email"),
body: _t("Click the button below to confirm adding this email address."),
continueText: _t("Confirm"),
continueKind: "primary",
},
};
const { finished } = Modal.createDialog(InteractiveAuthDialog, {
title: _t("Add Email Address"),
matrixClient: MatrixClientPeg.get(),
authData: e.data,
makeRequest: this.makeAddThreepidOnlyRequest,
aestheticsForStagePhases: {
[SSOAuthEntry.LOGIN_TYPE]: dialogAesthetics,
[SSOAuthEntry.UNSTABLE_LOGIN_TYPE]: dialogAesthetics,
},
});
return finished;
}
if (this.bind) {
const authClient = new IdentityAuthClient();
const identityAccessToken = await authClient.getAccessToken();
if (!identityAccessToken) {
throw new UserFriendlyError("settings|general|identity_server_no_token");
}
} else {
await MatrixClientPeg.get().addThreePid({
sid: this.sessionId,
await this.matrixClient.bindThreePid({
sid: this.sessionId!,
client_secret: this.clientSecret,
id_server: getIdServerDomain(),
}, this.bind);
id_server: getIdServerDomain(this.matrixClient),
id_access_token: identityAccessToken,
});
} else {
try {
await this.makeAddThreepidOnlyRequest();
// The spec has always required this to use UI auth but synapse briefly
// implemented it without, so this may just succeed and that's OK.
return [true];
} catch (err) {
if (!(err instanceof MatrixError) || err.httpStatus !== 401 || !err.data || !err.data.flows) {
// doesn't look like an interactive-auth failure
throw err;
}
const dialogAesthetics = {
[SSOAuthEntry.PHASE_PREAUTH]: {
title: _t("auth|uia|sso_title"),
body: _t("auth|uia|sso_body"),
continueText: _t("auth|sso"),
continueKind: "primary",
},
[SSOAuthEntry.PHASE_POSTAUTH]: {
title: _t("settings|general|confirm_adding_email_title"),
body: _t("settings|general|confirm_adding_email_body"),
continueText: _t("action|confirm"),
continueKind: "primary",
},
};
const { finished } = Modal.createDialog(InteractiveAuthDialog<{}>, {
title: _t("settings|general|add_email_dialog_title"),
matrixClient: this.matrixClient,
authData: err.data,
makeRequest: this.makeAddThreepidOnlyRequest,
aestheticsForStagePhases: {
[SSOAuthEntry.LOGIN_TYPE]: dialogAesthetics,
[SSOAuthEntry.UNSTABLE_LOGIN_TYPE]: dialogAesthetics,
},
} as InteractiveAuthDialogProps<IAddThreePidOnlyBody>);
return finished;
}
}
} catch (err) {
if (err.httpStatus === 401) {
err.message = _t('Failed to verify email address: make sure you clicked the link in the email');
} else if (err.httpStatus) {
err.message += ` (Status ${err.httpStatus})`;
if (err instanceof HTTPError && err.httpStatus === 401) {
throw new UserFriendlyError("settings|general|add_email_failed_verification", { cause: err });
}
// Otherwise, just blurt out the same error
throw err;
}
return [];
}
/**
* @param {{type: string, session?: string}} auth UI auth object
* @return {Promise<Object>} Response from /3pid/add call (in current spec, an empty object)
*/
private makeAddThreepidOnlyRequest = (auth?: {type: string, session?: string}): Promise<{}> => {
return MatrixClientPeg.get().addThreePidOnly({
sid: this.sessionId,
private makeAddThreepidOnlyRequest = (auth?: IAddThreePidOnlyBody["auth"] | null): Promise<{}> => {
return this.matrixClient.addThreePidOnly({
sid: this.sessionId!,
client_secret: this.clientSecret,
auth,
auth: auth ?? undefined,
});
};
@ -256,88 +271,75 @@ export default class AddThreepid {
* with a "message" property which contains a human-readable message detailing why
* the request failed.
*/
public async haveMsisdnToken(msisdnToken: string): Promise<any[]> {
public async haveMsisdnToken(
msisdnToken: string,
): Promise<[success?: boolean, result?: IAuthData | Error | null] | undefined> {
const authClient = new IdentityAuthClient();
const supportsSeparateAddAndBind =
await MatrixClientPeg.get().doesServerSupportSeparateAddAndBind();
let result;
if (this.submitUrl) {
result = await MatrixClientPeg.get().submitMsisdnTokenOtherUrl(
await this.matrixClient.submitMsisdnTokenOtherUrl(
this.submitUrl,
this.sessionId,
this.sessionId!,
this.clientSecret,
msisdnToken,
);
} else if (this.bind || !supportsSeparateAddAndBind) {
result = await MatrixClientPeg.get().submitMsisdnToken(
this.sessionId,
} else if (this.bind) {
await this.matrixClient.submitMsisdnToken(
this.sessionId!,
this.clientSecret,
msisdnToken,
await authClient.getAccessToken(),
);
} else {
throw new Error("The add / bind with MSISDN flow is misconfigured");
}
if (result.errcode) {
throw result;
throw new UserFriendlyError("settings|general|add_msisdn_misconfigured");
}
if (supportsSeparateAddAndBind) {
if (this.bind) {
await MatrixClientPeg.get().bindThreePid({
sid: this.sessionId,
client_secret: this.clientSecret,
id_server: getIdServerDomain(),
id_access_token: await authClient.getAccessToken(),
});
} else {
try {
await this.makeAddThreepidOnlyRequest();
// The spec has always required this to use UI auth but synapse briefly
// implemented it without, so this may just succeed and that's OK.
return;
} catch (e) {
if (e.httpStatus !== 401 || !e.data || !e.data.flows) {
// doesn't look like an interactive-auth failure
throw e;
}
const dialogAesthetics = {
[SSOAuthEntry.PHASE_PREAUTH]: {
title: _t("Use Single Sign On to continue"),
body: _t("Confirm adding this phone number by using " +
"Single Sign On to prove your identity."),
continueText: _t("Single Sign On"),
continueKind: "primary",
},
[SSOAuthEntry.PHASE_POSTAUTH]: {
title: _t("Confirm adding phone number"),
body: _t("Click the button below to confirm adding this phone number."),
continueText: _t("Confirm"),
continueKind: "primary",
},
};
const { finished } = Modal.createDialog(InteractiveAuthDialog, {
title: _t("Add Phone Number"),
matrixClient: MatrixClientPeg.get(),
authData: e.data,
makeRequest: this.makeAddThreepidOnlyRequest,
aestheticsForStagePhases: {
[SSOAuthEntry.LOGIN_TYPE]: dialogAesthetics,
[SSOAuthEntry.UNSTABLE_LOGIN_TYPE]: dialogAesthetics,
},
});
return finished;
}
}
} else {
await MatrixClientPeg.get().addThreePid({
sid: this.sessionId,
if (this.bind) {
await this.matrixClient.bindThreePid({
sid: this.sessionId!,
client_secret: this.clientSecret,
id_server: getIdServerDomain(),
}, this.bind);
id_server: getIdServerDomain(this.matrixClient),
id_access_token: await authClient.getAccessToken(),
});
} else {
try {
await this.makeAddThreepidOnlyRequest();
// The spec has always required this to use UI auth but synapse briefly
// implemented it without, so this may just succeed and that's OK.
return;
} catch (err) {
if (!(err instanceof MatrixError) || err.httpStatus !== 401 || !err.data || !err.data.flows) {
// doesn't look like an interactive-auth failure
throw err;
}
const dialogAesthetics = {
[SSOAuthEntry.PHASE_PREAUTH]: {
title: _t("auth|uia|sso_title"),
body: _t("settings|general|add_msisdn_confirm_sso_button"),
continueText: _t("auth|sso"),
continueKind: "primary",
},
[SSOAuthEntry.PHASE_POSTAUTH]: {
title: _t("settings|general|add_msisdn_confirm_button"),
body: _t("settings|general|add_msisdn_confirm_body"),
continueText: _t("action|confirm"),
continueKind: "primary",
},
};
const { finished } = Modal.createDialog(InteractiveAuthDialog<{}>, {
title: _t("settings|general|add_msisdn_dialog_title"),
matrixClient: this.matrixClient,
authData: err.data,
makeRequest: this.makeAddThreepidOnlyRequest,
aestheticsForStagePhases: {
[SSOAuthEntry.LOGIN_TYPE]: dialogAesthetics,
[SSOAuthEntry.UNSTABLE_LOGIN_TYPE]: dialogAesthetics,
},
} as InteractiveAuthDialogProps<IAddThreePidOnlyBody>);
return finished;
}
}
}
}

View file

@ -14,24 +14,24 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import React, { ComponentType } from "react";
import React, { ComponentType, PropsWithChildren } from "react";
import { logger } from "matrix-js-sdk/src/logger";
import { _t } from './languageHandler';
import { IDialogProps } from "./components/views/dialogs/IDialogProps";
import { _t } from "./languageHandler";
import BaseDialog from "./components/views/dialogs/BaseDialog";
import DialogButtons from "./components/views/elements/DialogButtons";
import Spinner from "./components/views/elements/Spinner";
type AsyncImport<T> = { default: T };
interface IProps extends IDialogProps {
interface IProps {
// A promise which resolves with the real component
prom: Promise<ComponentType | AsyncImport<ComponentType>>;
prom: Promise<ComponentType<any> | AsyncImport<ComponentType<any>>>;
onFinished(): void;
}
interface IState {
component?: ComponentType;
component?: ComponentType<PropsWithChildren<any>>;
error?: Error;
}
@ -42,55 +42,53 @@ interface IState {
export default class AsyncWrapper extends React.Component<IProps, IState> {
private unmounted = false;
public state = {
component: null,
error: null,
};
public state: IState = {};
componentDidMount() {
// XXX: temporary logging to try to diagnose
// https://github.com/vector-im/element-web/issues/3148
logger.log('Starting load of AsyncWrapper for modal');
this.props.prom.then((result) => {
if (this.unmounted) return;
public componentDidMount(): void {
this.props.prom
.then((result) => {
if (this.unmounted) return;
// Take the 'default' member if it's there, then we support
// passing in just an import()ed module, since ES6 async import
// always returns a module *namespace*.
const component = (result as AsyncImport<ComponentType>).default
? (result as AsyncImport<ComponentType>).default
: result as ComponentType;
this.setState({ component });
}).catch((e) => {
logger.warn('AsyncWrapper promise failed', e);
this.setState({ error: e });
});
// Take the 'default' member if it's there, then we support
// passing in just an import()ed module, since ES6 async import
// always returns a module *namespace*.
const component = (result as AsyncImport<ComponentType>).default
? (result as AsyncImport<ComponentType>).default
: (result as ComponentType);
this.setState({ component });
})
.catch((e) => {
logger.warn("AsyncWrapper promise failed", e);
this.setState({ error: e });
});
}
componentWillUnmount() {
public componentWillUnmount(): void {
this.unmounted = true;
}
private onWrapperCancelClick = () => {
this.props.onFinished(false);
private onWrapperCancelClick = (): void => {
this.props.onFinished();
};
render() {
public render(): React.ReactNode {
if (this.state.component) {
const Component = this.state.component;
return <Component {...this.props} />;
} else if (this.state.error) {
return <BaseDialog onFinished={this.props.onFinished} title={_t("Error")}>
{ _t("Unable to load! Check your network connectivity and try again.") }
<DialogButtons primaryButton={_t("Dismiss")}
onPrimaryButtonClick={this.onWrapperCancelClick}
hasCancel={false}
/>
</BaseDialog>;
return (
<BaseDialog onFinished={this.props.onFinished} title={_t("common|error")}>
{_t("failed_load_async_component")}
<DialogButtons
primaryButton={_t("action|dismiss")}
onPrimaryButtonClick={this.onWrapperCancelClick}
hasCancel={false}
/>
</BaseDialog>
);
} else {
// show a spinner until the component is loaded.
return <Spinner />;
}
}
}

View file

@ -14,24 +14,30 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import { RoomMember } from "matrix-js-sdk/src/models/room-member";
import { User } from "matrix-js-sdk/src/models/user";
import { Room } from "matrix-js-sdk/src/models/room";
import { ResizeMethod } from "matrix-js-sdk/src/@types/partials";
import { split } from "lodash";
import { RoomMember, User, Room, ResizeMethod } from "matrix-js-sdk/src/matrix";
import { useIdColorHash } from "@vector-im/compound-web";
import DMRoomMap from './utils/DMRoomMap';
import DMRoomMap from "./utils/DMRoomMap";
import { mediaFromMxc } from "./customisations/Media";
import { isLocalRoom } from "./utils/localRoom/isLocalRoom";
import { getFirstGrapheme } from "./utils/strings";
/**
* Hardcoded from the Compound colors.
* Shade for background as defined in the compound web implementation
* https://github.com/vector-im/compound-web/blob/main/src/components/Avatar
*/
const AVATAR_BG_COLORS = ["#e9f2ff", "#faeefb", "#e3f7ed", "#ffecf0", "#ffefe4", "#e3f5f8", "#f1efff", "#e0f8d9"];
const AVATAR_TEXT_COLORS = ["#043894", "#671481", "#004933", "#7e0642", "#850000", "#004077", "#4c05b5", "#004b00"];
// Not to be used for BaseAvatar urls as that has similar default avatar fallback already
export function avatarUrlForMember(
member: RoomMember,
member: RoomMember | undefined,
width: number,
height: number,
resizeMethod: ResizeMethod,
): string {
let url: string;
let url: string | null | undefined;
if (member?.getMxcAvatarUrl()) {
url = mediaFromMxc(member.getMxcAvatarUrl()).getThumbnailOfSourceHttp(width, height, resizeMethod);
}
@ -39,11 +45,23 @@ export function avatarUrlForMember(
// member can be null here currently since on invites, the JS SDK
// does not have enough info to build a RoomMember object for
// the inviter.
url = defaultAvatarUrlForString(member ? member.userId : '');
url = defaultAvatarUrlForString(member ? member.userId : "");
}
return url;
}
/**
* Determines the HEX color to use in the avatar pills
* @param id the user or room ID
* @returns the text color to use on the avatar
*/
export function getAvatarTextColor(id: string): string {
// eslint-disable-next-line react-hooks/rules-of-hooks
const index = useIdColorHash(id);
return AVATAR_TEXT_COLORS[index - 1];
}
export function avatarUrlForUser(
user: Pick<User, "avatarUrl">,
width: number,
@ -55,10 +73,15 @@ export function avatarUrlForUser(
}
function isValidHexColor(color: string): boolean {
return typeof color === "string" &&
return (
typeof color === "string" &&
(color.length === 7 || color.length === 9) &&
color.charAt(0) === "#" &&
!color.slice(1).split("").some(c => isNaN(parseInt(c, 16)));
!color
.slice(1)
.split("")
.some((c) => isNaN(parseInt(c, 16)))
);
}
function urlForColor(color: string): string {
@ -83,16 +106,12 @@ const colorToDataURLCache = new Map<string, string>();
export function defaultAvatarUrlForString(s: string): string {
if (!s) return ""; // XXX: should never happen but empirically does by evidence of a rageshake
const defaultColors = ['#0DBD8B', '#368bd6', '#ac3ba8'];
let total = 0;
for (let i = 0; i < s.length; ++i) {
total += s.charCodeAt(i);
}
const colorIndex = total % defaultColors.length;
// eslint-disable-next-line react-hooks/rules-of-hooks
const colorIndex = useIdColorHash(s);
// overwritten color value in custom themes
const cssVariable = `--avatar-background-colors_${colorIndex}`;
const cssValue = document.body.style.getPropertyValue(cssVariable);
const color = cssValue || defaultColors[colorIndex];
const cssValue = getComputedStyle(document.body).getPropertyValue(cssVariable);
const color = cssValue || AVATAR_BG_COLORS[colorIndex - 1];
let dataUrl = colorToDataURLCache.get(color);
if (!dataUrl) {
// validate color as this can come from account_data
@ -113,7 +132,7 @@ export function defaultAvatarUrlForString(s: string): string {
* @param {string} name
* @return {string} the first letter
*/
export function getInitialLetter(name: string): string {
export function getInitialLetter(name: string): string | undefined {
if (!name) {
// XXX: We should find out what causes the name to sometimes be falsy.
console.trace("`name` argument to `getInitialLetter` not supplied");
@ -124,36 +143,45 @@ export function getInitialLetter(name: string): string {
}
const initial = name[0];
if ((initial === '@' || initial === '#' || initial === '+') && name[1]) {
if ((initial === "@" || initial === "#" || initial === "+") && name[1]) {
name = name.substring(1);
}
// rely on the grapheme cluster splitter in lodash so that we don't break apart compound emojis
return split(name, "", 1)[0].toUpperCase();
return getFirstGrapheme(name).toUpperCase();
}
export function avatarUrlForRoom(room: Room, width: number, height: number, resizeMethod?: ResizeMethod) {
export function avatarUrlForRoom(
room: Room | null,
width?: number,
height?: number,
resizeMethod?: ResizeMethod,
): string | null {
if (!room) return null; // null-guard
if (room.getMxcAvatarUrl()) {
return mediaFromMxc(room.getMxcAvatarUrl()).getThumbnailOfSourceHttp(width, height, resizeMethod);
const media = mediaFromMxc(room.getMxcAvatarUrl() ?? undefined);
if (width !== undefined && height !== undefined) {
return media.getThumbnailOfSourceHttp(width, height, resizeMethod);
}
return media.srcHttp;
}
// space rooms cannot be DMs so skip the rest
if (room.isSpaceRoom()) return null;
// If the room is not a DM don't fallback to a member avatar
if (
!DMRoomMap.shared().getUserIdForRoomId(room.roomId)
&& !(isLocalRoom(room))
) {
if (!DMRoomMap.shared().getUserIdForRoomId(room.roomId) && !isLocalRoom(room)) {
return null;
}
// If there are only two members in the DM use the avatar of the other member
const otherMember = room.getAvatarFallbackMember();
if (otherMember?.getMxcAvatarUrl()) {
return mediaFromMxc(otherMember.getMxcAvatarUrl()).getThumbnailOfSourceHttp(width, height, resizeMethod);
const media = mediaFromMxc(otherMember.getMxcAvatarUrl());
if (width !== undefined && height !== undefined) {
return media.getThumbnailOfSourceHttp(width, height, resizeMethod);
}
return media.srcHttp;
}
return null;
}

View file

@ -17,14 +17,18 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import { MatrixClient } from "matrix-js-sdk/src/client";
import { encodeUnpaddedBase64 } from "matrix-js-sdk/src/crypto/olmlib";
import {
MatrixClient,
MatrixEvent,
Room,
SSOAction,
encodeUnpaddedBase64,
OidcRegistrationClientMetadata,
} from "matrix-js-sdk/src/matrix";
import { logger } from "matrix-js-sdk/src/logger";
import { MatrixEvent } from "matrix-js-sdk/src/models/event";
import { Room } from "matrix-js-sdk/src/models/room";
import dis from './dispatcher/dispatcher';
import BaseEventIndexManager from './indexing/BaseEventIndexManager';
import dis from "./dispatcher/dispatcher";
import BaseEventIndexManager from "./indexing/BaseEventIndexManager";
import { ActionPayload } from "./dispatcher/payloads";
import { CheckUpdatesPayload } from "./dispatcher/payloads/CheckUpdatesPayload";
import { Action } from "./dispatcher/actions";
@ -33,6 +37,7 @@ import { MatrixClientPeg } from "./MatrixClientPeg";
import { idbLoad, idbSave, idbDelete } from "./utils/StorageManager";
import { ViewRoomPayload } from "./dispatcher/payloads/ViewRoomPayload";
import { IConfigOptions } from "./IConfigOptions";
import SdkConfig from "./SdkConfig";
export const SSO_HOMESERVER_URL_KEY = "mx_sso_hs_url";
export const SSO_ID_SERVER_URL_KEY = "mx_sso_is_url";
@ -69,18 +74,18 @@ export default abstract class BasePlatform {
protected notificationCount = 0;
protected errorDidOccur = false;
constructor() {
protected constructor() {
dis.register(this.onAction);
this.startUpdateCheck = this.startUpdateCheck.bind(this);
}
public abstract getConfig(): Promise<IConfigOptions>;
public abstract getConfig(): Promise<IConfigOptions | undefined>;
public abstract getDefaultDeviceDisplayName(): string;
protected onAction = (payload: ActionPayload): void => {
switch (payload.action) {
case 'on_client_not_viable':
case "on_client_not_viable":
case Action.OnLoggedOut:
this.setNotificationCount(0);
break;
@ -129,7 +134,7 @@ export default abstract class BasePlatform {
if (MatrixClientPeg.userRegisteredWithinLastHours(24)) return false;
try {
const [version, deferUntil] = JSON.parse(localStorage.getItem(UPDATE_DEFER_KEY));
const [version, deferUntil] = JSON.parse(localStorage.getItem(UPDATE_DEFER_KEY)!);
return newVersion !== version || Date.now() > deferUntil;
} catch (e) {
return true;
@ -192,15 +197,15 @@ export default abstract class BasePlatform {
public displayNotification(
title: string,
msg: string,
avatarUrl: string,
avatarUrl: string | null,
room: Room,
ev?: MatrixEvent,
): Notification {
const notifBody = {
const notifBody: NotificationOptions = {
body: msg,
silent: true, // we play our own sounds
};
if (avatarUrl) notifBody['icon'] = avatarUrl;
if (avatarUrl) notifBody["icon"] = avatarUrl;
const notification = new window.Notification(title, notifBody);
notification.onclick = () => {
@ -210,7 +215,7 @@ export default abstract class BasePlatform {
metricsTrigger: "Notification",
};
if (ev.getThread()) {
if (ev?.getThread()) {
payload.event_id = ev.getId();
}
@ -254,7 +259,7 @@ export default abstract class BasePlatform {
return false;
}
public getSettingValue(settingName: string): Promise<any> {
public async getSettingValue(settingName: string): Promise<any> {
return undefined;
}
@ -272,15 +277,15 @@ export default abstract class BasePlatform {
return null;
}
public setLanguage(preferredLangs: string[]) {}
public setLanguage(preferredLangs: string[]): void {}
public setSpellCheckEnabled(enabled: boolean): void {}
public async getSpellCheckEnabled(): Promise<boolean> {
return null;
return false;
}
public setSpellCheckLanguages(preferredLangs: string[]) {}
public setSpellCheckLanguages(preferredLangs: string[]): void {}
public getSpellCheckLanguages(): Promise<string[]> | null {
return null;
@ -308,9 +313,13 @@ export default abstract class BasePlatform {
return null;
}
protected getSSOCallbackUrl(fragmentAfterLogin: string): URL {
/**
* The URL to return to after a successful SSO/OIDC authentication
* @param fragmentAfterLogin optional fragment for specific view to return to
*/
public getSSOCallbackUrl(fragmentAfterLogin = ""): URL {
const url = new URL(window.location.href);
url.hash = fragmentAfterLogin || "";
url.hash = fragmentAfterLogin;
return url;
}
@ -319,24 +328,26 @@ export default abstract class BasePlatform {
* @param {MatrixClient} mxClient the matrix client using which we should start the flow
* @param {"sso"|"cas"} loginType the type of SSO it is, CAS/SSO.
* @param {string} fragmentAfterLogin the hash to pass to the app during sso callback.
* @param {SSOAction} action the SSO flow to indicate to the IdP, optional.
* @param {string} idpId The ID of the Identity Provider being targeted, optional.
*/
public startSingleSignOn(
mxClient: MatrixClient,
loginType: "sso" | "cas",
fragmentAfterLogin: string,
fragmentAfterLogin?: string,
idpId?: string,
action?: SSOAction,
): void {
// persist hs url and is url for when the user is returned to the app with the login token
localStorage.setItem(SSO_HOMESERVER_URL_KEY, mxClient.getHomeserverUrl());
if (mxClient.getIdentityServerUrl()) {
localStorage.setItem(SSO_ID_SERVER_URL_KEY, mxClient.getIdentityServerUrl());
localStorage.setItem(SSO_ID_SERVER_URL_KEY, mxClient.getIdentityServerUrl()!);
}
if (idpId) {
localStorage.setItem(SSO_IDP_ID_KEY, idpId);
}
const callbackUrl = this.getSSOCallbackUrl(fragmentAfterLogin);
window.location.href = mxClient.getSsoLoginUrl(callbackUrl.toString(), loginType, idpId); // redirect to SSO
window.location.href = mxClient.getSsoLoginUrl(callbackUrl.toString(), loginType, idpId, action); // redirect to SSO
}
/**
@ -365,6 +376,22 @@ export default abstract class BasePlatform {
return null;
}
const additionalData = this.getPickleAdditionalData(userId, deviceId);
try {
const key = await crypto.subtle.decrypt(
{ name: "AES-GCM", iv: data.iv, additionalData },
data.cryptoKey,
data.encrypted,
);
return encodeUnpaddedBase64(key);
} catch (e) {
logger.error("Error decrypting pickle key");
return null;
}
}
private getPickleAdditionalData(userId: string, deviceId: string): Uint8Array {
const additionalData = new Uint8Array(userId.length + deviceId.length + 1);
for (let i = 0; i < userId.length; i++) {
additionalData[i] = userId.charCodeAt(i);
@ -373,17 +400,7 @@ export default abstract class BasePlatform {
for (let i = 0; i < deviceId.length; i++) {
additionalData[userId.length + 1 + i] = deviceId.charCodeAt(i);
}
try {
const key = await crypto.subtle.decrypt(
{ name: "AES-GCM", iv: data.iv, additionalData }, data.cryptoKey,
data.encrypted,
);
return encodeUnpaddedBase64(key);
} catch (e) {
logger.error("Error decrypting pickle key");
return null;
}
return additionalData;
}
/**
@ -400,24 +417,15 @@ export default abstract class BasePlatform {
const crypto = window.crypto;
const randomArray = new Uint8Array(32);
crypto.getRandomValues(randomArray);
const cryptoKey = await crypto.subtle.generateKey(
{ name: "AES-GCM", length: 256 }, false, ["encrypt", "decrypt"],
);
const cryptoKey = await crypto.subtle.generateKey({ name: "AES-GCM", length: 256 }, false, [
"encrypt",
"decrypt",
]);
const iv = new Uint8Array(32);
crypto.getRandomValues(iv);
const additionalData = new Uint8Array(userId.length + deviceId.length + 1);
for (let i = 0; i < userId.length; i++) {
additionalData[i] = userId.charCodeAt(i);
}
additionalData[userId.length] = 124; // "|"
for (let i = 0; i < deviceId.length; i++) {
additionalData[userId.length + 1 + i] = deviceId.charCodeAt(i);
}
const encrypted = await crypto.subtle.encrypt(
{ name: "AES-GCM", iv, additionalData }, cryptoKey, randomArray,
);
const additionalData = this.getPickleAdditionalData(userId, deviceId);
const encrypted = await crypto.subtle.encrypt({ name: "AES-GCM", iv, additionalData }, cryptoKey, randomArray);
try {
await idbSave("pickleKey", [userId, deviceId], { encrypted, iv, cryptoKey });
@ -430,7 +438,7 @@ export default abstract class BasePlatform {
/**
* Delete a previously stored pickle key from storage.
* @param {string} userId the user ID for the user that the pickle key is for.
* @param {string} userId the device ID that the pickle key is for.
* @param {string} deviceId the device ID that the pickle key is for.
*/
public async destroyPickleKey(userId: string, deviceId: string): Promise<void> {
try {
@ -439,4 +447,47 @@ export default abstract class BasePlatform {
logger.error("idbDelete failed in destroyPickleKey", e);
}
}
/**
* Clear app storage, called when logging out to perform data clean up.
*/
public async clearStorage(): Promise<void> {
window.sessionStorage.clear();
window.localStorage.clear();
}
/**
* Base URL to use when generating external links for this client, for platforms e.g. Desktop this will be a different instance
*/
public get baseUrl(): string {
return window.location.origin + window.location.pathname;
}
/**
* Metadata to use for dynamic OIDC client registrations
*/
public async getOidcClientMetadata(): Promise<OidcRegistrationClientMetadata> {
const config = SdkConfig.get();
return {
clientName: config.brand,
clientUri: this.baseUrl,
redirectUris: [this.getSSOCallbackUrl().href],
logoUri: new URL("vector-icons/1024.png", this.baseUrl).href,
applicationType: "web",
// XXX: We break the spec by not consistently supplying these required fields
// contacts: [],
// @ts-ignore
tosUri: config.terms_and_conditions_links?.[0]?.url,
// @ts-ignore
policyUri: config.privacy_policy_url,
};
}
/**
* Suffix to append to the `state` parameter of OIDC /auth calls. Will be round-tripped to the callback URI.
* Currently only required for ElectronPlatform for passing element-desktop-ssoid.
*/
public getOidcClientState(): string {
return "";
}
}

View file

@ -14,15 +14,10 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import { defer, IDeferred } from "matrix-js-sdk/src/utils";
// @ts-ignore - `.ts` is needed here to make TS happy
import BlurhashWorker from "./workers/blurhash.worker.ts";
interface IBlurhashWorkerResponse {
seq: number;
blurhash: string;
}
import { Request, Response } from "./workers/blurhash.worker.ts";
import { WorkerManager } from "./WorkerManager";
import blurhashWorkerFactory from "./workers/blurhashWorkerFactory";
export class BlurhashEncoder {
private static internalInstance = new BlurhashEncoder();
@ -31,30 +26,9 @@ export class BlurhashEncoder {
return BlurhashEncoder.internalInstance;
}
private readonly worker: Worker;
private seq = 0;
private pendingDeferredMap = new Map<number, IDeferred<string>>();
constructor() {
this.worker = new BlurhashWorker();
this.worker.onmessage = this.onMessage;
}
private onMessage = (ev: MessageEvent<IBlurhashWorkerResponse>) => {
const { seq, blurhash } = ev.data;
const deferred = this.pendingDeferredMap.get(seq);
if (deferred) {
this.pendingDeferredMap.delete(seq);
deferred.resolve(blurhash);
}
};
private readonly worker = new WorkerManager<Request, Response>(blurhashWorkerFactory());
public getBlurhash(imageData: ImageData): Promise<string> {
const seq = this.seq++;
const deferred = defer<string>();
this.pendingDeferredMap.set(seq, deferred);
this.worker.postMessage({ seq, imageData });
return deferred.promise;
return this.worker.call({ imageData }).then((resp) => resp.blurhash);
}
}

View file

@ -16,20 +16,34 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import { MatrixClient } from "matrix-js-sdk/src/client";
import { MsgType } from "matrix-js-sdk/src/@types/event";
import {
MatrixClient,
MsgType,
IImageInfo,
HTTPError,
IEventRelation,
ISendEventResponse,
MatrixEvent,
UploadOpts,
UploadProgress,
THREAD_RELATION_TYPE,
} from "matrix-js-sdk/src/matrix";
import {
ImageInfo,
AudioInfo,
VideoInfo,
EncryptedFile,
MediaEventContent,
MediaEventInfo,
} from "matrix-js-sdk/src/types";
import encrypt from "matrix-encrypt-attachment";
import extractPngChunks from "png-chunks-extract";
import { IImageInfo } from "matrix-js-sdk/src/@types/partials";
import { logger } from "matrix-js-sdk/src/logger";
import { IEventRelation, ISendEventResponse, MatrixEvent, UploadOpts, UploadProgress } from "matrix-js-sdk/src/matrix";
import { THREAD_RELATION_TYPE } from "matrix-js-sdk/src/models/thread";
import { removeElement } from "matrix-js-sdk/src/utils";
import { IEncryptedFile, IMediaEventContent, IMediaEventInfo } from "./customisations/models/IMediaEventContent";
import dis from './dispatcher/dispatcher';
import { _t } from './languageHandler';
import Modal from './Modal';
import dis from "./dispatcher/dispatcher";
import { _t } from "./languageHandler";
import Modal from "./Modal";
import Spinner from "./components/views/elements/Spinner";
import { Action } from "./dispatcher/actions";
import {
@ -48,7 +62,7 @@ import ErrorDialog from "./components/views/dialogs/ErrorDialog";
import UploadFailureDialog from "./components/views/dialogs/UploadFailureDialog";
import UploadConfirmDialog from "./components/views/dialogs/UploadConfirmDialog";
import { createThumbnail } from "./utils/image-media";
import { attachRelation } from "./components/views/rooms/SendMessageComposer";
import { attachMentions, attachRelation } from "./components/views/rooms/SendMessageComposer";
import { doMaybeLocalRoomAction } from "./utils/local-room";
import { SdkContextClass } from "./contexts/SDKContext";
@ -68,16 +82,20 @@ interface IMediaConfig {
* @param {File} imageFile The file to load in an image element.
* @return {Promise} A promise that resolves with the html image element.
*/
async function loadImageElement(imageFile: File) {
async function loadImageElement(imageFile: File): Promise<{
width: number;
height: number;
img: HTMLImageElement;
}> {
// Load the file into an html element
const img = new Image();
const objectUrl = URL.createObjectURL(imageFile);
const imgPromise = new Promise((resolve, reject) => {
img.onload = function() {
img.onload = function (): void {
URL.revokeObjectURL(objectUrl);
resolve(img);
};
img.onerror = function(e) {
img.onerror = function (e): void {
reject(e);
};
});
@ -85,29 +103,34 @@ async function loadImageElement(imageFile: File) {
// check for hi-dpi PNGs and fudge display resolution as needed.
// this is mainly needed for macOS screencaps
let parsePromise: Promise<boolean>;
let parsePromise = Promise.resolve(false);
if (imageFile.type === "image/png") {
// in practice macOS happens to order the chunks so they fall in
// the first 0x1000 bytes (thanks to a massive ICC header).
// Thus we could slice the file down to only sniff the first 0x1000
// bytes (but this makes extractPngChunks choke on the corrupt file)
const headers = imageFile; //.slice(0, 0x1000);
parsePromise = readFileAsArrayBuffer(headers).then(arrayBuffer => {
const buffer = new Uint8Array(arrayBuffer);
const chunks = extractPngChunks(buffer);
for (const chunk of chunks) {
if (chunk.name === 'pHYs') {
if (chunk.data.byteLength !== PHYS_HIDPI.length) return;
return chunk.data.every((val, i) => val === PHYS_HIDPI[i]);
parsePromise = readFileAsArrayBuffer(headers)
.then((arrayBuffer) => {
const buffer = new Uint8Array(arrayBuffer);
const chunks = extractPngChunks(buffer);
for (const chunk of chunks) {
if (chunk.name === "pHYs") {
if (chunk.data.byteLength !== PHYS_HIDPI.length) return false;
return chunk.data.every((val, i) => val === PHYS_HIDPI[i]);
}
}
}
return false;
});
return false;
})
.catch((e) => {
console.error("Failed to parse PNG", e);
return false;
});
}
const [hidpi] = await Promise.all([parsePromise, imgPromise]);
const width = hidpi ? (img.width >> 1) : img.width;
const height = hidpi ? (img.height >> 1) : img.height;
const width = hidpi ? img.width >> 1 : img.width;
const height = hidpi ? img.height >> 1 : img.height;
return { width, height, img };
}
@ -130,11 +153,7 @@ const ALWAYS_INCLUDE_THUMBNAIL = ["image/avif", "image/webp"];
* @param {File} imageFile The image to read and thumbnail.
* @return {Promise} A promise that resolves with the attachment info.
*/
async function infoForImageFile(
matrixClient: MatrixClient,
roomId: string,
imageFile: File,
): Promise<Partial<IMediaEventInfo>> {
async function infoForImageFile(matrixClient: MatrixClient, roomId: string, imageFile: File): Promise<ImageInfo> {
let thumbnailType = "image/png";
if (imageFile.type === "image/jpeg") {
thumbnailType = "image/jpeg";
@ -148,13 +167,13 @@ async function infoForImageFile(
// For lesser supported image types, always include the thumbnail even if it is larger
if (!ALWAYS_INCLUDE_THUMBNAIL.includes(imageFile.type)) {
// we do all sizing checks here because we still rely on thumbnail generation for making a blurhash from.
const sizeDifference = imageFile.size - imageInfo.thumbnail_info.size;
const sizeDifference = imageFile.size - imageInfo.thumbnail_info!.size;
if (
// image is small enough already
imageFile.size <= IMAGE_SIZE_THRESHOLD_THUMBNAIL ||
// thumbnail is not sufficiently smaller than original
(sizeDifference <= IMAGE_THUMBNAIL_MIN_REDUCTION_SIZE &&
sizeDifference <= (imageFile.size * IMAGE_THUMBNAIL_MIN_REDUCTION_PERCENT))
sizeDifference <= imageFile.size * IMAGE_THUMBNAIL_MIN_REDUCTION_PERCENT)
) {
delete imageInfo["thumbnail_info"];
return imageInfo;
@ -168,16 +187,59 @@ async function infoForImageFile(
return imageInfo;
}
/**
* Load a file into a newly created audio element and load the metadata
*
* @param {File} audioFile The file to load in an audio element.
* @return {Promise} A promise that resolves with the audio element.
*/
function loadAudioElement(audioFile: File): Promise<HTMLAudioElement> {
return new Promise((resolve, reject) => {
// Load the file into a html element
const audio = document.createElement("audio");
audio.preload = "metadata";
audio.muted = true;
const reader = new FileReader();
reader.onload = function (ev): void {
audio.onloadedmetadata = async function (): Promise<void> {
resolve(audio);
};
audio.onerror = function (e): void {
reject(e);
};
audio.src = ev.target?.result as string;
};
reader.onerror = function (e): void {
reject(e);
};
reader.readAsDataURL(audioFile);
});
}
/**
* Read the metadata for an audio file.
*
* @param {File} audioFile The audio to read.
* @return {Promise} A promise that resolves with the attachment info.
*/
async function infoForAudioFile(audioFile: File): Promise<AudioInfo> {
const audio = await loadAudioElement(audioFile);
return { duration: Math.ceil(audio.duration * 1000) };
}
/**
* Load a file into a newly created video element and pull some strings
* in an attempt to guarantee the first frame will be showing.
*
* @param {File} videoFile The file to load in an video element.
* @return {Promise} A promise that resolves with the video image element.
* @param {File} videoFile The file to load in a video element.
* @return {Promise} A promise that resolves with the video element.
*/
function loadVideoElement(videoFile: File): Promise<HTMLVideoElement> {
return new Promise((resolve, reject) => {
// Load the file into an html element
// Load the file into a html element
const video = document.createElement("video");
video.preload = "metadata";
video.playsInline = true;
@ -185,20 +247,20 @@ function loadVideoElement(videoFile: File): Promise<HTMLVideoElement> {
const reader = new FileReader();
reader.onload = function(ev) {
reader.onload = function (ev): void {
// Wait until we have enough data to thumbnail the first frame.
video.onloadeddata = async function() {
video.onloadeddata = async function (): Promise<void> {
resolve(video);
video.pause();
};
video.onerror = function(e) {
video.onerror = function (e): void {
reject(e);
};
let dataUrl = ev.target.result as string;
let dataUrl = ev.target?.result as string;
// Chrome chokes on quicktime but likes mp4, and `file.type` is
// read only, so do this horrible hack to unbreak quicktime
if (dataUrl.startsWith("data:video/quicktime;")) {
if (dataUrl?.startsWith("data:video/quicktime;")) {
dataUrl = dataUrl.replace("data:video/quicktime;", "data:video/mp4;");
}
@ -206,7 +268,7 @@ function loadVideoElement(videoFile: File): Promise<HTMLVideoElement> {
video.load();
video.play();
};
reader.onerror = function(e) {
reader.onerror = function (e): void {
reject(e);
};
reader.readAsDataURL(videoFile);
@ -221,24 +283,24 @@ function loadVideoElement(videoFile: File): Promise<HTMLVideoElement> {
* @param {File} videoFile The video to read and thumbnail.
* @return {Promise} A promise that resolves with the attachment info.
*/
function infoForVideoFile(
matrixClient: MatrixClient,
roomId: string,
videoFile: File,
): Promise<Partial<IMediaEventInfo>> {
function infoForVideoFile(matrixClient: MatrixClient, roomId: string, videoFile: File): Promise<VideoInfo> {
const thumbnailType = "image/jpeg";
let videoInfo: Partial<IMediaEventInfo>;
return loadVideoElement(videoFile).then((video) => {
return createThumbnail(video, video.videoWidth, video.videoHeight, thumbnailType);
}).then((result) => {
videoInfo = result.info;
return uploadFile(matrixClient, roomId, result.thumbnail);
}).then((result) => {
videoInfo.thumbnail_url = result.url;
videoInfo.thumbnail_file = result.file;
return videoInfo;
});
const videoInfo: VideoInfo = {};
return loadVideoElement(videoFile)
.then((video) => {
videoInfo.duration = Math.ceil(video.duration * 1000);
return createThumbnail(video, video.videoWidth, video.videoHeight, thumbnailType);
})
.then((result) => {
Object.assign(videoInfo, result.info);
return uploadFile(matrixClient, roomId, result.thumbnail);
})
.then((result) => {
videoInfo.thumbnail_url = result.url;
videoInfo.thumbnail_file = result.file;
return videoInfo;
});
}
/**
@ -250,10 +312,10 @@ function infoForVideoFile(
function readFileAsArrayBuffer(file: File | Blob): Promise<ArrayBuffer> {
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onload = function(e) {
resolve(e.target.result as ArrayBuffer);
reader.onload = function (e): void {
resolve(e.target?.result as ArrayBuffer);
};
reader.onerror = function(e) {
reader.onerror = function (e): void {
reject(e);
};
reader.readAsArrayBuffer(file);
@ -280,7 +342,7 @@ export async function uploadFile(
file: File | Blob,
progressHandler?: UploadOpts["progressHandler"],
controller?: AbortController,
): Promise<{ url?: string, file?: IEncryptedFile }> {
): Promise<{ url?: string; file?: EncryptedFile }> {
const abortController = controller ?? new AbortController();
// If the room is encrypted then encrypt the file before uploading it.
@ -300,6 +362,7 @@ export async function uploadFile(
progressHandler,
abortController,
includeFilename: false,
type: "application/octet-stream",
});
if (abortController.signal.aborted) throw new UploadCanceledError();
@ -309,7 +372,7 @@ export async function uploadFile(
file: {
...encryptResult.info,
url,
} as IEncryptedFile,
} as EncryptedFile,
};
} else {
const { content_uri: url } = await matrixClient.uploadContent(file, { progressHandler, abortController });
@ -321,7 +384,7 @@ export async function uploadFile(
export default class ContentMessages {
private inprogress: RoomUpload[] = [];
private mediaConfig: IMediaConfig = null;
private mediaConfig: IMediaConfig | null = null;
public sendStickerContentToRoom(
url: string,
@ -357,19 +420,25 @@ export default class ContentMessages {
context = TimelineRenderingType.Room,
): Promise<void> {
if (matrixClient.isGuest()) {
dis.dispatch({ action: 'require_registration' });
dis.dispatch({ action: "require_registration" });
return;
}
const replyToEvent = SdkContextClass.instance.roomViewStore.getQuotingEvent();
if (!this.mediaConfig) { // hot-path optimization to not flash a spinner if we don't need to
const modal = Modal.createDialog(Spinner, null, 'mx_Dialog_spinner');
await this.ensureMediaConfigFetched(matrixClient);
modal.close();
if (!this.mediaConfig) {
// hot-path optimization to not flash a spinner if we don't need to
const modal = Modal.createDialog(Spinner, undefined, "mx_Dialog_spinner");
await Promise.race([this.ensureMediaConfigFetched(matrixClient), modal.finished]);
if (!this.mediaConfig) {
// User cancelled by clicking away on the spinner
return;
} else {
modal.close();
}
}
const tooBigFiles = [];
const okFiles = [];
const tooBigFiles: File[] = [];
const okFiles: File[] = [];
for (const file of files) {
if (this.isFileSizeAcceptable(file)) {
@ -380,7 +449,7 @@ export default class ContentMessages {
}
if (tooBigFiles.length > 0) {
const { finished } = Modal.createDialog<[boolean]>(UploadFailureDialog, {
const { finished } = Modal.createDialog(UploadFailureDialog, {
badFiles: tooBigFiles,
totalFiles: files.length,
contentMessages: this,
@ -398,7 +467,7 @@ export default class ContentMessages {
const loopPromiseBefore = promBefore;
if (!uploadAll) {
const { finished } = Modal.createDialog<[boolean, boolean]>(UploadConfirmDialog, {
const { finished } = Modal.createDialog(UploadConfirmDialog, {
file,
currentIndex: i,
totalFiles: okFiles.length,
@ -412,14 +481,16 @@ export default class ContentMessages {
promBefore = doMaybeLocalRoomAction(
roomId,
(actualRoomId) => this.sendContentToRoom(
file,
actualRoomId,
relation,
matrixClient,
replyToEvent,
loopPromiseBefore,
),
(actualRoomId) =>
this.sendContentToRoom(
file,
actualRoomId,
relation,
matrixClient,
replyToEvent ?? undefined,
loopPromiseBefore,
),
matrixClient,
);
}
@ -440,11 +511,13 @@ export default class ContentMessages {
}
public getCurrentUploads(relation?: IEventRelation): RoomUpload[] {
return this.inprogress.filter(roomUpload => {
return this.inprogress.filter((roomUpload) => {
const noRelation = !relation && !roomUpload.relation;
const matchingRelation = relation && roomUpload.relation
&& relation.rel_type === roomUpload.relation.rel_type
&& relation.event_id === roomUpload.relation.event_id;
const matchingRelation =
relation &&
roomUpload.relation &&
relation.rel_type === roomUpload.relation.rel_type &&
relation.event_id === roomUpload.relation.event_id;
return (noRelation || matchingRelation) && !roomUpload.cancelled;
});
@ -462,9 +535,9 @@ export default class ContentMessages {
matrixClient: MatrixClient,
replyToEvent: MatrixEvent | undefined,
promBefore?: Promise<any>,
) {
const fileName = file.name || _t("Attachment");
const content: Omit<IMediaEventContent, "info"> & { info: Partial<IMediaEventInfo> } = {
): Promise<void> {
const fileName = file.name || _t("common|attachment");
const content: Omit<MediaEventContent, "info"> & { info: Partial<MediaEventInfo> } = {
body: fileName,
info: {
size: file.size,
@ -472,6 +545,8 @@ export default class ContentMessages {
msgtype: MsgType.File, // set more specifically later
};
// Attach mentions, which really only applies if there's a replyToEvent.
attachMentions(matrixClient.getSafeUserId(), content, null, replyToEvent);
attachRelation(content, relation);
if (replyToEvent) {
addReplyToMessageContent(content, replyToEvent, {
@ -492,25 +567,37 @@ export default class ContentMessages {
this.inprogress.push(upload);
dis.dispatch<UploadStartedPayload>({ action: Action.UploadStarted, upload });
function onProgress(progress: UploadProgress) {
function onProgress(progress: UploadProgress): void {
upload.onProgress(progress);
dis.dispatch<UploadProgressPayload>({ action: Action.UploadProgress, upload });
}
try {
if (file.type.startsWith('image/')) {
if (file.type.startsWith("image/")) {
content.msgtype = MsgType.Image;
try {
const imageInfo = await infoForImageFile(matrixClient, roomId, file);
Object.assign(content.info, imageInfo);
} catch (e) {
// Failed to thumbnail, fall back to uploading an m.file
if (e instanceof HTTPError) {
// re-throw to main upload error handler
throw e;
}
// Otherwise we failed to thumbnail, fall back to uploading an m.file
logger.error(e);
content.msgtype = MsgType.File;
}
} else if (file.type.indexOf('audio/') === 0) {
} else if (file.type.indexOf("audio/") === 0) {
content.msgtype = MsgType.Audio;
} else if (file.type.indexOf('video/') === 0) {
try {
const audioInfo = await infoForAudioFile(file);
Object.assign(content.info, audioInfo);
} catch (e) {
// Failed to process audio file, fall back to uploading an m.file
logger.error(e);
content.msgtype = MsgType.File;
}
} else if (file.type.indexOf("video/") === 0) {
content.msgtype = MsgType.Video;
try {
const videoInfo = await infoForVideoFile(matrixClient, roomId, file);
@ -536,66 +623,71 @@ export default class ContentMessages {
if (upload.cancelled) throw new UploadCanceledError();
const threadId = relation?.rel_type === THREAD_RELATION_TYPE.name ? relation.event_id : null;
const response = await matrixClient.sendMessage(roomId, threadId, content);
const response = await matrixClient.sendMessage(roomId, threadId ?? null, content);
if (SettingsStore.getValue("Performance.addSendMessageTimingMetadata")) {
sendRoundTripMetric(matrixClient, roomId, response.event_id);
}
dis.dispatch<UploadFinishedPayload>({ action: Action.UploadFinished, upload });
dis.dispatch({ action: 'message_sent' });
dis.dispatch({ action: "message_sent" });
} catch (error) {
// 413: File was too big or upset the server in some way:
// clear the media size limit so we fetch it again next time we try to upload
if (error?.httpStatus === 413) {
if (error instanceof HTTPError && error.httpStatus === 413) {
this.mediaConfig = null;
}
if (!upload.cancelled) {
let desc = _t("The file '%(fileName)s' failed to upload.", { fileName: upload.fileName });
if (error.httpStatus === 413) {
desc = _t(
"The file '%(fileName)s' exceeds this homeserver's size limit for uploads",
{ fileName: upload.fileName },
);
let desc = _t("upload_failed_generic", { fileName: upload.fileName });
if (error instanceof HTTPError && error.httpStatus === 413) {
desc = _t("upload_failed_size", {
fileName: upload.fileName,
});
}
Modal.createDialog(ErrorDialog, {
title: _t('Upload Failed'),
title: _t("upload_failed_title"),
description: desc,
});
dis.dispatch<UploadErrorPayload>({ action: Action.UploadFailed, upload, error });
}
} finally {
removeElement(this.inprogress, e => e.promise === upload.promise);
removeElement(this.inprogress, (e) => e.promise === upload.promise);
}
}
private isFileSizeAcceptable(file: File) {
if (this.mediaConfig !== null &&
private isFileSizeAcceptable(file: File): boolean {
if (
this.mediaConfig !== null &&
this.mediaConfig["m.upload.size"] !== undefined &&
file.size > this.mediaConfig["m.upload.size"]) {
file.size > this.mediaConfig["m.upload.size"]
) {
return false;
}
return true;
}
private ensureMediaConfigFetched(matrixClient: MatrixClient): Promise<void> {
if (this.mediaConfig !== null) return;
if (this.mediaConfig !== null) return Promise.resolve();
logger.log("[Media Config] Fetching");
return matrixClient.getMediaConfig().then((config) => {
logger.log("[Media Config] Fetched config:", config);
return config;
}).catch(() => {
// Media repo can't or won't report limits, so provide an empty object (no limits).
logger.log("[Media Config] Could not fetch config, so not limiting uploads.");
return {};
}).then((config) => {
this.mediaConfig = config;
});
return matrixClient
.getMediaConfig()
.then((config) => {
logger.log("[Media Config] Fetched config:", config);
return config;
})
.catch(() => {
// Media repo can't or won't report limits, so provide an empty object (no limits).
logger.log("[Media Config] Could not fetch config, so not limiting uploads.");
return {};
})
.then((config) => {
this.mediaConfig = config;
});
}
static sharedInstance() {
public static sharedInstance(): ContentMessages {
if (window.mxContentMessages === undefined) {
window.mxContentMessages = new ContentMessages();
}

View file

@ -16,149 +16,234 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import { _t } from './languageHandler';
import { Optional } from "matrix-events-sdk";
function getDaysArray(): string[] {
return [
_t('Sun'),
_t('Mon'),
_t('Tue'),
_t('Wed'),
_t('Thu'),
_t('Fri'),
_t('Sat'),
];
import { _t, getUserLanguage } from "./languageHandler";
export const MINUTE_MS = 60000;
export const HOUR_MS = MINUTE_MS * 60;
export const DAY_MS = HOUR_MS * 24;
/**
* Returns array of 7 weekday names, from Sunday to Saturday, internationalised to the user's language.
* @param weekday - format desired "short" | "long" | "narrow"
*/
export function getDaysArray(weekday: Intl.DateTimeFormatOptions["weekday"] = "short"): string[] {
const sunday = 1672574400000; // 2023-01-01 12:00 UTC
const { format } = new Intl.DateTimeFormat(getUserLanguage(), { weekday, timeZone: "UTC" });
return [...Array(7).keys()].map((day) => format(sunday + day * DAY_MS));
}
function getMonthsArray(): string[] {
return [
_t('Jan'),
_t('Feb'),
_t('Mar'),
_t('Apr'),
_t('May'),
_t('Jun'),
_t('Jul'),
_t('Aug'),
_t('Sep'),
_t('Oct'),
_t('Nov'),
_t('Dec'),
];
/**
* Returns array of 12 month names, from January to December, internationalised to the user's language.
* @param month - format desired "numeric" | "2-digit" | "long" | "short" | "narrow"
*/
export function getMonthsArray(month: Intl.DateTimeFormatOptions["month"] = "short"): string[] {
const { format } = new Intl.DateTimeFormat(getUserLanguage(), { month, timeZone: "UTC" });
return [...Array(12).keys()].map((m) => format(Date.UTC(2021, m)));
}
function pad(n: number): string {
return (n < 10 ? '0' : '') + n;
// XXX: Ideally we could just specify `hour12: boolean` but it has issues on Chrome in the `en` locale
// https://support.google.com/chrome/thread/29828561?hl=en
function getTwelveHourOptions(showTwelveHour: boolean): Intl.DateTimeFormatOptions {
return {
hourCycle: showTwelveHour ? "h12" : "h23",
};
}
function twelveHourTime(date: Date, showSeconds = false): string {
let hours = date.getHours() % 12;
const minutes = pad(date.getMinutes());
const ampm = date.getHours() >= 12 ? _t('PM') : _t('AM');
hours = hours ? hours : 12; // convert 0 -> 12
if (showSeconds) {
const seconds = pad(date.getSeconds());
return `${hours}:${minutes}:${seconds}${ampm}`;
}
return `${hours}:${minutes}${ampm}`;
}
export function formatDate(date: Date, showTwelveHour = false): string {
/**
* Formats a given date to a date & time string.
*
* The output format depends on how far away the given date is from now.
* Will use the browser's default time zone.
* If the date is today it will return a time string excluding seconds. See {@formatTime}.
* If the date is within the last 6 days it will return the name of the weekday along with the time string excluding seconds.
* If the date is within the same year then it will return the weekday, month and day of the month along with the time string excluding seconds.
* Otherwise, it will return a string representing the full date & time in a human friendly manner. See {@formatFullDate}.
* @param date - date object to format
* @param showTwelveHour - whether to use 12-hour rather than 24-hour time. Defaults to `false` (24 hour mode).
* Overrides the default from the locale, whether `true` or `false`.
* @param locale - the locale string to use, in BCP 47 format, defaulting to user's selected application locale
*/
export function formatDate(date: Date, showTwelveHour = false, locale?: string): string {
const _locale = locale ?? getUserLanguage();
const now = new Date();
const days = getDaysArray();
const months = getMonthsArray();
if (date.toDateString() === now.toDateString()) {
return formatTime(date, showTwelveHour);
} else if (now.getTime() - date.getTime() < 6 * 24 * 60 * 60 * 1000) {
// TODO: use standard date localize function provided in counterpart
return _t('%(weekDayName)s %(time)s', {
weekDayName: days[date.getDay()],
time: formatTime(date, showTwelveHour),
});
return formatTime(date, showTwelveHour, _locale);
} else if (now.getTime() - date.getTime() < 6 * DAY_MS) {
// Time is within the last 6 days (or in the future)
return new Intl.DateTimeFormat(_locale, {
...getTwelveHourOptions(showTwelveHour),
weekday: "short",
hour: "numeric",
minute: "2-digit",
}).format(date);
} else if (now.getFullYear() === date.getFullYear()) {
// TODO: use standard date localize function provided in counterpart
return _t('%(weekDayName)s, %(monthName)s %(day)s %(time)s', {
weekDayName: days[date.getDay()],
monthName: months[date.getMonth()],
day: date.getDate(),
time: formatTime(date, showTwelveHour),
});
return new Intl.DateTimeFormat(_locale, {
...getTwelveHourOptions(showTwelveHour),
weekday: "short",
month: "short",
day: "numeric",
hour: "numeric",
minute: "2-digit",
}).format(date);
}
return formatFullDate(date, showTwelveHour);
return formatFullDate(date, showTwelveHour, false, _locale);
}
export function formatFullDateNoTime(date: Date): string {
const days = getDaysArray();
const months = getMonthsArray();
return _t('%(weekDayName)s, %(monthName)s %(day)s %(fullYear)s', {
weekDayName: days[date.getDay()],
monthName: months[date.getMonth()],
day: date.getDate(),
fullYear: date.getFullYear(),
});
/**
* Formats a given date to a human-friendly string with short weekday.
* Will use the browser's default time zone.
* @example "Thu, 17 Nov 2022" in en-GB locale
* @param date - date object to format
* @param locale - the locale string to use, in BCP 47 format, defaulting to user's selected application locale
*/
export function formatFullDateNoTime(date: Date, locale?: string): string {
return new Intl.DateTimeFormat(locale ?? getUserLanguage(), {
weekday: "short",
month: "short",
day: "numeric",
year: "numeric",
}).format(date);
}
export function formatFullDate(date: Date, showTwelveHour = false, showSeconds = true): string {
const days = getDaysArray();
const months = getMonthsArray();
return _t('%(weekDayName)s, %(monthName)s %(day)s %(fullYear)s %(time)s', {
weekDayName: days[date.getDay()],
monthName: months[date.getMonth()],
day: date.getDate(),
fullYear: date.getFullYear(),
time: showSeconds ? formatFullTime(date, showTwelveHour) : formatTime(date, showTwelveHour),
});
/**
* Formats a given date to a date & time string, optionally including seconds.
* Will use the browser's default time zone.
* @example "Thu, 17 Nov 2022, 4:58:32 pm" in en-GB locale with showTwelveHour=true and showSeconds=true
* @param date - date object to format
* @param showTwelveHour - whether to use 12-hour rather than 24-hour time. Defaults to `false` (24 hour mode).
* Overrides the default from the locale, whether `true` or `false`.
* @param showSeconds - whether to include seconds in the time portion of the string
* @param locale - the locale string to use, in BCP 47 format, defaulting to user's selected application locale
*/
export function formatFullDate(date: Date, showTwelveHour = false, showSeconds = true, locale?: string): string {
return new Intl.DateTimeFormat(locale ?? getUserLanguage(), {
...getTwelveHourOptions(showTwelveHour),
weekday: "short",
month: "short",
day: "numeric",
year: "numeric",
hour: "numeric",
minute: "2-digit",
second: showSeconds ? "2-digit" : undefined,
}).format(date);
}
export function formatFullTime(date: Date, showTwelveHour = false): string {
if (showTwelveHour) {
return twelveHourTime(date, true);
}
return pad(date.getHours()) + ':' + pad(date.getMinutes()) + ':' + pad(date.getSeconds());
/**
* Formats dates to be compatible with attributes of a `<input type="date">`. Dates
* should be formatted like "2020-06-23" (formatted according to ISO8601).
*
* @param date The date to format.
* @returns The date string in ISO8601 format ready to be used with an `<input>`
*/
export function formatDateForInput(date: Date): string {
const year = `${date.getFullYear()}`.padStart(4, "0");
const month = `${date.getMonth() + 1}`.padStart(2, "0");
const day = `${date.getDate()}`.padStart(2, "0");
return `${year}-${month}-${day}`;
}
export function formatTime(date: Date, showTwelveHour = false): string {
if (showTwelveHour) {
return twelveHourTime(date);
}
return pad(date.getHours()) + ':' + pad(date.getMinutes());
/**
* Formats a given date to a time string including seconds.
* Will use the browser's default time zone.
* @example "4:58:32 PM" in en-GB locale with showTwelveHour=true
* @example "16:58:32" in en-GB locale with showTwelveHour=false
* @param date - date object to format
* @param showTwelveHour - whether to use 12-hour rather than 24-hour time. Defaults to `false` (24 hour mode).
* Overrides the default from the locale, whether `true` or `false`.
* @param locale - the locale string to use, in BCP 47 format, defaulting to user's selected application locale
*/
export function formatFullTime(date: Date, showTwelveHour = false, locale?: string): string {
return new Intl.DateTimeFormat(locale ?? getUserLanguage(), {
...getTwelveHourOptions(showTwelveHour),
hour: "numeric",
minute: "2-digit",
second: "2-digit",
}).format(date);
}
export function formatCallTime(delta: Date): string {
const hours = delta.getUTCHours();
const minutes = delta.getUTCMinutes();
const seconds = delta.getUTCSeconds();
let output = "";
if (hours) output += `${hours}h `;
if (minutes || output) output += `${minutes}m `;
if (seconds || output) output += `${seconds}s`;
return output;
/**
* Formats a given date to a time string excluding seconds.
* Will use the browser's default time zone.
* @example "4:58 PM" in en-GB locale with showTwelveHour=true
* @example "16:58" in en-GB locale with showTwelveHour=false
* @param date - date object to format
* @param showTwelveHour - whether to use 12-hour rather than 24-hour time. Defaults to `false` (24 hour mode).
* Overrides the default from the locale, whether `true` or `false`.
* @param locale - the locale string to use, in BCP 47 format, defaulting to user's selected application locale
*/
export function formatTime(date: Date, showTwelveHour = false, locale?: string): string {
return new Intl.DateTimeFormat(locale ?? getUserLanguage(), {
...getTwelveHourOptions(showTwelveHour),
hour: "numeric",
minute: "2-digit",
}).format(date);
}
export function formatSeconds(inSeconds: number): string {
const hours = Math.floor(inSeconds / (60 * 60)).toFixed(0).padStart(2, '0');
const minutes = Math.floor((inSeconds % (60 * 60)) / 60).toFixed(0).padStart(2, '0');
const seconds = Math.floor(((inSeconds % (60 * 60)) % 60)).toFixed(0).padStart(2, '0');
const isNegative = inSeconds < 0;
inSeconds = Math.abs(inSeconds);
const hours = Math.floor(inSeconds / (60 * 60))
.toFixed(0)
.padStart(2, "0");
const minutes = Math.floor((inSeconds % (60 * 60)) / 60)
.toFixed(0)
.padStart(2, "0");
const seconds = Math.floor((inSeconds % (60 * 60)) % 60)
.toFixed(0)
.padStart(2, "0");
let output = "";
if (hours !== "00") output += `${hours}:`;
output += `${minutes}:${seconds}`;
if (isNegative) {
output = "-" + output;
}
return output;
}
const MILLIS_IN_DAY = 86400000;
export function formatTimeLeft(inSeconds: number): string {
const hours = Math.floor(inSeconds / (60 * 60)).toFixed(0);
const minutes = Math.floor((inSeconds % (60 * 60)) / 60).toFixed(0);
const seconds = Math.floor((inSeconds % (60 * 60)) % 60).toFixed(0);
if (hours !== "0") {
return _t("time|hours_minutes_seconds_left", {
hours,
minutes,
seconds,
});
}
if (minutes !== "0") {
return _t("time|minutes_seconds_left", {
minutes,
seconds,
});
}
return _t("time|seconds_left", {
seconds,
});
}
function withinPast24Hours(prevDate: Date, nextDate: Date): boolean {
return Math.abs(prevDate.getTime() - nextDate.getTime()) <= MILLIS_IN_DAY;
return Math.abs(prevDate.getTime() - nextDate.getTime()) <= DAY_MS;
}
function withinCurrentDay(prevDate: Date, nextDate: Date): boolean {
return withinPast24Hours(prevDate, nextDate) && prevDate.getDay() === nextDate.getDay();
}
function withinCurrentYear(prevDate: Date, nextDate: Date): boolean {
return prevDate.getFullYear() === nextDate.getFullYear();
}
export function wantsDateSeparator(prevEventDate: Date, nextEventDate: Date): boolean {
export function wantsDateSeparator(prevEventDate: Optional<Date>, nextEventDate: Optional<Date>): boolean {
if (!nextEventDate || !prevEventDate) {
return false;
}
@ -171,16 +256,16 @@ export function wantsDateSeparator(prevEventDate: Date, nextEventDate: Date): bo
return prevEventDate.getDay() !== nextEventDate.getDay();
}
export function formatFullDateNoDay(date: Date) {
return _t("%(date)s at %(time)s", {
date: date.toLocaleDateString().replace(/\//g, '-'),
time: date.toLocaleTimeString().replace(/:/g, '-'),
export function formatFullDateNoDay(date: Date): string {
const locale = getUserLanguage();
return _t("time|date_at_time", {
date: date.toLocaleDateString(locale).replace(/\//g, "-"),
time: date.toLocaleTimeString(locale).replace(/:/g, "-"),
});
}
/**
* Returns an ISO date string without textual description of the date (ie: no "Wednesday" or
* similar)
* Returns an ISO date string without textual description of the date (ie: no "Wednesday" or similar)
* @param date The date to format.
* @returns The date string in ISO format.
*/
@ -188,19 +273,24 @@ export function formatFullDateNoDayISO(date: Date): string {
return date.toISOString();
}
export function formatFullDateNoDayNoTime(date: Date) {
return (
date.getFullYear() +
"/" +
pad(date.getMonth() + 1) +
"/" +
pad(date.getDate())
);
/**
* Formats a given date to a string.
* Will use the browser's default time zone.
* @example 17/11/2022 in en-GB locale
* @param date - date object to format
* @param locale - the locale string to use, in BCP 47 format, defaulting to user's selected application locale
*/
export function formatFullDateNoDayNoTime(date: Date, locale?: string): string {
return new Intl.DateTimeFormat(locale ?? getUserLanguage(), {
year: "numeric",
month: "numeric",
day: "numeric",
}).format(date);
}
export function formatRelativeTime(date: Date, showTwelveHour = false): string {
const now = new Date(Date.now());
if (withinPast24Hours(date, now)) {
const now = new Date();
if (withinCurrentDay(date, now)) {
return formatTime(date, showTwelveHour);
} else {
const months = getMonthsArray();
@ -214,23 +304,56 @@ export function formatRelativeTime(date: Date, showTwelveHour = false): string {
}
/**
* Formats duration in ms to human readable string
* Returns value in biggest possible unit (day, hour, min, second)
* Formats duration in ms to human-readable string
* Returns value in the biggest possible unit (day, hour, min, second)
* Rounds values up until unit threshold
* ie. 23:13:57 -> 23h, 24:13:57 -> 1d, 44:56:56 -> 2d
* i.e. 23:13:57 -> 23h, 24:13:57 -> 1d, 44:56:56 -> 2d
*/
const MINUTE_MS = 60000;
const HOUR_MS = MINUTE_MS * 60;
const DAY_MS = HOUR_MS * 24;
export function formatDuration(durationMs: number): string {
if (durationMs >= DAY_MS) {
return _t('%(value)sd', { value: Math.round(durationMs / DAY_MS) });
return _t("time|short_days", { value: Math.round(durationMs / DAY_MS) });
}
if (durationMs >= HOUR_MS) {
return _t('%(value)sh', { value: Math.round(durationMs / HOUR_MS) });
return _t("time|short_hours", { value: Math.round(durationMs / HOUR_MS) });
}
if (durationMs >= MINUTE_MS) {
return _t('%(value)sm', { value: Math.round(durationMs / MINUTE_MS) });
return _t("time|short_minutes", { value: Math.round(durationMs / MINUTE_MS) });
}
return _t('%(value)ss', { value: Math.round(durationMs / 1000) });
return _t("time|short_seconds", { value: Math.round(durationMs / 1000) });
}
/**
* Formats duration in ms to human-readable string
* Returns precise value down to the nearest second
* i.e. 23:13:57 -> 23h 13m 57s, 44:56:56 -> 1d 20h 56m 56s
*/
export function formatPreciseDuration(durationMs: number): string {
const days = Math.floor(durationMs / DAY_MS);
const hours = Math.floor((durationMs % DAY_MS) / HOUR_MS);
const minutes = Math.floor((durationMs % HOUR_MS) / MINUTE_MS);
const seconds = Math.floor((durationMs % MINUTE_MS) / 1000);
if (days > 0) {
return _t("time|short_days_hours_minutes_seconds", { days, hours, minutes, seconds });
}
if (hours > 0) {
return _t("time|short_hours_minutes_seconds", { hours, minutes, seconds });
}
if (minutes > 0) {
return _t("time|short_minutes_seconds", { minutes, seconds });
}
return _t("time|short_seconds", { value: seconds });
}
/**
* Formats a timestamp to a short date
* Similar to {@formatFullDateNoDayNoTime} but with 2-digit on day, month, year.
* @example 25/12/22 in en-GB locale
* @param timestamp - epoch timestamp
* @param locale - the locale string to use, in BCP 47 format, defaulting to user's selected application locale
* @returns {string} formattedDate
*/
export const formatLocalDateShort = (timestamp: number, locale?: string): string =>
new Intl.DateTimeFormat(locale ?? getUserLanguage(), { day: "2-digit", month: "2-digit", year: "2-digit" }).format(
timestamp,
);

View file

@ -15,15 +15,18 @@ limitations under the License.
*/
import { DecryptionError } from "matrix-js-sdk/src/crypto/algorithms";
import { MatrixEvent } from "matrix-js-sdk/src/models/event";
import { MatrixEvent } from "matrix-js-sdk/src/matrix";
import { Error as ErrorEvent } from "@matrix-org/analytics-events/types/typescript/Error";
import { PosthogAnalytics } from './PosthogAnalytics';
import { PosthogAnalytics } from "./PosthogAnalytics";
export class DecryptionFailure {
public readonly ts: number;
constructor(public readonly failedEventId: string, public readonly errorCode: string) {
public constructor(
public readonly failedEventId: string,
public readonly errorCode: string,
) {
this.ts = Date.now();
}
}
@ -35,28 +38,31 @@ type TrackingFn = (count: number, trackedErrCode: ErrorCode, rawError: string) =
export type ErrCodeMapFn = (errcode: string) => ErrorCode;
export class DecryptionFailureTracker {
private static internalInstance = new DecryptionFailureTracker((total, errorCode, rawError) => {
for (let i = 0; i < total; i++) {
PosthogAnalytics.instance.trackEvent<ErrorEvent>({
eventName: "Error",
domain: "E2EE",
name: errorCode,
context: `mxc_crypto_error_type_${rawError}`,
});
}
}, (errorCode) => {
// Map JS-SDK error codes to tracker codes for aggregation
switch (errorCode) {
case 'MEGOLM_UNKNOWN_INBOUND_SESSION_ID':
return 'OlmKeysNotSentError';
case 'OLM_UNKNOWN_MESSAGE_INDEX':
return 'OlmIndexError';
case undefined:
return 'OlmUnspecifiedError';
default:
return 'UnknownError';
}
});
private static internalInstance = new DecryptionFailureTracker(
(total, errorCode, rawError) => {
for (let i = 0; i < total; i++) {
PosthogAnalytics.instance.trackEvent<ErrorEvent>({
eventName: "Error",
domain: "E2EE",
name: errorCode,
context: `mxc_crypto_error_type_${rawError}`,
});
}
},
(errorCode) => {
// Map JS-SDK error codes to tracker codes for aggregation
switch (errorCode) {
case "MEGOLM_UNKNOWN_INBOUND_SESSION_ID":
return "OlmKeysNotSentError";
case "OLM_UNKNOWN_MESSAGE_INDEX":
return "OlmIndexError";
case undefined:
return "OlmUnspecifiedError";
default:
return "UnknownError";
}
},
);
// Map of event IDs to DecryptionFailure items.
public failures: Map<string, DecryptionFailure> = new Map();
@ -80,18 +86,18 @@ export class DecryptionFailureTracker {
public trackedEvents: Set<string> = new Set();
// Set to an interval ID when `start` is called
public checkInterval: number = null;
public trackInterval: number = null;
public checkInterval: number | null = null;
public trackInterval: number | null = null;
// Spread the load on `Analytics` by tracking at a low frequency, `TRACK_INTERVAL_MS`.
static TRACK_INTERVAL_MS = 60000;
public static TRACK_INTERVAL_MS = 60000;
// Call `checkFailures` every `CHECK_INTERVAL_MS`.
static CHECK_INTERVAL_MS = 5000;
public static CHECK_INTERVAL_MS = 40000;
// Give events a chance to be decrypted by waiting `GRACE_PERIOD_MS` before counting
// the failure in `failureCounts`.
static GRACE_PERIOD_MS = 4000;
public static GRACE_PERIOD_MS = 30000;
/**
* Create a new DecryptionFailureTracker.
@ -107,13 +113,16 @@ export class DecryptionFailureTracker {
* @param {function?} errorCodeMapFn The function used to map error codes to the
* trackedErrorCode. If not provided, the `.code` of errors will be used.
*/
private constructor(private readonly fn: TrackingFn, private readonly errorCodeMapFn: ErrCodeMapFn) {
if (!fn || typeof fn !== 'function') {
throw new Error('DecryptionFailureTracker requires tracking function');
private constructor(
private readonly fn: TrackingFn,
private readonly errorCodeMapFn: ErrCodeMapFn,
) {
if (!fn || typeof fn !== "function") {
throw new Error("DecryptionFailureTracker requires tracking function");
}
if (typeof errorCodeMapFn !== 'function') {
throw new Error('DecryptionFailureTracker second constructor argument should be a function');
if (typeof errorCodeMapFn !== "function") {
throw new Error("DecryptionFailureTracker second constructor argument should be a function");
}
}
@ -135,7 +144,7 @@ export class DecryptionFailureTracker {
return;
}
if (err) {
this.addDecryptionFailure(new DecryptionFailure(e.getId(), err.code));
this.addDecryptionFailure(new DecryptionFailure(e.getId()!, err.code));
} else {
// Could be an event in the failures, remove it
this.removeDecryptionFailuresForEvent(e);
@ -143,20 +152,24 @@ export class DecryptionFailureTracker {
}
public addVisibleEvent(e: MatrixEvent): void {
const eventId = e.getId();
const eventId = e.getId()!;
if (this.trackedEvents.has(eventId)) { return; }
if (this.trackedEvents.has(eventId)) {
return;
}
this.visibleEvents.add(eventId);
if (this.failures.has(eventId) && !this.visibleFailures.has(eventId)) {
this.visibleFailures.set(eventId, this.failures.get(eventId));
this.visibleFailures.set(eventId, this.failures.get(eventId)!);
}
}
public addDecryptionFailure(failure: DecryptionFailure): void {
const eventId = failure.failedEventId;
if (this.trackedEvents.has(eventId)) { return; }
if (this.trackedEvents.has(eventId)) {
return;
}
this.failures.set(eventId, failure);
if (this.visibleEvents.has(eventId) && !this.visibleFailures.has(eventId)) {
@ -165,7 +178,7 @@ export class DecryptionFailureTracker {
}
public removeDecryptionFailuresForEvent(e: MatrixEvent): void {
const eventId = e.getId();
const eventId = e.getId()!;
this.failures.delete(eventId);
this.visibleFailures.delete(eventId);
}
@ -174,23 +187,20 @@ export class DecryptionFailureTracker {
* Start checking for and tracking failures.
*/
public start(): void {
this.checkInterval = setInterval(
this.checkInterval = window.setInterval(
() => this.checkFailures(Date.now()),
DecryptionFailureTracker.CHECK_INTERVAL_MS,
);
this.trackInterval = setInterval(
() => this.trackFailures(),
DecryptionFailureTracker.TRACK_INTERVAL_MS,
);
this.trackInterval = window.setInterval(() => this.trackFailures(), DecryptionFailureTracker.TRACK_INTERVAL_MS);
}
/**
* Clear state and stop checking for and tracking failures.
*/
public stop(): void {
clearInterval(this.checkInterval);
clearInterval(this.trackInterval);
if (this.checkInterval) clearInterval(this.checkInterval);
if (this.trackInterval) clearInterval(this.trackInterval);
this.failures = new Map();
this.visibleEvents = new Set();

View file

@ -14,13 +14,19 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import { MatrixEvent } from "matrix-js-sdk/src/models/event";
import {
MatrixEvent,
ClientEvent,
EventType,
MatrixClient,
RoomStateEvent,
SyncState,
ClientStoppedError,
} from "matrix-js-sdk/src/matrix";
import { logger } from "matrix-js-sdk/src/logger";
import { CryptoEvent } from "matrix-js-sdk/src/crypto";
import { ClientEvent, EventType, RoomStateEvent } from "matrix-js-sdk/src/matrix";
import { SyncState } from "matrix-js-sdk/src/sync";
import { KeyBackupInfo } from "matrix-js-sdk/src/crypto-api";
import { MatrixClientPeg } from './MatrixClientPeg';
import dis from "./dispatcher/dispatcher";
import {
hideToast as hideBulkUnverifiedSessionsToast,
@ -36,58 +42,64 @@ import {
showToast as showUnverifiedSessionsToast,
} from "./toasts/UnverifiedSessionToast";
import { accessSecretStorage, isSecretStorageBeingAccessed } from "./SecurityManager";
import { isSecureBackupRequired } from './utils/WellKnownUtils';
import { isSecureBackupRequired } from "./utils/WellKnownUtils";
import { ActionPayload } from "./dispatcher/payloads";
import { Action } from "./dispatcher/actions";
import { isLoggedIn } from "./utils/login";
import SdkConfig from "./SdkConfig";
import PlatformPeg from "./PlatformPeg";
import {
recordClientInformation,
removeClientInformation,
} from "./utils/device/clientInformation";
import { recordClientInformation, removeClientInformation } from "./utils/device/clientInformation";
import SettingsStore, { CallbackFn } from "./settings/SettingsStore";
import { UIFeature } from "./settings/UIFeature";
import { isBulkUnverifiedDeviceReminderSnoozed } from "./utils/device/snoozeBulkUnverifiedDeviceReminder";
import { getUserDeviceIds } from "./utils/crypto/deviceInfo";
const KEY_BACKUP_POLL_INTERVAL = 5 * 60 * 1000;
export default class DeviceListener {
private dispatcherRef: string;
private dispatcherRef?: string;
// device IDs for which the user has dismissed the verify toast ('Later')
private dismissed = new Set<string>();
// has the user dismissed any of the various nag toasts to setup encryption on this device?
private dismissedThisDeviceToast = false;
// cache of the key backup info
private keyBackupInfo: object = null;
private keyBackupFetchedAt: number = null;
private keyBackupStatusChecked = false;
/** Cache of the info about the current key backup on the server. */
private keyBackupInfo: KeyBackupInfo | null = null;
/** When `keyBackupInfo` was last updated */
private keyBackupFetchedAt: number | null = null;
// We keep a list of our own device IDs so we can batch ones that were already
// there the last time the app launched into a single toast, but display new
// ones in their own toasts.
private ourDeviceIdsAtStart: Set<string> = null;
private ourDeviceIdsAtStart: Set<string> | null = null;
// The set of device IDs we're currently displaying toasts for
private displayingToastsForDeviceIds = new Set<string>();
private running = false;
// The client with which the instance is running. Only set if `running` is true, otherwise undefined.
private client?: MatrixClient;
private shouldRecordClientInformation = false;
private enableBulkUnverifiedSessionsReminder = true;
private deviceClientInformationSettingWatcherRef: string | undefined;
public static sharedInstance() {
public static sharedInstance(): DeviceListener {
if (!window.mxDeviceListener) window.mxDeviceListener = new DeviceListener();
return window.mxDeviceListener;
}
public start() {
public start(matrixClient: MatrixClient): void {
this.running = true;
MatrixClientPeg.get().on(CryptoEvent.WillUpdateDevices, this.onWillUpdateDevices);
MatrixClientPeg.get().on(CryptoEvent.DevicesUpdated, this.onDevicesUpdated);
MatrixClientPeg.get().on(CryptoEvent.DeviceVerificationChanged, this.onDeviceVerificationChanged);
MatrixClientPeg.get().on(CryptoEvent.UserTrustStatusChanged, this.onUserTrustStatusChanged);
MatrixClientPeg.get().on(CryptoEvent.KeysChanged, this.onCrossSingingKeysChanged);
MatrixClientPeg.get().on(ClientEvent.AccountData, this.onAccountData);
MatrixClientPeg.get().on(ClientEvent.Sync, this.onSync);
MatrixClientPeg.get().on(RoomStateEvent.Events, this.onRoomStateEvents);
this.shouldRecordClientInformation = SettingsStore.getValue('deviceClientInformationOptIn');
this.client = matrixClient;
this.client.on(CryptoEvent.WillUpdateDevices, this.onWillUpdateDevices);
this.client.on(CryptoEvent.DevicesUpdated, this.onDevicesUpdated);
this.client.on(CryptoEvent.DeviceVerificationChanged, this.onDeviceVerificationChanged);
this.client.on(CryptoEvent.UserTrustStatusChanged, this.onUserTrustStatusChanged);
this.client.on(CryptoEvent.KeysChanged, this.onCrossSingingKeysChanged);
this.client.on(ClientEvent.AccountData, this.onAccountData);
this.client.on(ClientEvent.Sync, this.onSync);
this.client.on(RoomStateEvent.Events, this.onRoomStateEvents);
this.shouldRecordClientInformation = SettingsStore.getValue("deviceClientInformationOptIn");
// only configurable in config, so we don't need to watch the value
this.enableBulkUnverifiedSessionsReminder = SettingsStore.getValue(UIFeature.BulkUnverifiedSessionsReminder);
this.deviceClientInformationSettingWatcherRef = SettingsStore.watchSetting(
'deviceClientInformationOptIn',
"deviceClientInformationOptIn",
null,
this.onRecordClientInformationSettingChange,
);
@ -96,27 +108,24 @@ export default class DeviceListener {
this.updateClientInformation();
}
public stop() {
public stop(): void {
this.running = false;
if (MatrixClientPeg.get()) {
MatrixClientPeg.get().removeListener(CryptoEvent.WillUpdateDevices, this.onWillUpdateDevices);
MatrixClientPeg.get().removeListener(CryptoEvent.DevicesUpdated, this.onDevicesUpdated);
MatrixClientPeg.get().removeListener(
CryptoEvent.DeviceVerificationChanged,
this.onDeviceVerificationChanged,
);
MatrixClientPeg.get().removeListener(CryptoEvent.UserTrustStatusChanged, this.onUserTrustStatusChanged);
MatrixClientPeg.get().removeListener(CryptoEvent.KeysChanged, this.onCrossSingingKeysChanged);
MatrixClientPeg.get().removeListener(ClientEvent.AccountData, this.onAccountData);
MatrixClientPeg.get().removeListener(ClientEvent.Sync, this.onSync);
MatrixClientPeg.get().removeListener(RoomStateEvent.Events, this.onRoomStateEvents);
if (this.client) {
this.client.removeListener(CryptoEvent.WillUpdateDevices, this.onWillUpdateDevices);
this.client.removeListener(CryptoEvent.DevicesUpdated, this.onDevicesUpdated);
this.client.removeListener(CryptoEvent.DeviceVerificationChanged, this.onDeviceVerificationChanged);
this.client.removeListener(CryptoEvent.UserTrustStatusChanged, this.onUserTrustStatusChanged);
this.client.removeListener(CryptoEvent.KeysChanged, this.onCrossSingingKeysChanged);
this.client.removeListener(ClientEvent.AccountData, this.onAccountData);
this.client.removeListener(ClientEvent.Sync, this.onSync);
this.client.removeListener(RoomStateEvent.Events, this.onRoomStateEvents);
}
if (this.deviceClientInformationSettingWatcherRef) {
SettingsStore.unwatchSetting(this.deviceClientInformationSettingWatcherRef);
}
if (this.dispatcherRef) {
dis.unregister(this.dispatcherRef);
this.dispatcherRef = null;
this.dispatcherRef = undefined;
}
this.dismissed.clear();
this.dismissedThisDeviceToast = false;
@ -125,6 +134,7 @@ export default class DeviceListener {
this.keyBackupStatusChecked = false;
this.ourDeviceIdsAtStart = null;
this.displayingToastsForDeviceIds = new Set();
this.client = undefined;
}
/**
@ -132,8 +142,8 @@ export default class DeviceListener {
*
* @param {String[]} deviceIds List of device IDs to dismiss notifications for
*/
public async dismissUnverifiedSessions(deviceIds: Iterable<string>) {
logger.log("Dismissing unverified sessions: " + Array.from(deviceIds).join(','));
public async dismissUnverifiedSessions(deviceIds: Iterable<string>): Promise<void> {
logger.log("Dismissing unverified sessions: " + Array.from(deviceIds).join(","));
for (const d of deviceIds) {
this.dismissed.add(d);
}
@ -141,74 +151,85 @@ export default class DeviceListener {
this.recheck();
}
public dismissEncryptionSetup() {
public dismissEncryptionSetup(): void {
this.dismissedThisDeviceToast = true;
this.recheck();
}
private ensureDeviceIdsAtStartPopulated() {
private async ensureDeviceIdsAtStartPopulated(): Promise<void> {
if (this.ourDeviceIdsAtStart === null) {
const cli = MatrixClientPeg.get();
this.ourDeviceIdsAtStart = new Set(
cli.getStoredDevicesForUser(cli.getUserId()).map(d => d.deviceId),
);
this.ourDeviceIdsAtStart = await this.getDeviceIds();
}
}
private onWillUpdateDevices = async (users: string[], initialFetch?: boolean) => {
/** Get the device list for the current user
*
* @returns the set of device IDs
*/
private async getDeviceIds(): Promise<Set<string>> {
const cli = this.client;
if (!cli) return new Set();
return await getUserDeviceIds(cli, cli.getSafeUserId());
}
private onWillUpdateDevices = async (users: string[], initialFetch?: boolean): Promise<void> => {
if (!this.client) return;
// If we didn't know about *any* devices before (ie. it's fresh login),
// then they are all pre-existing devices, so ignore this and set the
// devicesAtStart list to the devices that we see after the fetch.
if (initialFetch) return;
const myUserId = MatrixClientPeg.get().getUserId();
if (users.includes(myUserId)) this.ensureDeviceIdsAtStartPopulated();
const myUserId = this.client.getSafeUserId();
if (users.includes(myUserId)) await this.ensureDeviceIdsAtStartPopulated();
// No need to do a recheck here: we just need to get a snapshot of our devices
// before we download any new ones.
};
private onDevicesUpdated = (users: string[]) => {
if (!users.includes(MatrixClientPeg.get().getUserId())) return;
private onDevicesUpdated = (users: string[]): void => {
if (!this.client) return;
if (!users.includes(this.client.getSafeUserId())) return;
this.recheck();
};
private onDeviceVerificationChanged = (userId: string) => {
if (userId !== MatrixClientPeg.get().getUserId()) return;
private onDeviceVerificationChanged = (userId: string): void => {
if (!this.client) return;
if (userId !== this.client.getUserId()) return;
this.recheck();
};
private onUserTrustStatusChanged = (userId: string) => {
if (userId !== MatrixClientPeg.get().getUserId()) return;
private onUserTrustStatusChanged = (userId: string): void => {
if (!this.client) return;
if (userId !== this.client.getUserId()) return;
this.recheck();
};
private onCrossSingingKeysChanged = () => {
private onCrossSingingKeysChanged = (): void => {
this.recheck();
};
private onAccountData = (ev: MatrixEvent) => {
private onAccountData = (ev: MatrixEvent): void => {
// User may have:
// * migrated SSSS to symmetric
// * uploaded keys to secret storage
// * completed secret storage creation
// which result in account data changes affecting checks below.
if (
ev.getType().startsWith('m.secret_storage.') ||
ev.getType().startsWith('m.cross_signing.') ||
ev.getType() === 'm.megolm_backup.v1'
ev.getType().startsWith("m.secret_storage.") ||
ev.getType().startsWith("m.cross_signing.") ||
ev.getType() === "m.megolm_backup.v1"
) {
this.recheck();
}
};
private onSync = (state: SyncState, prevState?: SyncState) => {
if (state === 'PREPARED' && prevState === null) {
private onSync = (state: SyncState, prevState: SyncState | null): void => {
if (state === "PREPARED" && prevState === null) {
this.recheck();
}
};
private onRoomStateEvents = (ev: MatrixEvent) => {
private onRoomStateEvents = (ev: MatrixEvent): void => {
if (ev.getType() !== EventType.RoomEncryption) return;
// If a room changes to encrypted, re-check as it may be our first
@ -216,46 +237,69 @@ export default class DeviceListener {
this.recheck();
};
private onAction = ({ action }: ActionPayload) => {
private onAction = ({ action }: ActionPayload): void => {
if (action !== Action.OnLoggedIn) return;
this.recheck();
this.updateClientInformation();
};
// The server doesn't tell us when key backup is set up, so we poll
// & cache the result
private async getKeyBackupInfo() {
const now = (new Date()).getTime();
if (!this.keyBackupInfo || this.keyBackupFetchedAt < now - KEY_BACKUP_POLL_INTERVAL) {
this.keyBackupInfo = await MatrixClientPeg.get().getKeyBackupVersion();
/**
* Fetch the key backup information from the server.
*
* The result is cached for `KEY_BACKUP_POLL_INTERVAL` ms to avoid repeated API calls.
*
* @returns The key backup info from the server, or `null` if there is no key backup.
*/
private async getKeyBackupInfo(): Promise<KeyBackupInfo | null> {
if (!this.client) return null;
const now = new Date().getTime();
if (
!this.keyBackupInfo ||
!this.keyBackupFetchedAt ||
this.keyBackupFetchedAt < now - KEY_BACKUP_POLL_INTERVAL
) {
this.keyBackupInfo = await this.client.getKeyBackupVersion();
this.keyBackupFetchedAt = now;
}
return this.keyBackupInfo;
}
private shouldShowSetupEncryptionToast() {
private shouldShowSetupEncryptionToast(): boolean {
// If we're in the middle of a secret storage operation, we're likely
// modifying the state involved here, so don't add new toasts to setup.
if (isSecretStorageBeingAccessed()) return false;
// Show setup toasts once the user is in at least one encrypted room.
const cli = MatrixClientPeg.get();
return cli && cli.getRooms().some(r => cli.isRoomEncrypted(r.roomId));
const cli = this.client;
return cli?.getRooms().some((r) => cli.isRoomEncrypted(r.roomId)) ?? false;
}
private async recheck() {
if (!this.running) return; // we have been stopped
const cli = MatrixClientPeg.get();
private recheck(): void {
this.doRecheck().catch((e) => {
if (e instanceof ClientStoppedError) {
// the client was stopped while recheck() was running. Nothing left to do.
} else {
logger.error("Error during `DeviceListener.recheck`", e);
}
});
}
if (!(await cli.doesServerSupportUnstableFeature("org.matrix.e2e_cross_signing"))) return;
private async doRecheck(): Promise<void> {
if (!this.running || !this.client) return; // we have been stopped
const cli = this.client;
// cross-signing support was added to Matrix in MSC1756, which landed in spec v1.1
if (!(await cli.isVersionSupported("v1.1"))) return;
const crypto = cli.getCrypto();
if (!crypto) return;
if (!cli.isCryptoEnabled()) return;
// don't recheck until the initial sync is complete: lots of account data events will fire
// while the initial sync is processing and we don't need to recheck on each one of them
// (we add a listener on sync to do once check after the initial sync is done)
if (!cli.isInitialSyncComplete()) return;
const crossSigningReady = await cli.isCrossSigningReady();
const secretStorageReady = await cli.isSecretStorageReady();
const crossSigningReady = await crypto.isCrossSigningReady();
const secretStorageReady = await crypto.isSecretStorageReady();
const allSystemsReady = crossSigningReady && secretStorageReady;
if (this.dismissedThisDeviceToast || allSystemsReady) {
@ -264,13 +308,11 @@ export default class DeviceListener {
this.checkKeyBackupStatus();
} else if (this.shouldShowSetupEncryptionToast()) {
// make sure our keys are finished downloading
await cli.downloadKeys([cli.getUserId()]);
await crypto.getUserDeviceInfo([cli.getSafeUserId()]);
// cross signing isn't enabled - nag to enable it
// There are 3 different toasts for:
if (
!cli.getCrossSigningId() &&
cli.getStoredCrossSigningForUser(cli.getUserId())
) {
if (!(await crypto.getCrossSigningKeyId()) && (await crypto.userHasCrossSigningKeys())) {
// Cross-signing on account but this device doesn't trust the master key (verify this session)
showSetupEncryptionToast(SetupKind.VERIFY_THIS_SESSION);
this.checkKeyBackupStatus();
@ -282,7 +324,7 @@ export default class DeviceListener {
} else {
// No cross-signing or key backup on account (set up encryption)
await cli.waitForClientWellKnown();
if (isSecureBackupRequired() && isLoggedIn()) {
if (isSecureBackupRequired(cli) && isLoggedIn()) {
// If we're meant to set up, and Secure Backup is required,
// trigger the flow directly without a toast once logged in.
hideSetupEncryptionToast();
@ -294,9 +336,9 @@ export default class DeviceListener {
}
}
// This needs to be done after awaiting on downloadKeys() above, so
// This needs to be done after awaiting on getUserDeviceInfo() above, so
// we make sure we get the devices after the fetch is done.
this.ensureDeviceIdsAtStartPopulated();
await this.ensureDeviceIdsAtStartPopulated();
// Unverified devices that were there last time the app ran
// (technically could just be a boolean: we don't actually
@ -306,30 +348,44 @@ export default class DeviceListener {
// Unverified devices that have appeared since then
const newUnverifiedDeviceIds = new Set<string>();
const isCurrentDeviceTrusted =
crossSigningReady &&
Boolean(
(await crypto.getDeviceVerificationStatus(cli.getSafeUserId(), cli.deviceId!))?.crossSigningVerified,
);
// as long as cross-signing isn't ready,
// you can't see or dismiss any device toasts
if (crossSigningReady) {
const devices = cli.getStoredDevicesForUser(cli.getUserId());
for (const device of devices) {
if (device.deviceId === cli.deviceId) continue;
const devices = await this.getDeviceIds();
for (const deviceId of devices) {
if (deviceId === cli.deviceId) continue;
const deviceTrust = await cli.checkDeviceTrust(cli.getUserId(), device.deviceId);
if (!deviceTrust.isCrossSigningVerified() && !this.dismissed.has(device.deviceId)) {
if (this.ourDeviceIdsAtStart.has(device.deviceId)) {
oldUnverifiedDeviceIds.add(device.deviceId);
const deviceTrust = await crypto.getDeviceVerificationStatus(cli.getSafeUserId(), deviceId);
if (!deviceTrust?.crossSigningVerified && !this.dismissed.has(deviceId)) {
if (this.ourDeviceIdsAtStart?.has(deviceId)) {
oldUnverifiedDeviceIds.add(deviceId);
} else {
newUnverifiedDeviceIds.add(device.deviceId);
newUnverifiedDeviceIds.add(deviceId);
}
}
}
}
logger.debug("Old unverified sessions: " + Array.from(oldUnverifiedDeviceIds).join(','));
logger.debug("New unverified sessions: " + Array.from(newUnverifiedDeviceIds).join(','));
logger.debug("Currently showing toasts for: " + Array.from(this.displayingToastsForDeviceIds).join(','));
logger.debug("Old unverified sessions: " + Array.from(oldUnverifiedDeviceIds).join(","));
logger.debug("New unverified sessions: " + Array.from(newUnverifiedDeviceIds).join(","));
logger.debug("Currently showing toasts for: " + Array.from(this.displayingToastsForDeviceIds).join(","));
const isBulkUnverifiedSessionsReminderSnoozed = isBulkUnverifiedDeviceReminderSnoozed();
// Display or hide the batch toast for old unverified sessions
if (oldUnverifiedDeviceIds.size > 0) {
// don't show the toast if the current device is unverified
if (
oldUnverifiedDeviceIds.size > 0 &&
isCurrentDeviceTrusted &&
this.enableBulkUnverifiedSessionsReminder &&
!isBulkUnverifiedSessionsReminderSnoozed
) {
showBulkUnverifiedSessionsToast(oldUnverifiedDeviceIds);
} else {
hideBulkUnverifiedSessionsToast();
@ -351,21 +407,30 @@ export default class DeviceListener {
this.displayingToastsForDeviceIds = newUnverifiedDeviceIds;
}
private checkKeyBackupStatus = async () => {
if (this.keyBackupStatusChecked) {
/**
* Check if key backup is enabled, and if not, raise an `Action.ReportKeyBackupNotEnabled` event (which will
* trigger an auto-rageshake).
*/
private checkKeyBackupStatus = async (): Promise<void> => {
if (this.keyBackupStatusChecked || !this.client) {
return;
}
// returns null when key backup status hasn't finished being checked
const isKeyBackupEnabled = MatrixClientPeg.get().getKeyBackupEnabled();
this.keyBackupStatusChecked = isKeyBackupEnabled !== null;
const activeKeyBackupVersion = await this.client.getCrypto()?.getActiveSessionBackupVersion();
// if key backup is enabled, no need to check this ever again (XXX: why only when it is enabled?)
this.keyBackupStatusChecked = !!activeKeyBackupVersion;
if (isKeyBackupEnabled === false) {
if (!activeKeyBackupVersion) {
dis.dispatch({ action: Action.ReportKeyBackupNotEnabled });
}
};
private keyBackupStatusChecked = false;
private onRecordClientInformationSettingChange: CallbackFn = (
_originalSettingName, _roomId, _level, _newLevel, newValue,
_originalSettingName,
_roomId,
_level,
_newLevel,
newValue,
) => {
const prevValue = this.shouldRecordClientInformation;
@ -376,21 +441,18 @@ export default class DeviceListener {
}
};
private updateClientInformation = async () => {
private updateClientInformation = async (): Promise<void> => {
if (!this.client) return;
try {
if (this.shouldRecordClientInformation) {
await recordClientInformation(
MatrixClientPeg.get(),
SdkConfig.get(),
PlatformPeg.get(),
);
await recordClientInformation(this.client, SdkConfig.get(), PlatformPeg.get() ?? undefined);
} else {
await removeClientInformation(MatrixClientPeg.get());
await removeClientInformation(this.client);
}
} catch (error) {
// this is a best effort operation
// log the error without rethrowing
logger.error('Failed to update client information', error);
logger.error("Failed to update client information", error);
}
};
}

View file

@ -16,5 +16,6 @@ limitations under the License.
import { TimelineRenderingType } from "./contexts/RoomContext";
export const editorRoomKey = (roomId: string, context: TimelineRenderingType) => `mx_edit_room_${roomId}_${context}`;
export const editorStateKey = (eventId: string) => `mx_edit_state_${eventId}`;
export const editorRoomKey = (roomId: string, context: TimelineRenderingType): string =>
`mx_edit_room_${roomId}_${context}`;
export const editorStateKey = (eventId: string): string => `mx_edit_state_${eventId}`;

View file

@ -17,29 +17,32 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import React, { ReactNode } from 'react';
import sanitizeHtml from 'sanitize-html';
import cheerio from 'cheerio';
import classNames from 'classnames';
import EMOJIBASE_REGEX from 'emojibase-regex';
import { split } from 'lodash';
import katex from 'katex';
import { AllHtmlEntities } from 'html-entities';
import { IContent } from 'matrix-js-sdk/src/models/event';
import { Optional } from 'matrix-events-sdk';
import React, { LegacyRef, ReactElement, ReactNode } from "react";
import sanitizeHtml from "sanitize-html";
import classNames from "classnames";
import EMOJIBASE_REGEX from "emojibase-regex";
import { merge } from "lodash";
import katex from "katex";
import { decode } from "html-entities";
import { IContent } from "matrix-js-sdk/src/matrix";
import { Optional } from "matrix-events-sdk";
import _Linkify from "linkify-react";
import escapeHtml from "escape-html";
import GraphemeSplitter from "graphemer";
import { getEmojiFromUnicode } from "@matrix-org/emojibase-bindings";
import {
_linkifyElement,
_linkifyString,
ELEMENT_URL_PATTERN,
options as linkifyMatrixOptions,
} from './linkify-matrix';
import { IExtendedSanitizeOptions } from './@types/sanitize-html';
import SettingsStore from './settings/SettingsStore';
} from "./linkify-matrix";
import { IExtendedSanitizeOptions } from "./@types/sanitize-html";
import SettingsStore from "./settings/SettingsStore";
import { tryTransformPermalinkToLocalHref } from "./utils/permalinks/Permalinks";
import { getEmojiFromUnicode } from "./emoji";
import { mediaFromMxc } from "./customisations/Media";
import { stripHTMLReply, stripPlainReply } from './utils/Reply';
import { stripHTMLReply, stripPlainReply } from "./utils/Reply";
import { PERMITTED_URL_SCHEMES } from "./utils/UrlUtils";
// Anything outside the basic multilingual plane will be a surrogate pair
const SURROGATE_PAIR_PATTERN = /([\ud800-\udbff])([\udc00-\udfff])/;
@ -49,44 +52,14 @@ const SURROGATE_PAIR_PATTERN = /([\ud800-\udbff])([\udc00-\udfff])/;
// (with plenty of false positives, but that's OK)
const SYMBOL_PATTERN = /([\u2100-\u2bff])/;
// Regex pattern for Zero-Width joiner unicode characters
const ZWJ_REGEX = /[\u200D\u2003]/g;
// Regex pattern for non-emoji characters that can appear in an "all-emoji" message
// (Zero-Width Joiner, Zero-Width Space, Emoji presentation character, other whitespace)
const EMOJI_SEPARATOR_REGEX = /[\u200D\u200B\s]|\uFE0F/g;
// Regex pattern for whitespace characters
const WHITESPACE_REGEX = /\s/g;
const BIGEMOJI_REGEX = new RegExp(`^(${EMOJIBASE_REGEX.source})+$`, 'i');
const BIGEMOJI_REGEX = new RegExp(`^(${EMOJIBASE_REGEX.source})+$`, "i");
const COLOR_REGEX = /^#[0-9a-fA-F]{6}$/;
export const PERMITTED_URL_SCHEMES = [
"bitcoin",
"ftp",
"geo",
"http",
"https",
"im",
"irc",
"ircs",
"magnet",
"mailto",
"matrix",
"mms",
"news",
"nntp",
"openpgp4fpr",
"sip",
"sftp",
"sms",
"smsto",
"ssh",
"tel",
"urn",
"webcal",
"wtai",
"xmpp",
];
const MEDIA_API_MXC_REGEX = /\/_matrix\/media\/r0\/(?:download|thumbnail)\/(.+?)\/(.+?)(?:[?/]|$)/;
/*
@ -95,8 +68,8 @@ const MEDIA_API_MXC_REGEX = /\/_matrix\/media\/r0\/(?:download|thumbnail)\/(.+?)
* positives, but useful for fast-path testing strings to see if they
* need emojification.
*/
function mightContainEmoji(str: string): boolean {
return SURROGATE_PAIR_PATTERN.test(str) || SYMBOL_PATTERN.test(str);
function mightContainEmoji(str?: string): boolean {
return !!str && (SURROGATE_PAIR_PATTERN.test(str) || SYMBOL_PATTERN.test(str));
}
/**
@ -107,7 +80,7 @@ function mightContainEmoji(str: string): boolean {
*/
export function unicodeToShortcode(char: string): string {
const shortcodes = getEmojiFromUnicode(char)?.shortcodes;
return shortcodes?.length ? `:${shortcodes[0]}:` : '';
return shortcodes?.length ? `:${shortcodes[0]}:` : "";
}
/*
@ -126,7 +99,7 @@ export function getHtmlText(insaneHtml: string): string {
allowedAttributes: {},
selfClosing: [],
allowedSchemes: [],
disallowedTagsMode: 'discard',
disallowedTagsMode: "discard",
});
}
@ -147,11 +120,12 @@ export function isUrlPermitted(inputUrl: string): boolean {
}
}
const transformTags: IExtendedSanitizeOptions["transformTags"] = { // custom to matrix
const transformTags: IExtendedSanitizeOptions["transformTags"] = {
// custom to matrix
// add blank targets to all hyperlinks except vector URLs
'a': function(tagName: string, attribs: sanitizeHtml.Attributes) {
"a": function (tagName: string, attribs: sanitizeHtml.Attributes) {
if (attribs.href) {
attribs.target = '_blank'; // by default
attribs.target = "_blank"; // by default
const transformed = tryTransformPermalinkToLocalHref(attribs.href); // only used to check if it is a link that can be handled locally
if (
@ -165,10 +139,10 @@ const transformTags: IExtendedSanitizeOptions["transformTags"] = { // custom to
delete attribs.href;
}
attribs.rel = 'noreferrer noopener'; // https://mathiasbynens.github.io/rel-noopener/
attribs.rel = "noreferrer noopener"; // https://mathiasbynens.github.io/rel-noopener/
return { tagName, attribs };
},
'img': function(tagName: string, attribs: sanitizeHtml.Attributes) {
"img": function (tagName: string, attribs: sanitizeHtml.Attributes) {
let src = attribs.src;
// Strip out imgs that aren't `mxc` here instead of using allowedSchemesByTag
// because transformTags is used _before_ we filter by allowedSchemesByTag and
@ -205,21 +179,21 @@ const transformTags: IExtendedSanitizeOptions["transformTags"] = { // custom to
attribs.style += "height: 100%;";
}
attribs.src = mediaFromMxc(src).getThumbnailOfSourceHttp(width, height);
attribs.src = mediaFromMxc(src).getThumbnailOfSourceHttp(width, height)!;
return { tagName, attribs };
},
'code': function(tagName: string, attribs: sanitizeHtml.Attributes) {
if (typeof attribs.class !== 'undefined') {
"code": function (tagName: string, attribs: sanitizeHtml.Attributes) {
if (typeof attribs.class !== "undefined") {
// Filter out all classes other than ones starting with language- for syntax highlighting.
const classes = attribs.class.split(/\s/).filter(function(cl) {
return cl.startsWith('language-') && !cl.startsWith('language-_');
const classes = attribs.class.split(/\s/).filter(function (cl) {
return cl.startsWith("language-") && !cl.startsWith("language-_");
});
attribs.class = classes.join(' ');
attribs.class = classes.join(" ");
}
return { tagName, attribs };
},
// eslint-disable-next-line @typescript-eslint/naming-convention
'*': function(tagName: string, attribs: sanitizeHtml.Attributes) {
"*": function (tagName: string, attribs: sanitizeHtml.Attributes) {
// Delete any style previously assigned, style is an allowedTag for font, span & img,
// because attributes are stripped after transforming.
// For img this is trusted as it is generated wholly within the img transformation method.
@ -229,9 +203,9 @@ const transformTags: IExtendedSanitizeOptions["transformTags"] = { // custom to
// Sanitise and transform data-mx-color and data-mx-bg-color to their CSS
// equivalents
const customCSSMapper = {
'data-mx-color': 'color',
'data-mx-bg-color': 'background-color',
const customCSSMapper: Record<string, string> = {
"data-mx-color": "color",
"data-mx-bg-color": "background-color",
// $customAttributeKey: $cssAttributeKey
};
@ -239,8 +213,9 @@ const transformTags: IExtendedSanitizeOptions["transformTags"] = { // custom to
Object.keys(customCSSMapper).forEach((customAttributeKey) => {
const cssAttributeKey = customCSSMapper[customAttributeKey];
const customAttributeValue = attribs[customAttributeKey];
if (customAttributeValue &&
typeof customAttributeValue === 'string' &&
if (
customAttributeValue &&
typeof customAttributeValue === "string" &&
COLOR_REGEX.test(customAttributeValue)
) {
style += cssAttributeKey + ":" + customAttributeValue + ";";
@ -258,28 +233,61 @@ const transformTags: IExtendedSanitizeOptions["transformTags"] = { // custom to
const sanitizeHtmlParams: IExtendedSanitizeOptions = {
allowedTags: [
'font', // custom to matrix for IRC-style font coloring
'del', // for markdown
'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'blockquote', 'p', 'a', 'ul', 'ol', 'sup', 'sub',
'nl', 'li', 'b', 'i', 'u', 'strong', 'em', 'strike', 'code', 'hr', 'br', 'div',
'table', 'thead', 'caption', 'tbody', 'tr', 'th', 'td', 'pre', 'span', 'img',
'details', 'summary',
"font", // custom to matrix for IRC-style font coloring
"del", // for markdown
"h1",
"h2",
"h3",
"h4",
"h5",
"h6",
"blockquote",
"p",
"a",
"ul",
"ol",
"sup",
"sub",
"nl",
"li",
"b",
"i",
"u",
"strong",
"em",
"strike",
"code",
"hr",
"br",
"div",
"table",
"thead",
"caption",
"tbody",
"tr",
"th",
"td",
"pre",
"span",
"img",
"details",
"summary",
],
allowedAttributes: {
// attribute sanitization happens after transformations, so we have to accept `style` for font, span & img
// but strip during the transformation.
// custom ones first:
font: ['color', 'data-mx-bg-color', 'data-mx-color', 'style'], // custom to matrix
span: ['data-mx-maths', 'data-mx-bg-color', 'data-mx-color', 'data-mx-spoiler', 'style'], // custom to matrix
div: ['data-mx-maths'],
a: ['href', 'name', 'target', 'rel'], // remote target: custom to matrix
font: ["color", "data-mx-bg-color", "data-mx-color", "style"], // custom to matrix
span: ["data-mx-maths", "data-mx-bg-color", "data-mx-color", "data-mx-spoiler", "style"], // custom to matrix
div: ["data-mx-maths"],
a: ["href", "name", "target", "rel"], // remote target: custom to matrix
// img tags also accept width/height, we just map those to max-width & max-height during transformation
img: ['src', 'alt', 'title', 'style'],
ol: ['start'],
code: ['class'], // We don't actually allow all classes, we filter them in transformTags
img: ["src", "alt", "title", "style"],
ol: ["start"],
code: ["class"], // We don't actually allow all classes, we filter them in transformTags
},
// Lots of these won't come up by default because we don't allow them
selfClosing: ['img', 'br', 'hr', 'area', 'base', 'basefont', 'input', 'link', 'meta'],
selfClosing: ["img", "br", "hr", "area", "base", "basefont", "input", "link", "meta"],
// URL schemes we permit
allowedSchemes: PERMITTED_URL_SCHEMES,
allowProtocolRelative: false,
@ -292,8 +300,8 @@ const sanitizeHtmlParams: IExtendedSanitizeOptions = {
const composerSanitizeHtmlParams: IExtendedSanitizeOptions = {
...sanitizeHtmlParams,
transformTags: {
'code': transformTags['code'],
'*': transformTags['*'],
"code": transformTags["code"],
"*": transformTags["*"],
},
};
@ -301,23 +309,34 @@ const composerSanitizeHtmlParams: IExtendedSanitizeOptions = {
const topicSanitizeHtmlParams: IExtendedSanitizeOptions = {
...sanitizeHtmlParams,
allowedTags: [
'font', // custom to matrix for IRC-style font coloring
'del', // for markdown
'a', 'sup', 'sub',
'b', 'i', 'u', 'strong', 'em', 'strike', 'br', 'div',
'span',
"font", // custom to matrix for IRC-style font coloring
"del", // for markdown
"a",
"sup",
"sub",
"b",
"i",
"u",
"strong",
"em",
"strike",
"br",
"div",
"span",
],
};
abstract class BaseHighlighter<T extends React.ReactNode> {
constructor(public highlightClass: string, public highlightLink: string) {
}
public constructor(
public highlightClass: string,
public highlightLink?: string,
) {}
/**
* apply the highlights to a section of text
* Apply the highlights to a section of text
*
* @param {string} safeSnippet The snippet of text to apply the highlights
* to.
* to. This input must be sanitised as it will be treated as HTML.
* @param {string[]} safeHighlights A list of substrings to highlight,
* sorted by descending length.
*
@ -326,7 +345,7 @@ abstract class BaseHighlighter<T extends React.ReactNode> {
*/
public applyHighlights(safeSnippet: string, safeHighlights: string[]): T[] {
let lastOffset = 0;
let offset;
let offset: number;
let nodes: T[] = [];
const safeHighlight = safeHighlights[0];
@ -399,17 +418,20 @@ interface IOpts {
}
export interface IOptsReturnNode extends IOpts {
returnString: false | undefined;
returnString?: false | undefined;
}
export interface IOptsReturnString extends IOpts {
returnString: true;
}
const emojiToHtmlSpan = (emoji: string) =>
const emojiToHtmlSpan = (emoji: string): string =>
`<span class='mx_Emoji' title='${unicodeToShortcode(emoji)}'>${emoji}</span>`;
const emojiToJsxSpan = (emoji: string, key: number) =>
<span key={key} className='mx_Emoji' title={unicodeToShortcode(emoji)}>{ emoji }</span>;
const emojiToJsxSpan = (emoji: string, key: number): JSX.Element => (
<span key={key} className="mx_Emoji" title={unicodeToShortcode(emoji)}>
{emoji}
</span>
);
/**
* Wraps emojis in <span> to style them separately from the rest of message. Consecutive emojis (and modifiers) are wrapped
@ -419,18 +441,22 @@ const emojiToJsxSpan = (emoji: string, key: number) =>
* @returns if isHtmlMessage is true, returns an array of strings, otherwise return an array of React Elements for emojis
* and plain text for everything else
*/
function formatEmojis(message: string, isHtmlMessage: boolean): (JSX.Element | string)[] {
export function formatEmojis(message: string | undefined, isHtmlMessage?: false): JSX.Element[];
export function formatEmojis(message: string | undefined, isHtmlMessage: true): string[];
export function formatEmojis(message: string | undefined, isHtmlMessage?: boolean): (JSX.Element | string)[] {
const emojiToSpan = isHtmlMessage ? emojiToHtmlSpan : emojiToJsxSpan;
const result: (JSX.Element | string)[] = [];
let text = '';
if (!message) return result;
let text = "";
let key = 0;
// We use lodash's grapheme splitter to avoid breaking apart compound emojis
for (const char of split(message, '')) {
const splitter = new GraphemeSplitter();
for (const char of splitter.iterateGraphemes(message)) {
if (EMOJIBASE_REGEX.test(char)) {
if (text) {
result.push(text);
text = '';
text = "";
}
result.push(emojiToSpan(char, key));
key++;
@ -459,8 +485,8 @@ function formatEmojis(message: string, isHtmlMessage: boolean): (JSX.Element | s
*/
export function bodyToHtml(content: IContent, highlights: Optional<string[]>, opts: IOptsReturnString): string;
export function bodyToHtml(content: IContent, highlights: Optional<string[]>, opts: IOptsReturnNode): ReactNode;
export function bodyToHtml(content: IContent, highlights: Optional<string[]>, opts: IOpts = {}) {
const isFormattedBody = content.format === "org.matrix.custom.html" && content.formatted_body;
export function bodyToHtml(content: IContent, highlights: Optional<string[]>, opts: IOpts = {}): ReactNode | string {
const isFormattedBody = content.format === "org.matrix.custom.html" && typeof content.formatted_body === "string";
let bodyHasEmoji = false;
let isHtmlMessage = false;
@ -470,7 +496,7 @@ export function bodyToHtml(content: IContent, highlights: Optional<string[]>, op
}
let strippedBody: string;
let safeBody: string; // safe, sanitised HTML, preferred over `strippedBody` which is fully plaintext
let safeBody: string | undefined; // safe, sanitised HTML, preferred over `strippedBody` which is fully plaintext
try {
// sanitizeHtml can hang if an unclosed HTML tag is thrown at it
@ -480,12 +506,12 @@ export function bodyToHtml(content: IContent, highlights: Optional<string[]>, op
?.filter((highlight: string): boolean => !highlight.includes("<"))
.map((highlight: string): string => sanitizeHtml(highlight, sanitizeParams));
let formattedBody = typeof content.formatted_body === 'string' ? content.formatted_body : null;
const plainBody = typeof content.body === 'string' ? content.body : "";
let formattedBody = typeof content.formatted_body === "string" ? content.formatted_body : null;
const plainBody = typeof content.body === "string" ? content.body : "";
if (opts.stripReplyFallback && formattedBody) formattedBody = stripHTMLReply(formattedBody);
strippedBody = opts.stripReplyFallback ? stripPlainReply(plainBody) : plainBody;
bodyHasEmoji = mightContainEmoji(isFormattedBody ? formattedBody : plainBody);
bodyHasEmoji = mightContainEmoji(isFormattedBody ? formattedBody! : plainBody);
const highlighter = safeHighlights?.length
? new HtmlHighlighter("mx_EventTile_searchHighlight", opts.highlightLink)
@ -498,98 +524,88 @@ export function bodyToHtml(content: IContent, highlights: Optional<string[]>, op
// are interrupted by HTML tags (not that we did before) - e.g. foo<span/>bar won't get highlighted
// by an attempt to search for 'foobar'. Then again, the search query probably wouldn't work either
// XXX: hacky bodge to temporarily apply a textFilter to the sanitizeParams structure.
sanitizeParams.textFilter = function(safeText) {
return highlighter.applyHighlights(safeText, safeHighlights).join('');
sanitizeParams.textFilter = function (safeText) {
return highlighter.applyHighlights(safeText, safeHighlights!).join("");
};
}
safeBody = sanitizeHtml(formattedBody, sanitizeParams);
const phtml = cheerio.load(safeBody, {
// @ts-ignore: The `_useHtmlParser2` internal option is the
// simplest way to both parse and render using `htmlparser2`.
_useHtmlParser2: true,
decodeEntities: false,
});
const isPlainText = phtml.html() === phtml.root().text();
isHtmlMessage = isFormattedBody && !isPlainText;
safeBody = sanitizeHtml(formattedBody!, sanitizeParams);
const phtml = new DOMParser().parseFromString(safeBody, "text/html");
const isPlainText = phtml.body.innerHTML === phtml.body.textContent;
isHtmlMessage = !isPlainText;
if (isHtmlMessage && SettingsStore.getValue("feature_latex_maths")) {
// @ts-ignore - The types for `replaceWith` wrongly expect
// Cheerio instance to be returned.
phtml('div, span[data-mx-maths!=""]').replaceWith(function(i, e) {
return katex.renderToString(
AllHtmlEntities.decode(phtml(e).attr('data-mx-maths')),
{
throwOnError: false,
// @ts-ignore - `e` can be an Element, not just a Node
displayMode: e.name == 'div',
output: "htmlAndMathml",
});
[...phtml.querySelectorAll<HTMLElement>("div, span[data-mx-maths]")].forEach((e) => {
e.outerHTML = katex.renderToString(decode(e.getAttribute("data-mx-maths")), {
throwOnError: false,
displayMode: e.tagName == "DIV",
output: "htmlAndMathml",
});
});
safeBody = phtml.html();
}
if (bodyHasEmoji) {
safeBody = formatEmojis(safeBody, true).join('');
safeBody = phtml.body.innerHTML;
}
} else if (highlighter) {
safeBody = highlighter.applyHighlights(plainBody, safeHighlights).join('');
safeBody = highlighter.applyHighlights(escapeHtml(plainBody), safeHighlights!).join("");
}
} finally {
delete sanitizeParams.textFilter;
}
const contentBody = safeBody ?? strippedBody;
if (opts.returnString) {
return contentBody;
}
let emojiBody = false;
if (!opts.disableBigEmoji && bodyHasEmoji) {
let contentBodyTrimmed = contentBody !== undefined ? contentBody.trim() : '';
const contentBody = safeBody ?? strippedBody;
let contentBodyTrimmed = contentBody !== undefined ? contentBody.trim() : "";
// Ignore spaces in body text. Emojis with spaces in between should
// still be counted as purely emoji messages.
contentBodyTrimmed = contentBodyTrimmed.replace(WHITESPACE_REGEX, '');
// Remove zero width joiner characters from emoji messages. This ensures
// that emojis that are made up of multiple unicode characters are still
// presented as large.
contentBodyTrimmed = contentBodyTrimmed.replace(ZWJ_REGEX, '');
// Remove zero width joiner, zero width spaces and other spaces in body
// text. This ensures that emojis with spaces in between or that are made
// up of multiple unicode characters are still counted as purely emoji
// messages.
contentBodyTrimmed = contentBodyTrimmed.replace(EMOJI_SEPARATOR_REGEX, "");
const match = BIGEMOJI_REGEX.exec(contentBodyTrimmed);
emojiBody = match && match[0] && match[0].length === contentBodyTrimmed.length &&
// Prevent user pills expanding for users with only emoji in
// their username. Permalinks (links in pills) can be any URL
// now, so we just check for an HTTP-looking thing.
(
strippedBody === safeBody || // replies have the html fallbacks, account for that here
content.formatted_body === undefined ||
(!content.formatted_body.includes("http:") &&
!content.formatted_body.includes("https:"))
);
emojiBody =
match?.[0]?.length === contentBodyTrimmed.length &&
// Prevent user pills expanding for users with only emoji in
// their username. Permalinks (links in pills) can be any URL
// now, so we just check for an HTTP-looking thing.
(strippedBody === safeBody || // replies have the html fallbacks, account for that here
content.formatted_body === undefined ||
(!content.formatted_body.includes("http:") && !content.formatted_body.includes("https:")));
}
if (isFormattedBody && bodyHasEmoji && safeBody) {
// This has to be done after the emojiBody check above as to not break big emoji on replies
safeBody = formatEmojis(safeBody, true).join("");
}
if (opts.returnString) {
return safeBody ?? strippedBody;
}
const className = classNames({
'mx_EventTile_body': true,
'mx_EventTile_bigEmoji': emojiBody,
'markdown-body': isHtmlMessage && !emojiBody,
"mx_EventTile_body": true,
"mx_EventTile_bigEmoji": emojiBody,
"markdown-body": isHtmlMessage && !emojiBody,
});
let emojiBodyElements: JSX.Element[];
let emojiBodyElements: JSX.Element[] | undefined;
if (!safeBody && bodyHasEmoji) {
emojiBodyElements = formatEmojis(strippedBody, false) as JSX.Element[];
}
return safeBody ?
return safeBody ? (
<span
key="body"
ref={opts.ref}
className={className}
dangerouslySetInnerHTML={{ __html: safeBody }}
dir="auto"
/> : <span key="body" ref={opts.ref} className={className} dir="auto">
{ emojiBodyElements || strippedBody }
</span>;
/>
) : (
<span key="body" ref={opts.ref} className={className} dir="auto">
{emojiBodyElements || strippedBody}
</span>
);
}
/**
@ -601,13 +617,13 @@ export function bodyToHtml(content: IContent, highlights: Optional<string[]>, op
* @return The HTML-ified node.
*/
export function topicToHtml(
topic: string,
topic?: string,
htmlTopic?: string,
ref?: React.Ref<HTMLSpanElement>,
ref?: LegacyRef<HTMLSpanElement>,
allowExtendedHtml = false,
): ReactNode {
if (!SettingsStore.getValue("feature_html_topic")) {
htmlTopic = null;
htmlTopic = undefined;
}
let isFormattedTopic = !!htmlTopic;
@ -615,32 +631,39 @@ export function topicToHtml(
let safeTopic = "";
try {
topicHasEmoji = mightContainEmoji(isFormattedTopic ? htmlTopic : topic);
topicHasEmoji = mightContainEmoji(isFormattedTopic ? htmlTopic! : topic);
if (isFormattedTopic) {
safeTopic = sanitizeHtml(htmlTopic, allowExtendedHtml ? sanitizeHtmlParams : topicSanitizeHtmlParams);
safeTopic = sanitizeHtml(htmlTopic!, allowExtendedHtml ? sanitizeHtmlParams : topicSanitizeHtmlParams);
if (topicHasEmoji) {
safeTopic = formatEmojis(safeTopic, true).join('');
safeTopic = formatEmojis(safeTopic, true).join("");
}
}
} catch {
isFormattedTopic = false; // Fall back to plain-text topic
}
let emojiBodyElements: ReturnType<typeof formatEmojis>;
let emojiBodyElements: JSX.Element[] | undefined;
if (!isFormattedTopic && topicHasEmoji) {
emojiBodyElements = formatEmojis(topic, false);
}
return isFormattedTopic
? <span
ref={ref}
dangerouslySetInnerHTML={{ __html: safeTopic }}
dir="auto"
/>
: <span ref={ref} dir="auto">
{ emojiBodyElements || topic }
</span>;
return isFormattedTopic ? (
<span ref={ref} dangerouslySetInnerHTML={{ __html: safeTopic }} dir="auto" />
) : (
<span ref={ref} dir="auto">
{emojiBodyElements || topic}
</span>
);
}
/* Wrapper around linkify-react merging in our default linkify options */
export function Linkify({ as, options, children }: React.ComponentProps<typeof _Linkify>): ReactElement {
return (
<_Linkify as={as} options={merge({}, linkifyMatrixOptions, options)}>
{children}
</_Linkify>
);
}
/**

View file

@ -57,7 +57,7 @@ export interface IConfigOptions {
branding?: {
welcome_background_url?: string | string[]; // chosen at random if array
auth_header_logo_url?: string;
auth_footer_links?: {text: string, url: string}[];
auth_footer_links?: { text: string; url: string }[];
};
map_style_url?: string; // for location-shared maps
@ -71,15 +71,15 @@ export interface IConfigOptions {
permalink_prefix?: string;
update_base_url?: string;
desktop_builds?: {
desktop_builds: {
available: boolean;
logo: string; // url
url: string; // download url
};
mobile_builds?: {
ios?: string; // download url
android?: string; // download url
fdroid?: string; // download url
mobile_builds: {
ios: string | null; // download url
android: string | null; // download url
fdroid: string | null; // download url
};
mobile_guide_toast?: boolean;
@ -95,17 +95,18 @@ export interface IConfigOptions {
integrations_rest_url?: string;
integrations_widgets_urls?: string[];
show_labs_settings?: boolean;
show_labs_settings: boolean;
features?: Record<string, boolean>; // <FeatureName, EnabledBool>
bug_report_endpoint_url?: string; // omission disables bug reporting
uisi_autorageshake_app?: string;
uisi_autorageshake_app?: string; // defaults to "element-auto-uisi"
sentry?: {
dsn: string;
environment?: string; // "production", etc
};
widget_build_url?: string; // url called to replace jitsi/call widget creation
widget_build_url_ignore_dm?: boolean;
audio_stream_url?: string;
jitsi?: {
preferred_domain: string;
@ -118,6 +119,7 @@ export interface IConfigOptions {
};
element_call: {
url?: string;
guest_spa_url?: string;
use_exclusively?: boolean;
participant_limit?: number;
brand?: string;
@ -135,8 +137,6 @@ export interface IConfigOptions {
admin_message_md: string; // message for how to contact the server owner when reporting an event
};
welcome_user_id?: string;
room_directory?: {
servers: string[];
};
@ -148,31 +148,28 @@ export interface IConfigOptions {
analytics_owner?: string; // defaults to `brand`
privacy_policy_url?: string; // location for cookie policy
// Server hosting upsell options
hosting_signup_link?: string; // slightly different from `host_signup`
host_signup?: {
brand?: string; // acts as the enabled flag too (truthy == show)
// Required-ness denotes when `brand` is truthy
cookie_policy_url: string;
privacy_policy_url: string;
terms_of_service_url: string;
url: string;
domains?: string[];
};
enable_presence_by_hs_url?: Record<string, boolean>; // <HomeserverName, Enabled>
terms_and_conditions_links?: { url: string, text: string }[];
terms_and_conditions_links?: { url: string; text: string }[];
help_url: string;
help_encryption_url: string;
latex_maths_delims?: {
inline?: {
left?: string;
right?: string;
pattern?: {
tex?: string;
latex?: string;
};
};
display?: {
left?: string;
right?: string;
pattern?: {
tex?: string;
latex?: string;
};
};
};
@ -182,7 +179,33 @@ export interface IConfigOptions {
voice_broadcast?: {
// length per voice chunk in seconds
chunk_length?: number;
// max voice broadcast length in seconds
max_length?: number;
};
user_notice?: {
title: string;
description: string;
show_once?: boolean;
};
feedback: {
existing_issues_url: string;
new_issue_url: string;
};
/**
* Configuration for OIDC issuers where a static client_id has been issued for the app.
* Otherwise dynamic client registration is attempted.
* The issuer URL must have a trailing `/`.
* OPTIONAL
*/
oidc_static_clients?: Record<
string,
{
client_id: string;
}
>;
}
export interface ISsoRedirectOptions {

View file

@ -15,27 +15,26 @@ limitations under the License.
*/
import React from "react";
import { SERVICE_TYPES } from 'matrix-js-sdk/src/service-types';
import { createClient, MatrixClient } from 'matrix-js-sdk/src/matrix';
import { SERVICE_TYPES, createClient, MatrixClient, MatrixError } from "matrix-js-sdk/src/matrix";
import { logger } from "matrix-js-sdk/src/logger";
import { MatrixClientPeg } from './MatrixClientPeg';
import Modal from './Modal';
import { _t } from './languageHandler';
import { Service, startTermsFlow, TermsNotSignedError } from './Terms';
import { MatrixClientPeg } from "./MatrixClientPeg";
import Modal from "./Modal";
import { _t } from "./languageHandler";
import { Service, startTermsFlow, TermsNotSignedError } from "./Terms";
import {
doesAccountDataHaveIdentityServer,
doesIdentityServerHaveTerms,
setToDefaultIdentityServer,
} from './utils/IdentityServerUtils';
} from "./utils/IdentityServerUtils";
import QuestionDialog from "./components/views/dialogs/QuestionDialog";
import { abbreviateUrl } from "./utils/UrlUtils";
export class AbortedIdentityActionError extends Error {}
export default class IdentityAuthClient {
private accessToken: string;
private tempClient: MatrixClient;
private accessToken: string | null = null;
private tempClient?: MatrixClient;
private authEnabled = true;
/**
@ -44,7 +43,7 @@ export default class IdentityAuthClient {
* When provided, this class will operate solely within memory, refusing to
* persist any information such as tokens. Default null (not provided).
*/
constructor(identityUrl?: string) {
public constructor(identityUrl?: string) {
if (identityUrl) {
// XXX: We shouldn't have to create a whole new MatrixClient just to
// do identity server auth. The functions don't take an identity URL
@ -58,32 +57,37 @@ export default class IdentityAuthClient {
}
}
// This client must not be used for general operations as it may not have a baseUrl or be running (tempClient).
private get identityClient(): MatrixClient {
return this.tempClient ?? this.matrixClient;
}
private get matrixClient(): MatrixClient {
return this.tempClient ? this.tempClient : MatrixClientPeg.get();
return MatrixClientPeg.safeGet();
}
private writeToken(): void {
if (this.tempClient) return; // temporary client: ignore
window.localStorage.setItem("mx_is_access_token", this.accessToken);
if (this.accessToken) {
window.localStorage.setItem("mx_is_access_token", this.accessToken);
} else {
window.localStorage.removeItem("mx_is_access_token");
}
}
private readToken(): string {
private readToken(): string | null {
if (this.tempClient) return null; // temporary client: ignore
return window.localStorage.getItem("mx_is_access_token");
}
public hasCredentials(): boolean {
return Boolean(this.accessToken);
}
// Returns a promise that resolves to the access_token string from the IS
public async getAccessToken({ check = true } = {}): Promise<string> {
public async getAccessToken({ check = true } = {}): Promise<string | null> {
if (!this.authEnabled) {
// The current IS doesn't support authentication
return null;
}
let token = this.accessToken;
let token: string | null = this.accessToken;
if (!token) {
token = this.readToken();
}
@ -101,10 +105,7 @@ export default class IdentityAuthClient {
try {
await this.checkToken(token);
} catch (e) {
if (
e instanceof TermsNotSignedError ||
e instanceof AbortedIdentityActionError
) {
if (e instanceof TermsNotSignedError || e instanceof AbortedIdentityActionError) {
// Retrying won't help this
throw e;
}
@ -121,18 +122,14 @@ export default class IdentityAuthClient {
}
private async checkToken(token: string): Promise<void> {
const identityServerUrl = this.matrixClient.getIdentityServerUrl();
const identityServerUrl = this.identityClient.getIdentityServerUrl()!;
try {
await this.matrixClient.getIdentityAccount(token);
await this.identityClient.getIdentityAccount(token);
} catch (e) {
if (e.errcode === "M_TERMS_NOT_SIGNED") {
if (e instanceof MatrixError && e.errcode === "M_TERMS_NOT_SIGNED") {
logger.log("Identity server requires new terms to be agreed to");
await startTermsFlow([new Service(
SERVICE_TYPES.IS,
identityServerUrl,
token,
)]);
await startTermsFlow(this.matrixClient, [new Service(SERVICE_TYPES.IS, identityServerUrl, token)]);
return;
}
throw e;
@ -140,35 +137,32 @@ export default class IdentityAuthClient {
if (
!this.tempClient &&
!doesAccountDataHaveIdentityServer() &&
!(await doesIdentityServerHaveTerms(identityServerUrl))
!doesAccountDataHaveIdentityServer(this.matrixClient) &&
!(await doesIdentityServerHaveTerms(this.matrixClient, identityServerUrl))
) {
const { finished } = Modal.createDialog(QuestionDialog, {
title: _t("Identity server has no terms of service"),
title: _t("terms|identity_server_no_terms_title"),
description: (
<div>
<p>{ _t(
"This action requires accessing the default identity server " +
"<server /> to validate an email address or phone number, " +
"but the server does not have any terms of service.", {},
{
server: () => <b>{ abbreviateUrl(identityServerUrl) }</b>,
},
) }</p>
<p>{ _t(
"Only continue if you trust the owner of the server.",
) }</p>
<p>
{_t(
"terms|identity_server_no_terms_description_1",
{},
{
server: () => <b>{abbreviateUrl(identityServerUrl)}</b>,
},
)}
</p>
<p>{_t("terms|identity_server_no_terms_description_2")}</p>
</div>
),
button: _t("Trust"),
button: _t("action|trust"),
});
const [confirmed] = await finished;
if (confirmed) {
setToDefaultIdentityServer();
setToDefaultIdentityServer(this.matrixClient);
} else {
throw new AbortedIdentityActionError(
"User aborted identity server action without terms",
);
throw new AbortedIdentityActionError("User aborted identity server action without terms");
}
}
@ -180,10 +174,10 @@ export default class IdentityAuthClient {
}
public async registerForToken(check = true): Promise<string> {
const hsOpenIdToken = await MatrixClientPeg.get().getOpenIdToken();
const hsOpenIdToken = await MatrixClientPeg.safeGet().getOpenIdToken();
// XXX: The spec is `token`, but we used `access_token` for a Sydent release.
const { access_token: accessToken, token } =
await this.matrixClient.registerWithIdentityServer(hsOpenIdToken);
await this.identityClient.registerWithIdentityServer(hsOpenIdToken);
const identityAccessToken = token ? token : accessToken;
if (check) await this.checkToken(identityAccessToken);
return identityAccessToken;

View file

@ -28,7 +28,19 @@ limitations under the License.
* consume in the timeline, when performing scroll offset calculations
* (e.g. scroll locking)
*/
export function thumbHeight(fullWidth: number, fullHeight: number, thumbWidth: number, thumbHeight: number) {
export function thumbHeight(fullWidth: number, fullHeight: number, thumbWidth: number, thumbHeight: number): number;
export function thumbHeight(
fullWidth: number | undefined,
fullHeight: number | undefined,
thumbWidth: number,
thumbHeight: number,
): null;
export function thumbHeight(
fullWidth: number | undefined,
fullHeight: number | undefined,
thumbWidth: number,
thumbHeight: number,
): number | null {
if (!fullWidth || !fullHeight) {
// Cannot calculate thumbnail height for image: missing w/h in metadata. We can't even
// log this because it's spammy
@ -48,4 +60,3 @@ export function thumbHeight(fullWidth: number, fullHeight: number, thumbWidth: n
return Math.floor(heightMulti * fullHeight);
}
}

View file

@ -19,15 +19,11 @@ import { IS_MAC, Key } from "./Keyboard";
import SettingsStore from "./settings/SettingsStore";
import SdkConfig from "./SdkConfig";
import { IKeyBindingsProvider, KeyBinding } from "./KeyBindingsManager";
import {
CATEGORIES,
CategoryName,
KeyBindingAction,
} from "./accessibility/KeyboardShortcuts";
import { CATEGORIES, CategoryName, KeyBindingAction } from "./accessibility/KeyboardShortcuts";
import { getKeyboardShortcuts } from "./accessibility/KeyboardShortcutUtils";
export const getBindingsByCategory = (category: CategoryName): KeyBinding[] => {
return CATEGORIES[category].settingNames.reduce((bindings, action) => {
return CATEGORIES[category].settingNames.reduce<KeyBinding[]>((bindings, action) => {
const keyCombo = getKeyboardShortcuts()[action]?.default;
if (keyCombo) {
bindings.push({ action, keyCombo });
@ -39,7 +35,7 @@ export const getBindingsByCategory = (category: CategoryName): KeyBinding[] => {
const messageComposerBindings = (): KeyBinding[] => {
const bindings = getBindingsByCategory(CategoryName.COMPOSER);
if (SettingsStore.getValue('MessageComposerInput.ctrlEnterToSend')) {
if (SettingsStore.getValue("MessageComposerInput.ctrlEnterToSend")) {
bindings.push({
action: KeyBindingAction.SendMessage,
keyCombo: {
@ -128,7 +124,7 @@ const roomListBindings = (): KeyBinding[] => {
const roomBindings = (): KeyBinding[] => {
const bindings = getBindingsByCategory(CategoryName.ROOM);
if (SettingsStore.getValue('ctrlFForSearch')) {
if (SettingsStore.getValue("ctrlFForSearch")) {
bindings.push({
action: KeyBindingAction.SearchInRoom,
keyCombo: {

View file

@ -16,8 +16,8 @@ limitations under the License.
*/
import { KeyBindingAction } from "./accessibility/KeyboardShortcuts";
import { defaultBindingsProvider } from './KeyBindingsDefaults';
import { IS_MAC } from './Keyboard';
import { defaultBindingsProvider } from "./KeyBindingsDefaults";
import { IS_MAC } from "./Keyboard";
/**
* Represent a key combination.
@ -25,7 +25,7 @@ import { IS_MAC } from './Keyboard';
* The combo is evaluated strictly, i.e. the KeyboardEvent must match exactly what is specified in the KeyCombo.
*/
export type KeyCombo = {
key?: string;
key: string;
/** On PC: ctrl is pressed; on Mac: meta is pressed */
ctrlOrCmdKey?: boolean;
@ -72,27 +72,18 @@ export function isKeyComboMatch(ev: KeyboardEvent | React.KeyboardEvent, combo:
// When ctrlOrCmd is set, the keys need do evaluated differently on PC and Mac
if (combo.ctrlOrCmdKey) {
if (onMac) {
if (!evMeta
|| evCtrl !== comboCtrl
|| evAlt !== comboAlt
|| evShift !== comboShift) {
if (!evMeta || evCtrl !== comboCtrl || evAlt !== comboAlt || evShift !== comboShift) {
return false;
}
} else {
if (!evCtrl
|| evMeta !== comboMeta
|| evAlt !== comboAlt
|| evShift !== comboShift) {
if (!evCtrl || evMeta !== comboMeta || evAlt !== comboAlt || evShift !== comboShift) {
return false;
}
}
return true;
}
if (evMeta !== comboMeta
|| evCtrl !== comboCtrl
|| evAlt !== comboAlt
|| evShift !== comboShift) {
if (evMeta !== comboMeta || evCtrl !== comboCtrl || evAlt !== comboAlt || evShift !== comboShift) {
return false;
}
@ -114,9 +105,7 @@ export class KeyBindingsManager {
* To overwrite the default key bindings add a new providers before the default provider, e.g. a provider for
* customized key bindings.
*/
bindingsProviders: IKeyBindingsProvider[] = [
defaultBindingsProvider,
];
public bindingsProviders: IKeyBindingsProvider[] = [defaultBindingsProvider];
/**
* Finds a matching KeyAction for a given KeyboardEvent
@ -127,7 +116,7 @@ export class KeyBindingsManager {
): KeyBindingAction | undefined {
for (const getter of getters) {
const bindings = getter();
const binding = bindings.find(it => isKeyComboMatch(ev, it.keyCombo, IS_MAC));
const binding = bindings.find((it) => isKeyComboMatch(ev, it.keyCombo, IS_MAC));
if (binding) {
return binding.action;
}
@ -135,36 +124,60 @@ export class KeyBindingsManager {
return undefined;
}
getMessageComposerAction(ev: KeyboardEvent | React.KeyboardEvent): KeyBindingAction | undefined {
return this.getAction(this.bindingsProviders.map(it => it.getMessageComposerBindings), ev);
public getMessageComposerAction(ev: KeyboardEvent | React.KeyboardEvent): KeyBindingAction | undefined {
return this.getAction(
this.bindingsProviders.map((it) => it.getMessageComposerBindings),
ev,
);
}
getAutocompleteAction(ev: KeyboardEvent | React.KeyboardEvent): KeyBindingAction | undefined {
return this.getAction(this.bindingsProviders.map(it => it.getAutocompleteBindings), ev);
public getAutocompleteAction(ev: KeyboardEvent | React.KeyboardEvent): KeyBindingAction | undefined {
return this.getAction(
this.bindingsProviders.map((it) => it.getAutocompleteBindings),
ev,
);
}
getRoomListAction(ev: KeyboardEvent | React.KeyboardEvent): KeyBindingAction | undefined {
return this.getAction(this.bindingsProviders.map(it => it.getRoomListBindings), ev);
public getRoomListAction(ev: KeyboardEvent | React.KeyboardEvent): KeyBindingAction | undefined {
return this.getAction(
this.bindingsProviders.map((it) => it.getRoomListBindings),
ev,
);
}
getRoomAction(ev: KeyboardEvent | React.KeyboardEvent): KeyBindingAction | undefined {
return this.getAction(this.bindingsProviders.map(it => it.getRoomBindings), ev);
public getRoomAction(ev: KeyboardEvent | React.KeyboardEvent): KeyBindingAction | undefined {
return this.getAction(
this.bindingsProviders.map((it) => it.getRoomBindings),
ev,
);
}
getNavigationAction(ev: KeyboardEvent | React.KeyboardEvent): KeyBindingAction | undefined {
return this.getAction(this.bindingsProviders.map(it => it.getNavigationBindings), ev);
public getNavigationAction(ev: KeyboardEvent | React.KeyboardEvent): KeyBindingAction | undefined {
return this.getAction(
this.bindingsProviders.map((it) => it.getNavigationBindings),
ev,
);
}
getAccessibilityAction(ev: KeyboardEvent | React.KeyboardEvent): KeyBindingAction | undefined {
return this.getAction(this.bindingsProviders.map(it => it.getAccessibilityBindings), ev);
public getAccessibilityAction(ev: KeyboardEvent | React.KeyboardEvent): KeyBindingAction | undefined {
return this.getAction(
this.bindingsProviders.map((it) => it.getAccessibilityBindings),
ev,
);
}
getCallAction(ev: KeyboardEvent | React.KeyboardEvent): KeyBindingAction | undefined {
return this.getAction(this.bindingsProviders.map(it => it.getCallBindings), ev);
public getCallAction(ev: KeyboardEvent | React.KeyboardEvent): KeyBindingAction | undefined {
return this.getAction(
this.bindingsProviders.map((it) => it.getCallBindings),
ev,
);
}
getLabsAction(ev: KeyboardEvent | React.KeyboardEvent): KeyBindingAction | undefined {
return this.getAction(this.bindingsProviders.map(it => it.getLabsBindings), ev);
public getLabsAction(ev: KeyboardEvent | React.KeyboardEvent): KeyBindingAction | undefined {
return this.getAction(
this.bindingsProviders.map((it) => it.getLabsBindings),
ev,
);
}
}

View file

@ -16,6 +16,8 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import React from "react";
export const Key = {
HOME: "Home",
END: "End",
@ -74,9 +76,9 @@ export const Key = {
Z: "z",
};
export const IS_MAC = navigator.platform.toUpperCase().includes('MAC');
export const IS_MAC = navigator.platform.toUpperCase().includes("MAC");
export function isOnlyCtrlOrCmdKeyEvent(ev) {
export function isOnlyCtrlOrCmdKeyEvent(ev: React.KeyboardEvent | KeyboardEvent): boolean {
if (IS_MAC) {
return ev.metaKey && !ev.altKey && !ev.ctrlKey && !ev.shiftKey;
} else {

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

View file

@ -15,25 +15,25 @@ limitations under the License.
*/
import { ClientWidgetApi } from "matrix-widget-api";
import { MatrixClient } from "matrix-js-sdk/src/matrix";
import { MatrixClientPeg } from "./MatrixClientPeg";
import SdkConfig from "./SdkConfig";
import { ElementWidgetActions } from "./stores/widgets/ElementWidgetActions";
export function getConfigLivestreamUrl() {
export function getConfigLivestreamUrl(): string | undefined {
return SdkConfig.get("audio_stream_url");
}
// Dummy rtmp URL used to signal that we want a special audio-only stream
const AUDIOSTREAM_DUMMY_URL = 'rtmp://audiostream.dummy/';
const AUDIOSTREAM_DUMMY_URL = "rtmp://audiostream.dummy/";
async function createLiveStream(roomId: string) {
const openIdToken = await MatrixClientPeg.get().getOpenIdToken();
async function createLiveStream(matrixClient: MatrixClient, roomId: string): Promise<void> {
const openIdToken = await matrixClient.getOpenIdToken();
const url = getConfigLivestreamUrl() + "/createStream";
const response = await window.fetch(url, {
method: 'POST',
method: "POST",
headers: {
"Content-Type": "application/json",
},
@ -44,11 +44,15 @@ async function createLiveStream(roomId: string) {
});
const respBody = await response.json();
return respBody['stream_id'];
return respBody["stream_id"];
}
export async function startJitsiAudioLivestream(widgetMessaging: ClientWidgetApi, roomId: string) {
const streamId = await createLiveStream(roomId);
export async function startJitsiAudioLivestream(
matrixClient: MatrixClient,
widgetMessaging: ClientWidgetApi,
roomId: string,
): Promise<void> {
const streamId = await createLiveStream(matrixClient, roomId);
await widgetMessaging.transport.send(ElementWidgetActions.StartLiveStream, {
rtmpStreamKey: AUDIOSTREAM_DUMMY_URL + streamId,

View file

@ -15,40 +15,56 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
// @ts-ignore - XXX: tsc doesn't like this: our js-sdk imports are complex so this isn't surprising
import { createClient } from "matrix-js-sdk/src/matrix";
import { MatrixClient } from "matrix-js-sdk/src/client";
import {
createClient,
MatrixClient,
LoginFlow,
DELEGATED_OIDC_COMPATIBILITY,
ILoginFlow,
LoginRequest,
OidcClientConfig,
} from "matrix-js-sdk/src/matrix";
import { logger } from "matrix-js-sdk/src/logger";
import { ILoginParams, LoginFlow } from "matrix-js-sdk/src/@types/auth";
import { IMatrixClientCreds } from "./MatrixClientPeg";
import SecurityCustomisations from "./customisations/Security";
import { getOidcClientId } from "./utils/oidc/registerClient";
import { IConfigOptions } from "./IConfigOptions";
import SdkConfig from "./SdkConfig";
import { isUserRegistrationSupported } from "./utils/oidc/isUserRegistrationSupported";
/**
* Login flows supported by this client
* LoginFlow type use the client API /login endpoint
* OidcNativeFlow is specific to this client
*/
export type ClientLoginFlow = LoginFlow | OidcNativeFlow;
interface ILoginOptions {
defaultDeviceDisplayName?: string;
/**
* Delegated auth config from server's .well-known.
*
* If this property is set, we will attempt an OIDC login using the delegated auth settings.
* The caller is responsible for checking that OIDC is enabled in the labs settings.
*/
delegatedAuthentication?: OidcClientConfig;
}
export default class Login {
private hsUrl: string;
private isUrl: string;
private fallbackHsUrl: string;
// TODO: Flows need a type in JS SDK
private flows: Array<LoginFlow>;
private defaultDeviceDisplayName: string;
private tempClient: MatrixClient;
private flows: Array<ClientLoginFlow> = [];
private readonly defaultDeviceDisplayName?: string;
private delegatedAuthentication?: OidcClientConfig;
private tempClient: MatrixClient | null = null; // memoize
constructor(
hsUrl: string,
isUrl: string,
fallbackHsUrl?: string,
opts?: ILoginOptions,
public constructor(
private hsUrl: string,
private isUrl: string,
private fallbackHsUrl: string | null,
opts: ILoginOptions,
) {
this.hsUrl = hsUrl;
this.isUrl = isUrl;
this.fallbackHsUrl = fallbackHsUrl;
this.flows = [];
this.defaultDeviceDisplayName = opts.defaultDeviceDisplayName;
this.tempClient = null; // memoize
this.delegatedAuthentication = opts.delegatedAuthentication;
}
public getHomeserverUrl(): string {
@ -69,38 +85,74 @@ export default class Login {
this.isUrl = isUrl;
}
/**
* Set delegated authentication config, clears tempClient.
* @param delegatedAuthentication delegated auth config, from ValidatedServerConfig
*/
public setDelegatedAuthentication(delegatedAuthentication?: OidcClientConfig): void {
this.tempClient = null; // clear memoization
this.delegatedAuthentication = delegatedAuthentication;
}
/**
* Get a temporary MatrixClient, which can be used for login or register
* requests.
* @returns {MatrixClient}
*/
public createTemporaryClient(): MatrixClient {
if (this.tempClient) return this.tempClient; // use memoization
return this.tempClient = createClient({
baseUrl: this.hsUrl,
idBaseUrl: this.isUrl,
});
if (!this.tempClient) {
this.tempClient = createClient({
baseUrl: this.hsUrl,
idBaseUrl: this.isUrl,
});
}
return this.tempClient;
}
public async getFlows(): Promise<Array<LoginFlow>> {
/**
* Get supported login flows
* @param isRegistration OPTIONAL used to verify registration is supported in delegated authentication config
* @returns Promise that resolves to supported login flows
*/
public async getFlows(isRegistration?: boolean): Promise<Array<ClientLoginFlow>> {
// try to use oidc native flow if we have delegated auth config
if (this.delegatedAuthentication) {
try {
const oidcFlow = await tryInitOidcNativeFlow(
this.delegatedAuthentication,
SdkConfig.get().oidc_static_clients,
isRegistration,
);
return [oidcFlow];
} catch (error) {
logger.error(error);
}
}
// oidc native flow not supported, continue with matrix login
const client = this.createTemporaryClient();
const { flows } = await client.loginFlows();
this.flows = flows;
const { flows }: { flows: LoginFlow[] } = await client.loginFlows();
// If an m.login.sso flow is present which is also flagged as being for MSC3824 OIDC compatibility then we only
// return that flow as (per MSC3824) it is the only one that the user should be offered to give the best experience
const oidcCompatibilityFlow = flows.find(
(f) => f.type === "m.login.sso" && DELEGATED_OIDC_COMPATIBILITY.findIn(f),
);
this.flows = oidcCompatibilityFlow ? [oidcCompatibilityFlow] : flows;
return this.flows;
}
public loginViaPassword(
username: string,
phoneCountry: string,
phoneNumber: string,
username: string | undefined,
phoneCountry: string | undefined,
phoneNumber: string | undefined,
password: string,
): Promise<IMatrixClientCreds> {
const isEmail = username.indexOf("@") > 0;
const isEmail = !!username && username.indexOf("@") > 0;
let identifier;
if (phoneCountry && phoneNumber) {
identifier = {
type: 'm.id.phone',
type: "m.id.phone",
country: phoneCountry,
phone: phoneNumber,
// XXX: Synapse historically wanted `number` and not `phone`
@ -108,13 +160,13 @@ export default class Login {
};
} else if (isEmail) {
identifier = {
type: 'm.id.thirdparty',
medium: 'email',
type: "m.id.thirdparty",
medium: "email",
address: username,
};
} else {
identifier = {
type: 'm.id.user',
type: "m.id.user",
user: username,
};
}
@ -125,34 +177,75 @@ export default class Login {
initial_device_display_name: this.defaultDeviceDisplayName,
};
const tryFallbackHs = (originalError) => {
return sendLoginRequest(
this.fallbackHsUrl, this.isUrl, 'm.login.password', loginParams,
).catch((fallbackError) => {
logger.log("fallback HS login failed", fallbackError);
// throw the original error
throw originalError;
});
const tryFallbackHs = (originalError: Error): Promise<IMatrixClientCreds> => {
return sendLoginRequest(this.fallbackHsUrl!, this.isUrl, "m.login.password", loginParams).catch(
(fallbackError) => {
logger.log("fallback HS login failed", fallbackError);
// throw the original error
throw originalError;
},
);
};
let originalLoginError = null;
return sendLoginRequest(
this.hsUrl, this.isUrl, 'm.login.password', loginParams,
).catch((error) => {
originalLoginError = error;
if (error.httpStatus === 403) {
if (this.fallbackHsUrl) {
return tryFallbackHs(originalLoginError);
let originalLoginError: Error | null = null;
return sendLoginRequest(this.hsUrl, this.isUrl, "m.login.password", loginParams)
.catch((error) => {
originalLoginError = error;
if (error.httpStatus === 403) {
if (this.fallbackHsUrl) {
return tryFallbackHs(originalLoginError!);
}
}
}
throw originalLoginError;
}).catch((error) => {
logger.log("Login failed", error);
throw error;
});
throw originalLoginError;
})
.catch((error) => {
logger.log("Login failed", error);
throw error;
});
}
}
/**
* Describes the OIDC native login flow
* Separate from js-sdk's `LoginFlow` as this does not use the same /login flow
* to which that type belongs.
*/
export interface OidcNativeFlow extends ILoginFlow {
type: "oidcNativeFlow";
// this client's id as registered with the configured OIDC OP
clientId: string;
}
/**
* Prepares an OidcNativeFlow for logging into the server.
*
* Finds a static clientId for configured issuer, or attempts dynamic registration with the OP, and wraps the
* results.
*
* @param delegatedAuthConfig Auth config from ValidatedServerConfig
* @param staticOidcClientIds static client config from config.json, used during client registration with OP
* @param isRegistration true when we are attempting registration
* @returns Promise<OidcNativeFlow> when oidc native authentication flow is supported and correctly configured
* @throws when client can't register with OP, or any unexpected error
*/
const tryInitOidcNativeFlow = async (
delegatedAuthConfig: OidcClientConfig,
staticOidcClientIds?: IConfigOptions["oidc_static_clients"],
isRegistration?: boolean,
): Promise<OidcNativeFlow> => {
// if registration is not supported, bail before attempting to get the clientId
if (isRegistration && !isUserRegistrationSupported(delegatedAuthConfig)) {
throw new Error("Registration is not supported by OP");
}
const clientId = await getOidcClientId(delegatedAuthConfig, staticOidcClientIds);
const flow = {
type: "oidcNativeFlow",
clientId,
} as OidcNativeFlow;
return flow;
};
/**
* Send a login request to the given server, and format the response
* as a MatrixClientCreds
@ -166,9 +259,9 @@ export default class Login {
*/
export async function sendLoginRequest(
hsUrl: string,
isUrl: string,
isUrl: string | undefined,
loginType: string,
loginParams: ILoginParams,
loginParams: Omit<LoginRequest, "type">,
): Promise<IMatrixClientCreds> {
const client = createClient({
baseUrl: hsUrl,
@ -179,11 +272,11 @@ export async function sendLoginRequest(
const wellknown = data.well_known;
if (wellknown) {
if (wellknown["m.homeserver"] && wellknown["m.homeserver"]["base_url"]) {
if (wellknown["m.homeserver"]?.["base_url"]) {
hsUrl = wellknown["m.homeserver"]["base_url"];
logger.log(`Overrode homeserver setting with ${hsUrl} from login response`);
}
if (wellknown["m.identity_server"] && wellknown["m.identity_server"]["base_url"]) {
if (wellknown["m.identity_server"]?.["base_url"]) {
// TODO: should we prompt here?
isUrl = wellknown["m.identity_server"]["base_url"];
logger.log(`Overrode IS setting with ${isUrl} from login response`);

View file

@ -15,36 +15,29 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import * as commonmark from 'commonmark';
import "./@types/commonmark"; // import better types than @types/commonmark
import * as commonmark from "commonmark";
import { escape } from "lodash";
import { logger } from 'matrix-js-sdk/src/logger';
import { logger } from "matrix-js-sdk/src/logger";
import { linkify } from './linkify-matrix';
import { linkify } from "./linkify-matrix";
const ALLOWED_HTML_TAGS = ['sub', 'sup', 'del', 'u'];
const ALLOWED_HTML_TAGS = ["sub", "sup", "del", "u", "br", "br/"];
// These types of node are definitely text
const TEXT_NODES = ['text', 'softbreak', 'linebreak', 'paragraph', 'document'];
// As far as @types/commonmark is concerned, these are not public, so add them
interface CommonmarkHtmlRendererInternal extends commonmark.HtmlRenderer {
paragraph: (node: commonmark.Node, entering: boolean) => void;
link: (node: commonmark.Node, entering: boolean) => void;
html_inline: (node: commonmark.Node) => void; // eslint-disable-line camelcase
html_block: (node: commonmark.Node) => void; // eslint-disable-line camelcase
text: (node: commonmark.Node) => void;
out: (text: string) => void;
emph: (node: commonmark.Node) => void;
}
const TEXT_NODES = ["text", "softbreak", "linebreak", "paragraph", "document"];
function isAllowedHtmlTag(node: commonmark.Node): boolean {
if (node.literal != null &&
node.literal.match('^<((div|span) data-mx-maths="[^"]*"|/(div|span))>$') != null) {
if (!node.literal) {
return false;
}
if (node.literal.match('^<((div|span) data-mx-maths="[^"]*"|/(div|span))>$') != null) {
return true;
}
// Regex won't work for tags with attrs, but we only
// allow <del> anyway.
// Regex won't work for tags with attrs, but the tags we allow
// shouldn't really have any anyway.
const matches = /^<\/?(.*)>$/.exec(node.literal);
if (matches && matches.length == 2) {
const tag = matches[1];
@ -67,16 +60,16 @@ function isMultiLine(node: commonmark.Node): boolean {
return par.firstChild != par.lastChild;
}
function getTextUntilEndOrLinebreak(node: commonmark.Node) {
let currentNode = node;
let text = '';
while (currentNode !== null && currentNode.type !== 'softbreak' && currentNode.type !== 'linebreak') {
function getTextUntilEndOrLinebreak(node: commonmark.Node): string {
let currentNode: commonmark.Node | null = node;
let text = "";
while (currentNode && currentNode.type !== "softbreak" && currentNode.type !== "linebreak") {
const { literal, type } = currentNode;
if (type === 'text' && literal) {
if (type === "text" && literal) {
let n = 0;
let char = literal[n];
while (char !== ' ' && char !== null && n <= literal.length) {
if (char === ' ') {
while (char !== " " && char !== null && n <= literal.length) {
if (char === " ") {
break;
}
if (char) {
@ -85,7 +78,7 @@ function getTextUntilEndOrLinebreak(node: commonmark.Node) {
n += 1;
char = literal[n];
}
if (char === ' ') {
if (char === " ") {
break;
}
}
@ -95,8 +88,32 @@ function getTextUntilEndOrLinebreak(node: commonmark.Node) {
}
const formattingChangesByNodeType = {
'emph': '_',
'strong': '__',
emph: "_",
strong: "__",
};
/**
* Returns the literal of a node an all child nodes.
*/
const innerNodeLiteral = (node: commonmark.Node): string => {
let literal = "";
const walker = node.walker();
let step: commonmark.NodeWalkingStep | null;
while ((step = walker.next())) {
const currentNode = step.node;
const currentNodeLiteral = currentNode.literal;
if (step.entering && currentNode.type === "text" && currentNodeLiteral) {
literal += currentNodeLiteral;
}
}
return literal;
};
const emptyItemWithNoSiblings = (node: commonmark.Node): boolean => {
return !node.prev && !node.next && !node.firstChild;
};
/**
@ -108,7 +125,7 @@ export default class Markdown {
private input: string;
private parsed: commonmark.Node;
constructor(input: string) {
public constructor(input: string) {
this.input = input;
const parser = new commonmark.Parser();
@ -128,16 +145,16 @@ export default class Markdown {
* See: https://github.com/vector-im/element-web/issues/4674
* @param parsed
*/
private repairLinks(parsed: commonmark.Node) {
private repairLinks(parsed: commonmark.Node): commonmark.Node {
const walker = parsed.walker();
let event: commonmark.NodeWalkingStep = null;
let text = '';
let event: commonmark.NodeWalkingStep | null = null;
let text = "";
let isInPara = false;
let previousNode: commonmark.Node | null = null;
let shouldUnlinkFormattingNode = false;
while ((event = walker.next())) {
const { node } = event;
if (node.type === 'paragraph') {
if (node.type === "paragraph") {
if (event.entering) {
isInPara = true;
} else {
@ -147,25 +164,25 @@ export default class Markdown {
if (isInPara) {
// Clear saved string when line ends
if (
node.type === 'softbreak' ||
node.type === 'linebreak' ||
node.type === "softbreak" ||
node.type === "linebreak" ||
// Also start calculating the text from the beginning on any spaces
(node.type === 'text' && node.literal === ' ')
(node.type === "text" && node.literal === " ")
) {
text = '';
text = "";
continue;
}
// Break up text nodes on spaces, so that we don't shoot past them without resetting
if (node.type === 'text') {
if (node.type === "text" && node.literal) {
const [thisPart, ...nextParts] = node.literal.split(/( )/);
node.literal = thisPart;
text += thisPart;
// Add the remaining parts as siblings
nextParts.reverse().forEach(part => {
nextParts.reverse().forEach((part) => {
if (part) {
const nextNode = new commonmark.Node('text');
const nextNode = new commonmark.Node("text");
nextNode.literal = part;
node.insertAfter(nextNode);
// Make the iterator aware of the newly inserted node
@ -175,31 +192,33 @@ export default class Markdown {
}
// We should not do this if previous node was not a textnode, as we can't combine it then.
if ((node.type === 'emph' || node.type === 'strong') && previousNode.type === 'text') {
if ((node.type === "emph" || node.type === "strong") && previousNode?.type === "text") {
if (event.entering) {
const foundLinks = linkify.find(text);
for (const { value } of foundLinks) {
if (node.firstChild.literal) {
if (node?.firstChild?.literal) {
/**
* NOTE: This technically should unlink the emph node and create LINK nodes instead, adding all the next elements as siblings
* but this solution seems to work well and is hopefully slightly easier to understand too
*/
const format = formattingChangesByNodeType[node.type];
const nonEmphasizedText = `${format}${node.firstChild.literal}${format}`;
const nonEmphasizedText = `${format}${innerNodeLiteral(node)}${format}`;
const f = getTextUntilEndOrLinebreak(node);
const newText = value + nonEmphasizedText + f;
const newLinks = linkify.find(newText);
// Should always find only one link here, if it finds more it means that the algorithm is broken
if (newLinks.length === 1) {
const emphasisTextNode = new commonmark.Node('text');
const emphasisTextNode = new commonmark.Node("text");
emphasisTextNode.literal = nonEmphasizedText;
previousNode.insertAfter(emphasisTextNode);
node.firstChild.literal = '';
node.firstChild.literal = "";
event = node.walker().next();
// Remove `em` opening and closing nodes
node.unlink();
previousNode.insertAfter(event.node);
shouldUnlinkFormattingNode = true;
if (event) {
// Remove `em` opening and closing nodes
node.unlink();
previousNode.insertAfter(event.node);
shouldUnlinkFormattingNode = true;
}
} else {
logger.error(
"Markdown links escaping found too many links for following text: ",
@ -225,16 +244,33 @@ export default class Markdown {
return parsed;
}
isPlainText(): boolean {
public isPlainText(): boolean {
const walker = this.parsed.walker();
let ev: commonmark.NodeWalkingStep | null;
let ev;
while (ev = walker.next()) {
while ((ev = walker.next())) {
const node = ev.node;
if (TEXT_NODES.indexOf(node.type) > -1) {
// definitely text
continue;
} else if (node.type == 'html_inline' || node.type == 'html_block') {
} else if (node.type == "list" || node.type == "item") {
// Special handling for inputs like `+`, `*`, `-` and `2021.` which
// would otherwise be treated as a list of a single empty item.
// See https://github.com/vector-im/element-web/issues/7631
if (node.type == "list" && node.firstChild && emptyItemWithNoSiblings(node.firstChild)) {
// A list with a single empty item is treated as plain text.
continue;
}
if (node.type == "item" && emptyItemWithNoSiblings(node)) {
// An empty list item with no sibling items is treated as plain text.
continue;
}
// Everything else is actual lists and therefore not plaintext.
return false;
} else if (node.type == "html_inline" || node.type == "html_block") {
// if it's an allowed html tag, we need to render it and therefore
// we will need to use HTML. If it's not allowed, it's not HTML since
// we'll just be treating it as text.
@ -248,7 +284,7 @@ export default class Markdown {
return true;
}
toHTML({ externalLinks = false } = {}): string {
public toHTML({ externalLinks = false } = {}): string {
const renderer = new commonmark.HtmlRenderer({
safe: false,
@ -257,8 +293,8 @@ export default class Markdown {
// so if these are just newline characters then the
// block quote ends up all on one line
// (https://github.com/vector-im/element-web/issues/3154)
softbreak: '<br />',
}) as CommonmarkHtmlRendererInternal;
softbreak: "<br />",
});
// Trying to strip out the wrapping <p/> causes a lot more complication
// than it's worth, i think. For instance, this code will go and strip
@ -269,7 +305,7 @@ export default class Markdown {
//
// Let's try sending with <p/>s anyway for now, though.
const realParagraph = renderer.paragraph;
renderer.paragraph = function(node: commonmark.Node, entering: boolean) {
renderer.paragraph = function (node: commonmark.Node, entering: boolean) {
// If there is only one top level node, just return the
// bare text: it's a single line of text and so should be
// 'inline', rather than unnecessarily wrapped in its own
@ -278,39 +314,41 @@ export default class Markdown {
// However, if it's a blockquote, adds a p tag anyway
// in order to avoid deviation to commonmark and unexpected
// results when parsing the formatted HTML.
if (node.parent.type === 'block_quote'|| isMultiLine(node)) {
if (node.parent?.type === "block_quote" || isMultiLine(node)) {
realParagraph.call(this, node, entering);
}
};
renderer.link = function(node, entering) {
renderer.link = function (node, entering) {
const attrs = this.attrs(node);
if (entering) {
attrs.push(['href', this.esc(node.destination)]);
if (entering && node.destination) {
attrs.push(["href", this.esc(node.destination)]);
if (node.title) {
attrs.push(['title', this.esc(node.title)]);
attrs.push(["title", this.esc(node.title)]);
}
// Modified link behaviour to treat them all as external and
// thus opening in a new tab.
if (externalLinks) {
attrs.push(['target', '_blank']);
attrs.push(['rel', 'noreferrer noopener']);
attrs.push(["target", "_blank"]);
attrs.push(["rel", "noreferrer noopener"]);
}
this.tag('a', attrs);
this.tag("a", attrs);
} else {
this.tag('/a');
this.tag("/a");
}
};
renderer.html_inline = function(node: commonmark.Node) {
if (isAllowedHtmlTag(node)) {
this.lit(node.literal);
} else {
this.lit(escape(node.literal));
renderer.html_inline = function (node: commonmark.Node) {
if (node.literal) {
if (isAllowedHtmlTag(node)) {
this.lit(node.literal);
} else {
this.lit(escape(node.literal));
}
}
};
renderer.html_block = function(node: commonmark.Node) {
renderer.html_block = function (node: commonmark.Node) {
/*
// as with `paragraph`, we only insert line breaks
// if there are multiple lines in the markdown.
@ -335,22 +373,22 @@ export default class Markdown {
* N.B. this does **NOT** render arbitrary MD to plain text - only MD
* which has no formatting. Otherwise it emits HTML(!).
*/
toPlaintext(): string {
const renderer = new commonmark.HtmlRenderer({ safe: false }) as CommonmarkHtmlRendererInternal;
public toPlaintext(): string {
const renderer = new commonmark.HtmlRenderer({ safe: false });
renderer.paragraph = function(node: commonmark.Node, entering: boolean) {
renderer.paragraph = function (node: commonmark.Node, entering: boolean) {
// as with toHTML, only append lines to paragraphs if there are
// multiple paragraphs
if (isMultiLine(node)) {
if (!entering && node.next) {
this.lit('\n\n');
this.lit("\n\n");
}
}
};
renderer.html_block = function(node: commonmark.Node) {
this.lit(node.literal);
if (isMultiLine(node) && node.next) this.lit('\n\n');
renderer.html_block = function (node: commonmark.Node) {
if (node.literal) this.lit(node.literal);
if (isMultiLine(node) && node.next) this.lit("\n\n");
};
return renderer.render(this.parsed);

View file

@ -2,7 +2,7 @@
Copyright 2015, 2016 OpenMarket Ltd
Copyright 2017 Vector Creations Ltd.
Copyright 2017, 2018, 2019 New Vector Ltd
Copyright 2019, 2020 The Matrix.org Foundation C.I.C.
Copyright 2019 - 2023 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.
@ -17,28 +17,43 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import { ICreateClientOpts, PendingEventOrdering, RoomNameState, RoomNameType } from 'matrix-js-sdk/src/matrix';
import { IStartClientOpts, MatrixClient } from 'matrix-js-sdk/src/client';
import { MemoryStore } from 'matrix-js-sdk/src/store/memory';
import * as utils from 'matrix-js-sdk/src/utils';
import { EventTimeline } from 'matrix-js-sdk/src/models/event-timeline';
import { EventTimelineSet } from 'matrix-js-sdk/src/models/event-timeline-set';
import { verificationMethods } from 'matrix-js-sdk/src/crypto';
import {
EventTimeline,
EventTimelineSet,
ICreateClientOpts,
IStartClientOpts,
MatrixClient,
MemoryStore,
PendingEventOrdering,
RoomNameState,
RoomNameType,
TokenRefreshFunction,
} from "matrix-js-sdk/src/matrix";
import * as utils from "matrix-js-sdk/src/utils";
import { verificationMethods } from "matrix-js-sdk/src/crypto";
import { SHOW_QR_CODE_METHOD } from "matrix-js-sdk/src/crypto/verification/QRCode";
import { logger } from "matrix-js-sdk/src/logger";
import createMatrixClient from './utils/createMatrixClient';
import SettingsStore from './settings/SettingsStore';
import MatrixActionCreators from './actions/MatrixActionCreators';
import Modal from './Modal';
import createMatrixClient from "./utils/createMatrixClient";
import SettingsStore from "./settings/SettingsStore";
import MatrixActionCreators from "./actions/MatrixActionCreators";
import Modal from "./Modal";
import MatrixClientBackedSettingsHandler from "./settings/handlers/MatrixClientBackedSettingsHandler";
import * as StorageManager from './utils/StorageManager';
import IdentityAuthClient from './IdentityAuthClient';
import { crossSigningCallbacks, tryToUnlockSecretStorageWithDehydrationKey } from './SecurityManager';
import * as StorageManager from "./utils/StorageManager";
import IdentityAuthClient from "./IdentityAuthClient";
import { crossSigningCallbacks, tryToUnlockSecretStorageWithDehydrationKey } from "./SecurityManager";
import SecurityCustomisations from "./customisations/Security";
import { SlidingSyncManager } from './SlidingSyncManager';
import { SlidingSyncManager } from "./SlidingSyncManager";
import CryptoStoreTooNewDialog from "./components/views/dialogs/CryptoStoreTooNewDialog";
import { _t } from "./languageHandler";
import { _t, UserFriendlyError } from "./languageHandler";
import { SettingLevel } from "./settings/SettingLevel";
import MatrixClientBackedController from "./settings/controllers/MatrixClientBackedController";
import ErrorDialog from "./components/views/dialogs/ErrorDialog";
import PlatformPeg from "./PlatformPeg";
import { formatList } from "./utils/FormattingUtils";
import SdkConfig from "./SdkConfig";
import { Features } from "./settings/Settings";
import { PhasedRolloutFeature } from "./utils/PhasedRolloutFeature";
export interface IMatrixClientCreds {
homeserverUrl: string;
@ -46,6 +61,7 @@ export interface IMatrixClientCreds {
userId: string;
deviceId?: string;
accessToken: string;
refreshToken?: string;
guest?: boolean;
pickleKey?: string;
freshLogin?: boolean;
@ -69,13 +85,12 @@ export interface IMatrixClientPeg {
*/
getHomeserverName(): string;
get(): MatrixClient;
get(): MatrixClient | null;
safeGet(): MatrixClient;
unset(): void;
assign(): Promise<any>;
start(): Promise<any>;
getCredentials(): IMatrixClientCreds;
/**
* If we've registered a user ID we set this to the ID of the
* user we've just registered. If they then go & log in, we
@ -111,8 +126,10 @@ export interface IMatrixClientPeg {
* homeserver / identity server URLs and active credentials
*
* @param {IMatrixClientCreds} creds The new credentials to use.
* @param {TokenRefreshFunction} tokenRefreshFunction OPTIONAL function used by MatrixClient to attempt token refresh
* see {@link ICreateClientOpts.tokenRefreshFunction}
*/
replaceUsingCreds(creds: IMatrixClientCreds): void;
replaceUsingCreds(creds: IMatrixClientCreds, tokenRefreshFunction?: TokenRefreshFunction): void;
}
/**
@ -130,14 +147,17 @@ class MatrixClientPegClass implements IMatrixClientPeg {
initialSyncLimit: 20,
};
private matrixClient: MatrixClient = null;
private matrixClient: MatrixClient | null = null;
private justRegisteredUserId: string | null = null;
// the credentials used to init the current client object.
// used if we tear it down & recreate it with a different store
private currentClientCreds: IMatrixClientCreds;
public get(): MatrixClient | null {
return this.matrixClient;
}
public get(): MatrixClient {
public safeGet(): MatrixClient {
if (!this.matrixClient) {
throw new UserFriendlyError("error_user_not_logged_in");
}
return this.matrixClient;
}
@ -156,10 +176,7 @@ class MatrixClientPegClass implements IMatrixClientPeg {
}
public currentUserIsJustRegistered(): boolean {
return (
this.matrixClient &&
this.matrixClient.credentials.userId === this.justRegisteredUserId
);
return !!this.matrixClient && this.matrixClient.credentials.userId === this.justRegisteredUserId;
}
public userRegisteredWithinLastHours(hours: number): boolean {
@ -168,9 +185,9 @@ class MatrixClientPegClass implements IMatrixClientPeg {
}
try {
const registrationTime = parseInt(window.localStorage.getItem("mx_registration_time"), 10);
const registrationTime = parseInt(window.localStorage.getItem("mx_registration_time")!, 10);
const diff = Date.now() - registrationTime;
return (diff / 36e5) <= hours;
return diff / 36e5 <= hours;
} catch (e) {
return false;
}
@ -178,57 +195,75 @@ class MatrixClientPegClass implements IMatrixClientPeg {
public userRegisteredAfter(timestamp: Date): boolean {
try {
const registrationTime = parseInt(window.localStorage.getItem("mx_registration_time"), 10);
const registrationTime = parseInt(window.localStorage.getItem("mx_registration_time")!, 10);
return timestamp.getTime() <= registrationTime;
} catch (e) {
return false;
}
}
public replaceUsingCreds(creds: IMatrixClientCreds): void {
this.currentClientCreds = creds;
this.createClient(creds);
public replaceUsingCreds(creds: IMatrixClientCreds, tokenRefreshFunction?: TokenRefreshFunction): void {
this.createClient(creds, tokenRefreshFunction);
}
private onUnexpectedStoreClose = async (): Promise<void> => {
if (!this.matrixClient) return;
this.matrixClient.stopClient(); // stop the client as the database has failed
this.matrixClient.store.destroy();
if (!this.matrixClient.isGuest()) {
// If the user is not a guest then prompt them to reload rather than doing it for them
// For guests this is likely to happen during e-mail verification as part of registration
const brand = SdkConfig.get().brand;
const platform = PlatformPeg.get()?.getHumanReadableName();
// Determine the description based on the platform
const description =
platform === "Web Platform"
? _t("error_database_closed_description|for_web", { brand })
: _t("error_database_closed_description|for_desktop");
const [reload] = await Modal.createDialog(ErrorDialog, {
title: _t("error_database_closed_title", { brand }),
description,
button: _t("action|reload"),
}).finished;
if (!reload) return;
}
PlatformPeg.get()?.reload();
};
public async assign(): Promise<any> {
for (const dbType of ['indexeddb', 'memory']) {
if (!this.matrixClient) {
throw new Error("createClient must be called first");
}
for (const dbType of ["indexeddb", "memory"]) {
try {
const promise = this.matrixClient.store.startup();
logger.log("MatrixClientPeg: waiting for MatrixClient store to initialise");
await promise;
break;
} catch (err) {
if (dbType === 'indexeddb') {
logger.error('Error starting matrixclient store - falling back to memory store', err);
if (dbType === "indexeddb") {
logger.error("Error starting matrixclient store - falling back to memory store", err);
this.matrixClient.store = new MemoryStore({
localStorage: localStorage,
});
} else {
logger.error('Failed to start memory store!', err);
logger.error("Failed to start memory store!", err);
throw err;
}
}
}
this.matrixClient.store.on?.("closed", this.onUnexpectedStoreClose);
// try to initialise e2e on the new client
try {
// check that we have a version of the js-sdk which includes initCrypto
if (!SettingsStore.getValue("lowBandwidth") && this.matrixClient.initCrypto) {
await this.matrixClient.initCrypto();
this.matrixClient.setCryptoTrustCrossSignedDevices(
!SettingsStore.getValue('e2ee.manuallyVerifyAllSessions'),
);
await tryToUnlockSecretStorageWithDehydrationKey(this.matrixClient);
StorageManager.setCryptoInitialised(true);
}
} catch (e) {
if (e && e.name === 'InvalidCryptoStoreError') {
// The js-sdk found a crypto DB too new for it to use
Modal.createDialog(CryptoStoreTooNewDialog);
}
// this can happen for a number of reasons, the most likely being
// that the olm library was missing. It's not fatal.
logger.warn("Unable to initialise e2e", e);
if (!SettingsStore.getValue("lowBandwidth")) {
await this.initClientCrypto();
}
const opts = utils.deepCopy(this.opts);
@ -236,7 +271,7 @@ class MatrixClientPegClass implements IMatrixClientPeg {
opts.pendingEventOrdering = PendingEventOrdering.Detached;
opts.lazyLoadMembers = true;
opts.clientWellKnownPollPeriod = 2 * 60 * 60; // 2 hours
opts.experimentalThreadSupport = SettingsStore.getValue("feature_thread");
opts.threadSupport = true;
if (SettingsStore.getValue("feature_sliding_sync")) {
const proxyUrl = SettingsStore.getValue("feature_sliding_sync_proxy_url");
@ -249,44 +284,95 @@ class MatrixClientPegClass implements IMatrixClientPeg {
this.matrixClient,
proxyUrl || this.matrixClient.baseUrl,
);
SlidingSyncManager.instance.startSpidering(100, 50); // 100 rooms at a time, 50ms apart
}
// Connect the matrix client to the dispatcher and setting handlers
MatrixActionCreators.start(this.matrixClient);
MatrixClientBackedSettingsHandler.matrixClient = this.matrixClient;
MatrixClientBackedController.matrixClient = this.matrixClient;
return opts;
}
/**
* Attempt to initialize the crypto layer on a newly-created MatrixClient
*/
private async initClientCrypto(): Promise<void> {
if (!this.matrixClient) {
throw new Error("createClient must be called first");
}
let useRustCrypto = SettingsStore.getValue(Features.RustCrypto);
// We want the value that is set in the config.json for that web instance
const defaultUseRustCrypto = SettingsStore.getValueAt(SettingLevel.CONFIG, Features.RustCrypto);
const migrationPercent = SettingsStore.getValueAt(SettingLevel.CONFIG, "RustCrypto.staged_rollout_percent");
// If the default config is to use rust crypto, and the user is on legacy crypto,
// we want to check if we should migrate the current user.
if (!useRustCrypto && defaultUseRustCrypto && Number.isInteger(migrationPercent)) {
// The user is not on rust crypto, but the default stack is now rust; Let's check if we should migrate
// the current user to rust crypto.
try {
const stagedRollout = new PhasedRolloutFeature("RustCrypto.staged_rollout_percent", migrationPercent);
// Device id should not be null at that point, or init crypto will fail anyhow
const deviceId = this.matrixClient.getDeviceId()!;
// we use deviceId rather than userId because we don't particularly want all devices
// of a user to be migrated at the same time.
useRustCrypto = stagedRollout.isFeatureEnabled(deviceId);
} catch (e) {
logger.warn("Failed to create staged rollout feature for rust crypto migration", e);
}
}
// we want to make sure that the same crypto implementation is used throughout the lifetime of a device,
// so persist the setting at the device layer
// (At some point, we'll allow the user to *enable* the setting via labs, which will migrate their existing
// device to the rust-sdk implementation, but that won't change anything here).
await SettingsStore.setValue(Features.RustCrypto, null, SettingLevel.DEVICE, useRustCrypto);
// Now we can initialise the right crypto impl.
if (useRustCrypto) {
await this.matrixClient.initRustCrypto();
StorageManager.setCryptoInitialised(true);
// TODO: device dehydration and whathaveyou
return;
}
// fall back to the libolm layer.
try {
// check that we have a version of the js-sdk which includes initCrypto
if (this.matrixClient.initCrypto) {
await this.matrixClient.initCrypto();
this.matrixClient.setCryptoTrustCrossSignedDevices(
!SettingsStore.getValue("e2ee.manuallyVerifyAllSessions"),
);
await tryToUnlockSecretStorageWithDehydrationKey(this.matrixClient);
StorageManager.setCryptoInitialised(true);
}
} catch (e) {
if (e instanceof Error && e.name === "InvalidCryptoStoreError") {
// The js-sdk found a crypto DB too new for it to use
Modal.createDialog(CryptoStoreTooNewDialog);
}
// this can happen for a number of reasons, the most likely being
// that the olm library was missing. It's not fatal.
logger.warn("Unable to initialise e2e", e);
}
}
public async start(): Promise<any> {
const opts = await this.assign();
logger.log(`MatrixClientPeg: really starting MatrixClient`);
await this.get().startClient(opts);
await this.matrixClient!.startClient(opts);
logger.log(`MatrixClientPeg: MatrixClient started`);
}
public getCredentials(): IMatrixClientCreds {
let copiedCredentials = this.currentClientCreds;
if (this.currentClientCreds?.userId !== this.matrixClient?.credentials?.userId) {
// cached credentials belong to a different user - don't use them
copiedCredentials = null;
}
return {
// Copy the cached credentials before overriding what we can.
...(copiedCredentials ?? {}),
homeserverUrl: this.matrixClient.baseUrl,
identityServerUrl: this.matrixClient.idBaseUrl,
userId: this.matrixClient.credentials.userId,
deviceId: this.matrixClient.getDeviceId(),
accessToken: this.matrixClient.getAccessToken(),
guest: this.matrixClient.isGuest(),
};
}
public getHomeserverName(): string {
const matches = /^@[^:]+:(.+)$/.exec(this.matrixClient.credentials.userId);
const matches = /^@[^:]+:(.+)$/.exec(this.safeGet().getSafeUserId());
if (matches === null || matches.length < 1) {
throw new Error("Failed to derive homeserver name from user ID!");
}
@ -296,7 +382,7 @@ class MatrixClientPegClass implements IMatrixClientPeg {
private namesToRoomName(names: string[], count: number): string | undefined {
const countWithoutMe = count - 1;
if (!names.length) {
return _t("Empty room");
return _t("empty_room");
}
if (names.length === 1 && countWithoutMe <= 1) {
return names[0];
@ -308,15 +394,9 @@ class MatrixClientPegClass implements IMatrixClientPeg {
if (name) return name;
if (names.length === 2 && count === 2) {
return _t("%(user1)s and %(user2)s", {
user1: names[0],
user2: names[1],
});
return formatList(names);
}
return _t("%(user)s and %(count)s others", {
user: names[0],
count: count - 1,
});
return formatList(names, 1);
}
private inviteeNamesToRoomName(names: string[], count: number): string {
@ -324,28 +404,30 @@ class MatrixClientPegClass implements IMatrixClientPeg {
if (name) return name;
if (names.length === 2 && count === 2) {
return _t("Inviting %(user1)s and %(user2)s", {
return _t("inviting_user1_and_user2", {
user1: names[0],
user2: names[1],
});
}
return _t("Inviting %(user)s and %(count)s others", {
return _t("inviting_user_and_n_others", {
user: names[0],
count: count - 1,
});
}
private createClient(creds: IMatrixClientCreds): void {
private createClient(creds: IMatrixClientCreds, tokenRefreshFunction?: TokenRefreshFunction): void {
const opts: ICreateClientOpts = {
baseUrl: creds.homeserverUrl,
idBaseUrl: creds.identityServerUrl,
accessToken: creds.accessToken,
refreshToken: creds.refreshToken,
tokenRefreshFunction,
userId: creds.userId,
deviceId: creds.deviceId,
pickleKey: creds.pickleKey,
timelineSupport: true,
forceTURN: !SettingsStore.getValue('webRtcAllowPeerToPeer'),
fallbackICEServerAllowed: !!SettingsStore.getValue('fallbackICEServerAllowed'),
forceTURN: !SettingsStore.getValue("webRtcAllowPeerToPeer"),
fallbackICEServerAllowed: !!SettingsStore.getValue("fallbackICEServerAllowed"),
// Gather up to 20 ICE candidates when a call arrives: this should be more than we'd
// ever normally need, so effectively this should make all the gathering happen when
// the call arrives.
@ -370,11 +452,11 @@ class MatrixClientPegClass implements IMatrixClientPeg {
}
case RoomNameType.EmptyRoom:
if (state.oldName) {
return _t("Empty room (was %(oldName)s)", {
return _t("empty_room_was_name", {
oldName: state.oldName,
});
} else {
return _t("Empty room");
return _t("empty_room");
}
default:
return null;
@ -387,11 +469,6 @@ class MatrixClientPegClass implements IMatrixClientPeg {
}
this.matrixClient = createMatrixClient(opts);
// we're going to add eventlisteners for each matrix event tile, so the
// potential number of event listeners is quite high.
this.matrixClient.setMaxListeners(500);
this.matrixClient.setGuest(Boolean(creds.guest));
const notifTimelineSet = new EventTimelineSet(undefined, {

View file

@ -15,12 +15,13 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import EventEmitter from 'events';
import EventEmitter from "events";
import { logger } from "matrix-js-sdk/src/logger";
import SettingsStore from "./settings/SettingsStore";
import { SettingLevel } from "./settings/SettingLevel";
import { MatrixClientPeg } from "./MatrixClientPeg";
import { _t } from "./languageHandler";
// XXX: MediaDeviceKind is a union type, so we make our own enum
export enum MediaDeviceKindEnum {
@ -36,7 +37,7 @@ export enum MediaDeviceHandlerEvent {
}
export default class MediaDeviceHandler extends EventEmitter {
private static internalInstance;
private static internalInstance?: MediaDeviceHandler;
public static get instance(): MediaDeviceHandler {
if (!MediaDeviceHandler.internalInstance) {
@ -47,7 +48,7 @@ export default class MediaDeviceHandler extends EventEmitter {
public static async hasAnyLabeledDevices(): Promise<boolean> {
const devices = await navigator.mediaDevices.enumerateDevices();
return devices.some(d => Boolean(d.label));
return devices.some((d) => Boolean(d.label));
}
/**
@ -63,10 +64,10 @@ export default class MediaDeviceHandler extends EventEmitter {
*
* @return Promise<IMediaDevices> The available media devices
*/
public static async getDevices(): Promise<IMediaDevices> {
public static async getDevices(): Promise<IMediaDevices | undefined> {
try {
const devices = await navigator.mediaDevices.enumerateDevices();
const output = {
const output: Record<MediaDeviceKindEnum, MediaDeviceInfo[]> = {
[MediaDeviceKindEnum.AudioOutput]: [],
[MediaDeviceKindEnum.AudioInput]: [],
[MediaDeviceKindEnum.VideoInput]: [],
@ -75,10 +76,22 @@ export default class MediaDeviceHandler extends EventEmitter {
devices.forEach((device) => output[device.kind].push(device));
return output;
} catch (error) {
logger.warn('Unable to refresh WebRTC Devices: ', error);
logger.warn("Unable to refresh WebRTC Devices: ", error);
}
}
public static getDefaultDevice = (devices: Array<Partial<MediaDeviceInfo>>): string => {
// Note we're looking for a device with deviceId 'default' but adding a device
// with deviceId == the empty string: this is because Chrome gives us a device
// with deviceId 'default', so we're looking for this, not the one we are adding.
if (!devices.some((i) => i.deviceId === "default")) {
devices.unshift({ deviceId: "", label: _t("voip|default_device") });
return "";
} else {
return "default";
}
};
/**
* Retrieves devices from the SettingsStore and tells the js-sdk to use them
*/
@ -86,8 +99,18 @@ export default class MediaDeviceHandler extends EventEmitter {
const audioDeviceId = SettingsStore.getValue("webrtc_audioinput");
const videoDeviceId = SettingsStore.getValue("webrtc_videoinput");
await MatrixClientPeg.get().getMediaHandler().setAudioInput(audioDeviceId);
await MatrixClientPeg.get().getMediaHandler().setVideoInput(videoDeviceId);
await MatrixClientPeg.safeGet().getMediaHandler().setAudioInput(audioDeviceId);
await MatrixClientPeg.safeGet().getMediaHandler().setVideoInput(videoDeviceId);
await MediaDeviceHandler.updateAudioSettings();
}
private static async updateAudioSettings(): Promise<void> {
await MatrixClientPeg.safeGet().getMediaHandler().setAudioSettings({
autoGainControl: MediaDeviceHandler.getAudioAutoGainControl(),
echoCancellation: MediaDeviceHandler.getAudioEchoCancellation(),
noiseSuppression: MediaDeviceHandler.getAudioNoiseSuppression(),
});
}
public setAudioOutput(deviceId: string): void {
@ -102,7 +125,7 @@ export default class MediaDeviceHandler extends EventEmitter {
*/
public async setAudioInput(deviceId: string): Promise<void> {
SettingsStore.setValue("webrtc_audioinput", null, SettingLevel.DEVICE, deviceId);
return MatrixClientPeg.get().getMediaHandler().setAudioInput(deviceId);
return MatrixClientPeg.safeGet().getMediaHandler().setAudioInput(deviceId);
}
/**
@ -112,17 +135,38 @@ export default class MediaDeviceHandler extends EventEmitter {
*/
public async setVideoInput(deviceId: string): Promise<void> {
SettingsStore.setValue("webrtc_videoinput", null, SettingLevel.DEVICE, deviceId);
return MatrixClientPeg.get().getMediaHandler().setVideoInput(deviceId);
return MatrixClientPeg.safeGet().getMediaHandler().setVideoInput(deviceId);
}
public async setDevice(deviceId: string, kind: MediaDeviceKindEnum): Promise<void> {
switch (kind) {
case MediaDeviceKindEnum.AudioOutput: this.setAudioOutput(deviceId); break;
case MediaDeviceKindEnum.AudioInput: await this.setAudioInput(deviceId); break;
case MediaDeviceKindEnum.VideoInput: await this.setVideoInput(deviceId); break;
case MediaDeviceKindEnum.AudioOutput:
this.setAudioOutput(deviceId);
break;
case MediaDeviceKindEnum.AudioInput:
await this.setAudioInput(deviceId);
break;
case MediaDeviceKindEnum.VideoInput:
await this.setVideoInput(deviceId);
break;
}
}
public static async setAudioAutoGainControl(value: boolean): Promise<void> {
await SettingsStore.setValue("webrtc_audio_autoGainControl", null, SettingLevel.DEVICE, value);
await MediaDeviceHandler.updateAudioSettings();
}
public static async setAudioEchoCancellation(value: boolean): Promise<void> {
await SettingsStore.setValue("webrtc_audio_echoCancellation", null, SettingLevel.DEVICE, value);
await MediaDeviceHandler.updateAudioSettings();
}
public static async setAudioNoiseSuppression(value: boolean): Promise<void> {
await SettingsStore.setValue("webrtc_audio_noiseSuppression", null, SettingLevel.DEVICE, value);
await MediaDeviceHandler.updateAudioSettings();
}
public static getAudioOutput(): string {
return SettingsStore.getValueAt(SettingLevel.DEVICE, "webrtc_audiooutput");
}
@ -135,6 +179,18 @@ export default class MediaDeviceHandler extends EventEmitter {
return SettingsStore.getValueAt(SettingLevel.DEVICE, "webrtc_videoinput");
}
public static getAudioAutoGainControl(): boolean {
return SettingsStore.getValue("webrtc_audio_autoGainControl");
}
public static getAudioEchoCancellation(): boolean {
return SettingsStore.getValue("webrtc_audio_echoCancellation");
}
public static getAudioNoiseSuppression(): boolean {
return SettingsStore.getValue("webrtc_audio_noiseSuppression");
}
/**
* Returns the current set deviceId for a device kind
* @param {MediaDeviceKindEnum} kind of the device that will be returned
@ -142,9 +198,12 @@ export default class MediaDeviceHandler extends EventEmitter {
*/
public static getDevice(kind: MediaDeviceKindEnum): string {
switch (kind) {
case MediaDeviceKindEnum.AudioOutput: return this.getAudioOutput();
case MediaDeviceKindEnum.AudioInput: return this.getAudioInput();
case MediaDeviceKindEnum.VideoInput: return this.getVideoInput();
case MediaDeviceKindEnum.AudioOutput:
return this.getAudioOutput();
case MediaDeviceKindEnum.AudioInput:
return this.getAudioInput();
case MediaDeviceKindEnum.VideoInput:
return this.getVideoInput();
}
}

View file

@ -15,60 +15,77 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import React from 'react';
import ReactDOM from 'react-dom';
import classNames from 'classnames';
import React from "react";
import ReactDOM from "react-dom";
import classNames from "classnames";
import { defer, sleep } from "matrix-js-sdk/src/utils";
import { TypedEventEmitter } from "matrix-js-sdk/src/matrix";
import { Glass, TooltipProvider } from "@vector-im/compound-web";
import dis from './dispatcher/dispatcher';
import AsyncWrapper from './AsyncWrapper';
import dis from "./dispatcher/dispatcher";
import AsyncWrapper from "./AsyncWrapper";
import { Defaultize } from "./@types/common";
const DIALOG_CONTAINER_ID = "mx_Dialog_Container";
const STATIC_DIALOG_CONTAINER_ID = "mx_Dialog_StaticContainer";
export interface IModal<T extends any[]> {
// Type which accepts a React Component which looks like a Modal (accepts an onFinished prop)
export type ComponentType =
| React.ComponentType<{
onFinished(...args: any): void;
}>
| React.ComponentType<any>;
// Generic type which returns the props of the Modal component with the onFinished being optional.
export type ComponentProps<C extends ComponentType> = Defaultize<
Omit<React.ComponentProps<C>, "onFinished">,
C["defaultProps"]
> &
Partial<Pick<React.ComponentProps<C>, "onFinished">>;
export interface IModal<C extends ComponentType> {
elem: React.ReactNode;
className?: string;
beforeClosePromise?: Promise<boolean>;
closeReason?: string;
onBeforeClose?(reason?: string): Promise<boolean>;
onFinished(...args: T): void;
close(...args: T): void;
onFinished: ComponentProps<C>["onFinished"];
close(...args: Parameters<ComponentProps<C>["onFinished"]>): void;
hidden?: boolean;
}
export interface IHandle<T extends any[]> {
finished: Promise<T>;
close(...args: T): void;
export interface IHandle<C extends ComponentType> {
finished: Promise<Parameters<ComponentProps<C>["onFinished"]>>;
close(...args: Parameters<ComponentProps<C>["onFinished"]>): void;
}
interface IProps<T extends any[]> {
onFinished?(...args: T): void;
// TODO improve typing here once all Modals are TS and we can exhaustively check the props
[key: string]: any;
interface IOptions<C extends ComponentType> {
onBeforeClose?: IModal<C>["onBeforeClose"];
}
interface IOptions<T extends any[]> {
onBeforeClose?: IModal<T>["onBeforeClose"];
export enum ModalManagerEvent {
Opened = "opened",
}
type ParametersWithoutFirst<T extends (...args: any) => any> = T extends (a: any, ...args: infer P) => any ? P : never;
type HandlerMap = {
[ModalManagerEvent.Opened]: () => void;
};
export class ModalManager {
export class ModalManager extends TypedEventEmitter<ModalManagerEvent, HandlerMap> {
private counter = 0;
// The modal to prioritise over all others. If this is set, only show
// this modal. Remove all other modals from the stack when this modal
// is closed.
private priorityModal: IModal<any> = null;
private priorityModal: IModal<any> | null = null;
// The modal to keep open underneath other modals if possible. Useful
// for cases like Settings where the modal should remain open while the
// user is prompted for more information/errors.
private staticModal: IModal<any> = null;
private staticModal: IModal<any> | null = null;
// A list of the modals we have stacked up, with the most recent at [0]
// Neither the static nor priority modal will be in this list.
private modals: IModal<any>[] = [];
private static getOrCreateContainer() {
private static getOrCreateContainer(): HTMLElement {
let container = document.getElementById(DIALOG_CONTAINER_ID);
if (!container) {
@ -80,7 +97,7 @@ export class ModalManager {
return container;
}
private static getOrCreateStaticContainer() {
private static getOrCreateStaticContainer(): HTMLElement {
let container = document.getElementById(STATIC_DIALOG_CONTAINER_ID);
if (!container) {
@ -92,59 +109,77 @@ export class ModalManager {
return container;
}
public toggleCurrentDialogVisibility() {
public toggleCurrentDialogVisibility(): void {
const modal = this.getCurrentModal();
if (!modal) return;
modal.hidden = !modal.hidden;
}
public hasDialogs() {
return this.priorityModal || this.staticModal || this.modals.length > 0;
public hasDialogs(): boolean {
return !!this.priorityModal || !!this.staticModal || this.modals.length > 0;
}
public createDialog<T extends any[]>(
Element: React.ComponentType<any>,
...rest: ParametersWithoutFirst<ModalManager["createDialogAsync"]>
) {
return this.createDialogAsync<T>(Promise.resolve(Element), ...rest);
public createDialog<C extends ComponentType>(
Element: C,
props?: ComponentProps<C>,
className?: string,
isPriorityModal = false,
isStaticModal = false,
options: IOptions<C> = {},
): IHandle<C> {
return this.createDialogAsync<C>(
Promise.resolve(Element),
props,
className,
isPriorityModal,
isStaticModal,
options,
);
}
public appendDialog<T extends any[]>(
Element: React.ComponentType,
...rest: ParametersWithoutFirst<ModalManager["appendDialogAsync"]>
) {
return this.appendDialogAsync<T>(Promise.resolve(Element), ...rest);
public appendDialog<C extends ComponentType>(
Element: C,
props?: ComponentProps<C>,
className?: string,
): IHandle<C> {
return this.appendDialogAsync<C>(Promise.resolve(Element), props, className);
}
public closeCurrentModal(reason: string) {
/**
* @param reason either "backgroundClick" or undefined
* @return whether a modal was closed
*/
public closeCurrentModal(reason?: string): boolean {
const modal = this.getCurrentModal();
if (!modal) {
return;
return false;
}
modal.closeReason = reason;
modal.close();
return true;
}
private buildModal<T extends any[]>(
prom: Promise<React.ComponentType>,
props?: IProps<T>,
private buildModal<C extends ComponentType>(
prom: Promise<C>,
props?: ComponentProps<C>,
className?: string,
options?: IOptions<T>,
) {
const modal: IModal<T> = {
onFinished: props ? props.onFinished : null,
onBeforeClose: options.onBeforeClose,
beforeClosePromise: null,
closeReason: null,
options?: IOptions<C>,
): {
modal: IModal<C>;
closeDialog: IHandle<C>["close"];
onFinishedProm: IHandle<C>["finished"];
} {
const modal = {
onFinished: props?.onFinished,
onBeforeClose: options?.onBeforeClose,
className,
// these will be set below but we need an object reference to pass to getCloseFn before we can do that
elem: null,
close: null,
};
} as IModal<C>;
// never call this from onFinished() otherwise it will loop
const [closeDialog, onFinishedProm] = this.getCloseFn<T>(modal, props);
const [closeDialog, onFinishedProm] = this.getCloseFn<C>(modal, props);
// don't attempt to reuse the same AsyncWrapper for different dialogs,
// otherwise we'll get confused.
@ -158,45 +193,48 @@ export class ModalManager {
return { modal, closeDialog, onFinishedProm };
}
private getCloseFn<T extends any[]>(
modal: IModal<T>,
props: IProps<T>,
): [IHandle<T>["close"], IHandle<T>["finished"]] {
const deferred = defer<T>();
return [async (...args: T) => {
if (modal.beforeClosePromise) {
await modal.beforeClosePromise;
} else if (modal.onBeforeClose) {
modal.beforeClosePromise = modal.onBeforeClose(modal.closeReason);
const shouldClose = await modal.beforeClosePromise;
modal.beforeClosePromise = null;
if (!shouldClose) {
return;
private getCloseFn<C extends ComponentType>(
modal: IModal<C>,
props?: ComponentProps<C>,
): [IHandle<C>["close"], IHandle<C>["finished"]] {
const deferred = defer<Parameters<ComponentProps<C>["onFinished"]>>();
return [
async (...args: Parameters<ComponentProps<C>["onFinished"]>): Promise<void> => {
if (modal.beforeClosePromise) {
await modal.beforeClosePromise;
} else if (modal.onBeforeClose) {
modal.beforeClosePromise = modal.onBeforeClose(modal.closeReason);
const shouldClose = await modal.beforeClosePromise;
modal.beforeClosePromise = undefined;
if (!shouldClose) {
return;
}
}
deferred.resolve(args);
if (props?.onFinished) props.onFinished.apply(null, args);
const i = this.modals.indexOf(modal);
if (i >= 0) {
this.modals.splice(i, 1);
}
}
deferred.resolve(args);
if (props && props.onFinished) props.onFinished.apply(null, args);
const i = this.modals.indexOf(modal);
if (i >= 0) {
this.modals.splice(i, 1);
}
if (this.priorityModal === modal) {
this.priorityModal = null;
if (this.priorityModal === modal) {
this.priorityModal = null;
// XXX: This is destructive
this.modals = [];
}
// XXX: This is destructive
this.modals = [];
}
if (this.staticModal === modal) {
this.staticModal = null;
if (this.staticModal === modal) {
this.staticModal = null;
// XXX: This is destructive
this.modals = [];
}
// XXX: This is destructive
this.modals = [];
}
this.reRender();
}, deferred.promise];
this.reRender();
},
deferred.promise,
];
}
/**
@ -236,15 +274,16 @@ export class ModalManager {
* @param {onBeforeClose} options.onBeforeClose a callback to decide whether to close the dialog
* @returns {object} Object with 'close' parameter being a function that will close the dialog
*/
public createDialogAsync<T extends any[]>(
prom: Promise<React.ComponentType>,
props?: IProps<T>,
public createDialogAsync<C extends ComponentType>(
prom: Promise<C>,
props?: ComponentProps<C>,
className?: string,
isPriorityModal = false,
isStaticModal = false,
options: IOptions<T> = {},
): IHandle<T> {
const { modal, closeDialog, onFinishedProm } = this.buildModal<T>(prom, props, className, options);
options: IOptions<C> = {},
): IHandle<C> {
const beforeModal = this.getCurrentModal();
const { modal, closeDialog, onFinishedProm } = this.buildModal<C>(prom, props, className, options);
if (isPriorityModal) {
// XXX: This is destructive
this.priorityModal = modal;
@ -256,28 +295,40 @@ export class ModalManager {
}
this.reRender();
this.emitIfChanged(beforeModal);
return {
close: closeDialog,
finished: onFinishedProm,
};
}
private appendDialogAsync<T extends any[]>(
prom: Promise<React.ComponentType>,
props?: IProps<T>,
private appendDialogAsync<C extends ComponentType>(
prom: Promise<C>,
props?: ComponentProps<C>,
className?: string,
): IHandle<T> {
const { modal, closeDialog, onFinishedProm } = this.buildModal<T>(prom, props, className, {});
): IHandle<C> {
const beforeModal = this.getCurrentModal();
const { modal, closeDialog, onFinishedProm } = this.buildModal<C>(prom, props, className, {});
this.modals.push(modal);
this.reRender();
this.emitIfChanged(beforeModal);
return {
close: closeDialog,
finished: onFinishedProm,
};
}
private onBackgroundClick = () => {
private emitIfChanged(beforeModal?: IModal<any>): void {
if (beforeModal !== this.getCurrentModal()) {
this.emit(ModalManagerEvent.Opened);
}
}
private onBackgroundClick = (): void => {
const modal = this.getCurrentModal();
if (!modal) {
return;
@ -288,14 +339,16 @@ export class ModalManager {
// so, pass the reason to close through a member variable
modal.closeReason = "backgroundClick";
modal.close();
modal.closeReason = null;
modal.closeReason = undefined;
};
private getCurrentModal(): IModal<any> {
return this.priorityModal ? this.priorityModal : (this.modals[0] || this.staticModal);
return this.priorityModal ? this.priorityModal : this.modals[0] || this.staticModal;
}
private async reRender() {
private async reRender(): Promise<void> {
// TODO: We should figure out how to remove this weird sleep. It also makes testing harder
//
// await next tick because sometimes ReactDOM can race with itself and cause the modal to wrongly stick around
await sleep(0);
@ -303,7 +356,7 @@ export class ModalManager {
// If there is no modal to render, make all of Element available
// to screen reader users again
dis.dispatch({
action: 'aria_unhide_main_app',
action: "aria_unhide_main_app",
});
ReactDOM.unmountComponentAtNode(ModalManager.getOrCreateContainer());
ReactDOM.unmountComponentAtNode(ModalManager.getOrCreateStaticContainer());
@ -314,19 +367,25 @@ export class ModalManager {
// so they won't be able to navigate into it and act on it using
// screen reader specific features
dis.dispatch({
action: 'aria_hide_main_app',
action: "aria_hide_main_app",
});
if (this.staticModal) {
const classes = classNames("mx_Dialog_wrapper mx_Dialog_staticWrapper", this.staticModal.className);
const staticDialog = (
<div className={classes}>
<div className="mx_Dialog">
{ this.staticModal.elem }
<TooltipProvider>
<div className={classes}>
<Glass className="mx_Dialog_border">
<div className="mx_Dialog">{this.staticModal.elem}</div>
</Glass>
<div
data-testid="dialog-background"
className="mx_Dialog_background mx_Dialog_staticBackground"
onClick={this.onBackgroundClick}
/>
</div>
<div className="mx_Dialog_background mx_Dialog_staticBackground" onClick={this.onBackgroundClick} />
</div>
</TooltipProvider>
);
ReactDOM.render(staticDialog, ModalManager.getOrCreateStaticContainer());
@ -342,12 +401,18 @@ export class ModalManager {
});
const dialog = (
<div className={classes}>
<div className="mx_Dialog">
{ modal.elem }
<TooltipProvider>
<div className={classes}>
<Glass className="mx_Dialog_border">
<div className="mx_Dialog">{modal.elem}</div>
</Glass>
<div
data-testid="dialog-background"
className="mx_Dialog_background"
onClick={this.onBackgroundClick}
/>
</div>
<div className="mx_Dialog_background" onClick={this.onBackgroundClick} />
</div>
</TooltipProvider>
);
setImmediate(() => ReactDOM.render(dialog, ModalManager.getOrCreateContainer()));

View file

@ -14,7 +14,7 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import React from "react";
import React, { Key, MutableRefObject, ReactElement, ReactFragment, ReactInstance, ReactPortal } from "react";
import ReactDom from "react-dom";
interface IChildProps {
@ -31,6 +31,12 @@ interface IProps {
// a list of state objects to apply to each child node in turn
startStyles: React.CSSProperties[];
innerRef?: MutableRefObject<any>;
}
function isReactElement(c: ReactElement | ReactFragment | ReactPortal): c is ReactElement {
return typeof c === "object" && "type" in c;
}
/**
@ -41,13 +47,13 @@ interface IProps {
* automatic positional animation, look at react-shuffle or similar libraries.
*/
export default class NodeAnimator extends React.Component<IProps> {
private nodes = {};
private children: { [key: string]: React.DetailedReactHTMLElement<any, HTMLElement> };
private nodes: Record<string, ReactInstance> = {};
private children: { [key: string]: ReactElement } = {};
public static defaultProps: Partial<IProps> = {
startStyles: [],
};
constructor(props: IProps) {
public constructor(props: IProps) {
super(props);
this.updateChildren(this.props.children);
@ -65,25 +71,25 @@ export default class NodeAnimator extends React.Component<IProps> {
*/
private applyStyles(node: HTMLElement, styles: React.CSSProperties): void {
Object.entries(styles).forEach(([property, value]) => {
node.style[property] = value;
node.style[property as keyof Omit<CSSStyleDeclaration, "length" | "parentRule">] = value;
});
}
private updateChildren(newChildren: React.ReactNode): void {
const oldChildren = this.children || {};
this.children = {};
React.Children.toArray(newChildren).forEach((c: any) => {
if (oldChildren[c.key]) {
const old = oldChildren[c.key];
const oldNode = ReactDom.findDOMNode(this.nodes[old.key]);
React.Children.toArray(newChildren).forEach((c) => {
if (!isReactElement(c)) return;
if (oldChildren[c.key!]) {
const old = oldChildren[c.key!];
const oldNode = ReactDom.findDOMNode(this.nodes[old.key!]);
if (oldNode && (oldNode as HTMLElement).style.left !== c.props.style.left) {
this.applyStyles(oldNode as HTMLElement, { left: c.props.style.left });
// console.log("translation: "+oldNode.style.left+" -> "+c.props.style.left);
}
// clone the old element with the props (and children) of the new element
// so prop updates are still received by the children.
this.children[c.key] = React.cloneElement(old, c.props, c.props.children);
this.children[c.key!] = React.cloneElement(old, c.props, c.props.children);
} else {
// new element. If we have a startStyle, use that as the style and go through
// the enter animations
@ -94,49 +100,38 @@ export default class NodeAnimator extends React.Component<IProps> {
if (startStyles.length > 0) {
const startStyle = startStyles[0];
newProps.style = startStyle;
// console.log("mounted@startstyle0: "+JSON.stringify(startStyle));
}
newProps.ref = ((n) => this.collectNode(
c.key, n, restingStyle,
));
newProps.ref = (n) => this.collectNode(c.key!, n, restingStyle);
this.children[c.key] = React.cloneElement(c, newProps);
this.children[c.key!] = React.cloneElement(c, newProps);
}
});
}
private collectNode(k: string, node: React.ReactInstance, restingStyle: React.CSSProperties): void {
if (
node &&
this.nodes[k] === undefined &&
this.props.startStyles.length > 0
) {
private collectNode(k: Key, node: React.ReactInstance, restingStyle: React.CSSProperties): void {
if (node && this.nodes[k] === undefined && this.props.startStyles.length > 0) {
const startStyles = this.props.startStyles;
const domNode = ReactDom.findDOMNode(node);
// start from startStyle 1: 0 is the one we gave it
// to start with, so now we animate 1 etc.
for (let i = 1; i < startStyles.length; ++i) {
this.applyStyles(domNode as HTMLElement, startStyles[i]);
// console.log("start:"
// JSON.stringify(startStyles[i]),
// );
}
// and then we animate to the resting state
setTimeout(() => {
window.setTimeout(() => {
this.applyStyles(domNode as HTMLElement, restingStyle);
}, 0);
// console.log("enter:",
// JSON.stringify(restingStyle));
}
this.nodes[k] = node;
if (this.props.innerRef) {
this.props.innerRef.current = node;
}
}
public render(): JSX.Element {
return (
<>{ Object.values(this.children) }</>
);
public render(): React.ReactNode {
return <>{Object.values(this.children)}</>;
}
}

View file

@ -17,26 +17,31 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import { MatrixEvent, MatrixEventEvent } from "matrix-js-sdk/src/models/event";
import { Room, RoomEvent } from "matrix-js-sdk/src/models/room";
import { ClientEvent } from "matrix-js-sdk/src/client";
import { logger } from "matrix-js-sdk/src/logger";
import { MsgType } from "matrix-js-sdk/src/@types/event";
import { M_LOCATION } from "matrix-js-sdk/src/@types/location";
import {
PermissionChanged as PermissionChangedEvent,
} from "@matrix-org/analytics-events/types/typescript/PermissionChanged";
import { ISyncStateData, SyncState } from "matrix-js-sdk/src/sync";
MatrixEvent,
MatrixEventEvent,
Room,
RoomEvent,
ClientEvent,
MsgType,
SyncState,
SyncStateData,
IRoomTimelineData,
M_LOCATION,
EventType,
} from "matrix-js-sdk/src/matrix";
import { logger } from "matrix-js-sdk/src/logger";
import { PermissionChanged as PermissionChangedEvent } from "@matrix-org/analytics-events/types/typescript/PermissionChanged";
import { MatrixClientPeg } from './MatrixClientPeg';
import { MatrixClientPeg } from "./MatrixClientPeg";
import { PosthogAnalytics } from "./PosthogAnalytics";
import SdkConfig from './SdkConfig';
import PlatformPeg from './PlatformPeg';
import * as TextForEvent from './TextForEvent';
import * as Avatar from './Avatar';
import dis from './dispatcher/dispatcher';
import { _t } from './languageHandler';
import Modal from './Modal';
import SdkConfig from "./SdkConfig";
import PlatformPeg from "./PlatformPeg";
import * as TextForEvent from "./TextForEvent";
import * as Avatar from "./Avatar";
import dis from "./dispatcher/dispatcher";
import { _t } from "./languageHandler";
import Modal from "./Modal";
import SettingsStore from "./settings/SettingsStore";
import { hideToast as hideNotificationsToast } from "./toasts/DesktopNotificationsToast";
import { SettingLevel } from "./settings/SettingLevel";
@ -47,11 +52,12 @@ import ErrorDialog from "./components/views/dialogs/ErrorDialog";
import LegacyCallHandler from "./LegacyCallHandler";
import VoipUserMapper from "./VoipUserMapper";
import { SdkContextClass } from "./contexts/SDKContext";
import { localNotificationsAreSilenced } from "./utils/notifications";
import { localNotificationsAreSilenced, createLocalNotificationSettingsIfNeeded } from "./utils/notifications";
import { getIncomingCallToastKey, IncomingCallToast } from "./toasts/IncomingCallToast";
import ToastStore from "./stores/ToastStore";
import { ElementCall } from "./models/Call";
import { createLocalNotificationSettingsIfNeeded } from './utils/notifications';
import { VoiceBroadcastChunkEventType, VoiceBroadcastInfoEventType } from "./voice-broadcast";
import { getSenderName } from "./utils/event/getSenderName";
import { stripPlainReply } from "./utils/Reply";
/*
* Dispatches:
@ -68,10 +74,10 @@ Override both the content body and the TextForEvent handler for specific msgtype
This is useful when the content body contains fallback text that would explain that the client can't handle a particular
type of tile.
*/
const msgTypeHandlers = {
const msgTypeHandlers: Record<string, (event: MatrixEvent) => string | null> = {
[MsgType.KeyVerificationRequest]: (event: MatrixEvent) => {
const name = (event.sender || {}).name;
return _t("%(name)s is requesting verification", { name });
return _t("notifier|m.key.verification.request", { name });
},
[M_LOCATION.name]: (event: MatrixEvent) => {
return TextForEvent.textForLocationEvent(event)();
@ -79,26 +85,45 @@ const msgTypeHandlers = {
[M_LOCATION.altName]: (event: MatrixEvent) => {
return TextForEvent.textForLocationEvent(event)();
},
[MsgType.Audio]: (event: MatrixEvent): string | null => {
if (event.getContent()?.[VoiceBroadcastChunkEventType]) {
if (event.getContent()?.[VoiceBroadcastChunkEventType]?.sequence === 1) {
// Show a notification for the first broadcast chunk.
// At this point a user received something to listen to.
return _t("notifier|io.element.voice_broadcast_chunk", { senderName: getSenderName(event) });
}
// Mute other broadcast chunks
return null;
}
return TextForEvent.textForEvent(event, MatrixClientPeg.safeGet());
},
};
export const Notifier = {
notifsByRoom: {},
class NotifierClass {
private notifsByRoom: Record<string, Notification[]> = {};
// A list of event IDs that we've received but need to wait until
// they're decrypted until we decide whether to notify for them
// or not
pendingEncryptedEventIds: [],
private pendingEncryptedEventIds: string[] = [];
notificationMessageForEvent: function(ev: MatrixEvent): string {
if (msgTypeHandlers.hasOwnProperty(ev.getContent().msgtype)) {
return msgTypeHandlers[ev.getContent().msgtype](ev);
private toolbarHidden?: boolean;
private isSyncing?: boolean;
public notificationMessageForEvent(ev: MatrixEvent): string | null {
const msgType = ev.getContent().msgtype;
if (msgType && msgTypeHandlers.hasOwnProperty(msgType)) {
return msgTypeHandlers[msgType](ev);
}
return TextForEvent.textForEvent(ev);
},
return TextForEvent.textForEvent(ev, MatrixClientPeg.safeGet());
}
_displayPopupNotification: function(ev: MatrixEvent, room: Room): void {
// XXX: exported for tests
public displayPopupNotification(ev: MatrixEvent, room: Room): void {
const plaf = PlatformPeg.get();
const cli = MatrixClientPeg.get();
const cli = MatrixClientPeg.safeGet();
if (!plaf) {
return;
}
@ -113,47 +138,54 @@ export const Notifier = {
let msg = this.notificationMessageForEvent(ev);
if (!msg) return;
let title;
let title: string | undefined;
if (!ev.sender || room.name === ev.sender.name) {
title = room.name;
// notificationMessageForEvent includes sender,
// but we already have the sender here
if (ev.getContent().body && !msgTypeHandlers.hasOwnProperty(ev.getContent().msgtype)) {
msg = ev.getContent().body;
// notificationMessageForEvent includes sender, but we already have the sender here
const msgType = ev.getContent().msgtype;
if (ev.getContent().body && (!msgType || !msgTypeHandlers.hasOwnProperty(msgType))) {
msg = stripPlainReply(ev.getContent().body);
}
} else if (ev.getType() === 'm.room.member') {
} else if (ev.getType() === "m.room.member") {
// context is all in the message here, we don't need
// to display sender info
title = room.name;
} else if (ev.sender) {
title = ev.sender.name + " (" + room.name + ")";
// notificationMessageForEvent includes sender,
// but we've just out sender in the title
if (ev.getContent().body && !msgTypeHandlers.hasOwnProperty(ev.getContent().msgtype)) {
msg = ev.getContent().body;
// notificationMessageForEvent includes sender, but we've just out sender in the title
const msgType = ev.getContent().msgtype;
if (ev.getContent().body && (!msgType || !msgTypeHandlers.hasOwnProperty(msgType))) {
msg = stripPlainReply(ev.getContent().body);
}
}
if (!title) return;
if (!this.isBodyEnabled()) {
msg = '';
msg = "";
}
let avatarUrl = null;
let avatarUrl: string | null = null;
if (ev.sender && !SettingsStore.getValue("lowBandwidth")) {
avatarUrl = Avatar.avatarUrlForMember(ev.sender, 40, 40, 'crop');
avatarUrl = Avatar.avatarUrlForMember(ev.sender, 40, 40, "crop");
}
const notif = plaf.displayNotification(title, msg, avatarUrl, room, ev);
const notif = plaf.displayNotification(title, msg!, avatarUrl, room, ev);
// if displayNotification returns non-null, the platform supports
// clearing notifications later, so keep track of this.
if (notif) {
if (this.notifsByRoom[ev.getRoomId()] === undefined) this.notifsByRoom[ev.getRoomId()] = [];
this.notifsByRoom[ev.getRoomId()].push(notif);
if (this.notifsByRoom[ev.getRoomId()!] === undefined) this.notifsByRoom[ev.getRoomId()!] = [];
this.notifsByRoom[ev.getRoomId()!].push(notif);
}
},
}
getSoundForRoom: function(roomId: string) {
public getSoundForRoom(roomId: string): {
url: string;
name: string;
type: string;
size: string;
} | null {
// We do no caching here because the SDK caches setting
// and the browser will cache the sound.
const content = SettingsStore.getValue("notificationSound", roomId);
@ -161,8 +193,8 @@ export const Notifier = {
return null;
}
if (!content.url) {
logger.warn(`${roomId} has custom notification sound event, but no url key`);
if (typeof content.url !== "string") {
logger.warn(`${roomId} has custom notification sound event, but no url string`);
return null;
}
@ -173,28 +205,36 @@ export const Notifier = {
// Ideally in here we could use MSC1310 to detect the type of file, and reject it.
const url = mediaFromMxc(content.url).srcHttp;
if (!url) {
logger.warn("Something went wrong when generating src http url for mxc");
return null;
}
return {
url: mediaFromMxc(content.url).srcHttp,
url,
name: content.name,
type: content.type,
size: content.size,
};
},
}
_playAudioNotification: async function(ev: MatrixEvent, room: Room): Promise<void> {
const cli = MatrixClientPeg.get();
// XXX: Exported for tests
public async playAudioNotification(ev: MatrixEvent, room: Room): Promise<void> {
const cli = MatrixClientPeg.safeGet();
if (localNotificationsAreSilenced(cli)) {
return;
}
const sound = this.getSoundForRoom(room.roomId);
logger.log(`Got sound ${sound && sound.name || "default"} for ${room.roomId}`);
logger.log(`Got sound ${(sound && sound.name) || "default"} for ${room.roomId}`);
try {
const selector =
document.querySelector<HTMLAudioElement>(sound ? `audio[src='${sound.url}']` : "#messageAudio");
const selector = document.querySelector<HTMLAudioElement>(
sound ? `audio[src='${sound.url}']` : "#messageAudio",
);
let audioElement = selector;
if (!selector) {
if (!audioElement) {
if (!sound) {
logger.error("No audio element or sound to play for notification");
return;
@ -209,39 +249,33 @@ export const Notifier = {
} catch (ex) {
logger.warn("Caught error when trying to fetch room notification sound:", ex);
}
},
}
start: function() {
// do not re-bind in the case of repeated call
this.boundOnEvent = this.boundOnEvent || this.onEvent.bind(this);
this.boundOnSyncStateChange = this.boundOnSyncStateChange || this.onSyncStateChange.bind(this);
this.boundOnRoomReceipt = this.boundOnRoomReceipt || this.onRoomReceipt.bind(this);
this.boundOnEventDecrypted = this.boundOnEventDecrypted || this.onEventDecrypted.bind(this);
MatrixClientPeg.get().on(ClientEvent.Event, this.boundOnEvent);
MatrixClientPeg.get().on(RoomEvent.Receipt, this.boundOnRoomReceipt);
MatrixClientPeg.get().on(MatrixEventEvent.Decrypted, this.boundOnEventDecrypted);
MatrixClientPeg.get().on(ClientEvent.Sync, this.boundOnSyncStateChange);
public start(): void {
const cli = MatrixClientPeg.safeGet();
cli.on(RoomEvent.Timeline, this.onEvent);
cli.on(RoomEvent.Receipt, this.onRoomReceipt);
cli.on(MatrixEventEvent.Decrypted, this.onEventDecrypted);
cli.on(ClientEvent.Sync, this.onSyncStateChange);
this.toolbarHidden = false;
this.isSyncing = false;
},
}
stop: function() {
public stop(): void {
if (MatrixClientPeg.get()) {
MatrixClientPeg.get().removeListener(ClientEvent.Event, this.boundOnEvent);
MatrixClientPeg.get().removeListener(RoomEvent.Receipt, this.boundOnRoomReceipt);
MatrixClientPeg.get().removeListener(MatrixEventEvent.Decrypted, this.boundOnEventDecrypted);
MatrixClientPeg.get().removeListener(ClientEvent.Sync, this.boundOnSyncStateChange);
MatrixClientPeg.get()!.removeListener(RoomEvent.Timeline, this.onEvent);
MatrixClientPeg.get()!.removeListener(RoomEvent.Receipt, this.onRoomReceipt);
MatrixClientPeg.get()!.removeListener(MatrixEventEvent.Decrypted, this.onEventDecrypted);
MatrixClientPeg.get()!.removeListener(ClientEvent.Sync, this.onSyncStateChange);
}
this.isSyncing = false;
},
}
supportsDesktopNotifications: function() {
const plaf = PlatformPeg.get();
return plaf && plaf.supportsNotifications();
},
public supportsDesktopNotifications(): boolean {
return PlatformPeg.get()?.supportsNotifications() ?? false;
}
setEnabled: function(enable: boolean, callback?: () => void) {
public setEnabled(enable: boolean, callback?: () => void): void {
const plaf = PlatformPeg.get();
if (!plaf) return;
@ -258,16 +292,18 @@ export const Notifier = {
if (enable) {
// Attempt to get permission from user
plaf.requestNotificationPermission().then((result) => {
if (result !== 'granted') {
if (result !== "granted") {
// The permission request was dismissed or denied
// TODO: Support alternative branding in messaging
const brand = SdkConfig.get().brand;
const description = result === 'denied'
? _t('%(brand)s does not have permission to send you notifications - ' +
'please check your browser settings', { brand })
: _t('%(brand)s was not given permission to send notifications - please try again', { brand });
const description =
result === "denied"
? _t("settings|notifications|error_permissions_denied", { brand })
: _t("settings|notifications|error_permissions_missing", {
brand,
});
Modal.createDialog(ErrorDialog, {
title: _t('Unable to enable Notifications'),
title: _t("settings|notifications|error_title"),
description,
});
return;
@ -299,31 +335,30 @@ export const Notifier = {
// set the notifications_hidden flag, as the user has knowingly interacted
// with the setting we shouldn't nag them any further
this.setPromptHidden(true);
},
}
isEnabled: function() {
public isEnabled(): boolean {
return this.isPossible() && SettingsStore.getValue("notificationsEnabled");
},
}
isPossible: function() {
public isPossible(): boolean {
const plaf = PlatformPeg.get();
if (!plaf) return false;
if (!plaf.supportsNotifications()) return false;
if (!plaf?.supportsNotifications()) return false;
if (!plaf.maySendNotifications()) return false;
return true; // possible, but not necessarily enabled
},
}
isBodyEnabled: function() {
public isBodyEnabled(): boolean {
return this.isEnabled() && SettingsStore.getValue("notificationBodyEnabled");
},
}
isAudioEnabled: function() {
public isAudioEnabled(): boolean {
// We don't route Audio via the HTML Notifications API so it is possible regardless of other things
return SettingsStore.getValue("audioNotificationsEnabled");
},
}
setPromptHidden: function(hidden: boolean, persistent = true) {
public setPromptHidden(hidden: boolean, persistent = true): void {
this.toolbarHidden = hidden;
hideNotificationsToast();
@ -332,28 +367,34 @@ export const Notifier = {
if (persistent && global.localStorage) {
global.localStorage.setItem("notifications_hidden", String(hidden));
}
},
}
shouldShowPrompt: function() {
public shouldShowPrompt(): boolean {
const client = MatrixClientPeg.get();
if (!client) {
return false;
}
const isGuest = client.isGuest();
return !isGuest && this.supportsDesktopNotifications() && !isPushNotifyDisabled() &&
!this.isEnabled() && !this._isPromptHidden();
},
return (
!isGuest &&
this.supportsDesktopNotifications() &&
!isPushNotifyDisabled() &&
!this.isEnabled() &&
!this.isPromptHidden()
);
}
_isPromptHidden: function() {
private isPromptHidden(): boolean {
// Check localStorage for any such meta data
if (global.localStorage) {
return global.localStorage.getItem("notifications_hidden") === "true";
}
return this.toolbarHidden;
},
return !!this.toolbarHidden;
}
onSyncStateChange: function(state: SyncState, prevState?: SyncState, data?: ISyncStateData) {
// XXX: Exported for tests
public onSyncStateChange = (state: SyncState, prevState: SyncState | null, data?: SyncStateData): void => {
if (state === SyncState.Syncing) {
this.isSyncing = true;
} else if (state === SyncState.Stopped || state === SyncState.Error) {
@ -361,24 +402,30 @@ export const Notifier = {
}
// wait for first non-cached sync to complete
if (
![SyncState.Stopped, SyncState.Error].includes(state) &&
!data?.fromCache
) {
createLocalNotificationSettingsIfNeeded(MatrixClientPeg.get());
if (![SyncState.Stopped, SyncState.Error].includes(state) && !data?.fromCache) {
createLocalNotificationSettingsIfNeeded(MatrixClientPeg.safeGet());
}
},
};
onEvent: function(ev: MatrixEvent) {
private onEvent = (
ev: MatrixEvent,
room: Room | undefined,
toStartOfTimeline: boolean | undefined,
removed: boolean,
data: IRoomTimelineData,
): void => {
if (removed) return; // only notify for new events, not removed ones
if (!data.liveEvent || !!toStartOfTimeline) return; // only notify for new things, not old.
if (!this.isSyncing) return; // don't alert for any messages initially
if (ev.getSender() === MatrixClientPeg.get().getUserId()) return;
if (ev.getSender() === MatrixClientPeg.safeGet().getUserId()) return;
if (data.timeline.getTimelineSet().threadListType !== null) return; // Ignore events on the thread list generated timelines
MatrixClientPeg.get().decryptEventIfNeeded(ev);
MatrixClientPeg.safeGet().decryptEventIfNeeded(ev);
// If it's an encrypted event and the type is still 'm.room.encrypted',
// it hasn't yet been decrypted, so wait until it is.
if (ev.isBeingDecrypted() || ev.isDecryptionFailure()) {
this.pendingEncryptedEventIds.push(ev.getId());
this.pendingEncryptedEventIds.push(ev.getId()!);
// don't let the list fill up indefinitely
while (this.pendingEncryptedEventIds.length > MAX_PENDING_ENCRYPTED) {
this.pendingEncryptedEventIds.shift();
@ -386,22 +433,22 @@ export const Notifier = {
return;
}
this._evaluateEvent(ev);
},
this.evaluateEvent(ev);
};
onEventDecrypted: function(ev: MatrixEvent) {
private onEventDecrypted = (ev: MatrixEvent): void => {
// 'decrypted' means the decryption process has finished: it may have failed,
// in which case it might decrypt soon if the keys arrive
if (ev.isDecryptionFailure()) return;
const idx = this.pendingEncryptedEventIds.indexOf(ev.getId());
const idx = this.pendingEncryptedEventIds.indexOf(ev.getId()!);
if (idx === -1) return;
this.pendingEncryptedEventIds.splice(idx, 1);
this._evaluateEvent(ev);
},
this.evaluateEvent(ev);
};
onRoomReceipt: function(ev: MatrixEvent, room: Room) {
private onRoomReceipt = (ev: MatrixEvent, room: Room): void => {
if (room.getUnreadNotificationCount() === 0) {
// ideally we would clear each notification when it was read,
// but we have no way, given a read receipt, to know whether
@ -417,10 +464,13 @@ export const Notifier = {
}
delete this.notifsByRoom[room.roomId];
}
},
};
_evaluateEvent: function(ev: MatrixEvent) {
let roomId = ev.getRoomId();
// XXX: exported for tests
public evaluateEvent(ev: MatrixEvent): void {
// Mute notifications for broadcast info events
if (ev.getType() === VoiceBroadcastInfoEventType) return;
let roomId = ev.getRoomId()!;
if (LegacyCallHandler.instance.getSupportsVirtualRooms()) {
// Attempt to translate a virtual room to a native one
const nativeRoomId = VoipUserMapper.sharedInstance().nativeRoomForVirtualRoom(roomId);
@ -428,61 +478,73 @@ export const Notifier = {
roomId = nativeRoomId;
}
}
const room = MatrixClientPeg.get().getRoom(roomId);
const room = MatrixClientPeg.safeGet().getRoom(roomId);
if (!room) {
// e.g we are in the process of joining a room.
// Seen in the Playwright lazy-loading test.
return;
}
const actions = MatrixClientPeg.get().getPushActionsForEvent(ev);
const actions = MatrixClientPeg.safeGet().getPushActionsForEvent(ev);
if (actions?.notify) {
this._performCustomEventHandling(ev);
this.performCustomEventHandling(ev);
const store = SdkContextClass.instance.roomViewStore;
const isViewingRoom = store.getRoomId() === room.roomId;
const threadId: string | undefined = ev.getId() !== ev.threadRootId
? ev.threadRootId
: undefined;
const threadId: string | undefined = ev.getId() !== ev.threadRootId ? ev.threadRootId : undefined;
const isViewingThread = store.getThreadId() === threadId;
const isViewingEventTimeline = isViewingRoom && (!threadId || isViewingThread);
if (isViewingEventTimeline &&
UserActivity.sharedInstance().userActiveRecently() &&
!Modal.hasDialogs()
) {
if (isViewingEventTimeline && UserActivity.sharedInstance().userActiveRecently() && !Modal.hasDialogs()) {
// don't bother notifying as user was recently active in this room
return;
}
if (this.isEnabled()) {
this._displayPopupNotification(ev, room);
this.displayPopupNotification(ev, room);
}
if (actions.tweaks.sound && this.isAudioEnabled()) {
PlatformPeg.get().loudNotification(ev, room);
this._playAudioNotification(ev, room);
PlatformPeg.get()?.loudNotification(ev, room);
this.playAudioNotification(ev, room);
}
}
},
}
/**
* Some events require special handling such as showing in-app toasts
*/
_performCustomEventHandling: function(ev: MatrixEvent) {
private performCustomEventHandling(ev: MatrixEvent): void {
if (
ElementCall.CALL_EVENT_TYPE.names.includes(ev.getType())
&& SettingsStore.getValue("feature_group_calls")
EventType.CallNotify === ev.getType() &&
SettingsStore.getValue("feature_group_calls") &&
(ev.getAge() ?? 0) < 10000
) {
const content = ev.getContent();
const roomId = ev.getRoomId();
if (typeof content.call_id !== "string") {
logger.warn("Received malformatted CallNotify event. Did not contain 'call_id' of type 'string'");
return;
}
if (!roomId) {
logger.warn("Could not get roomId for CallNotify event");
return;
}
ToastStore.sharedInstance().addOrReplaceToast({
key: getIncomingCallToastKey(ev.getStateKey()),
key: getIncomingCallToastKey(content.call_id, roomId),
priority: 100,
component: IncomingCallToast,
bodyClassName: "mx_IncomingCallToast",
props: { callEvent: ev },
props: { notifyEvent: ev },
});
}
},
};
}
}
if (!window.mxNotifier) {
window.mxNotifier = Notifier;
window.mxNotifier = new NotifierClass();
}
export default window.mxNotifier;
export const Notifier: NotifierClass = window.mxNotifier;

View file

@ -15,9 +15,9 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import { createClient, IRequestTokenResponse, MatrixClient } from 'matrix-js-sdk/src/matrix';
import { createClient, IRequestTokenResponse, MatrixClient } from "matrix-js-sdk/src/matrix";
import { _t } from './languageHandler';
import { _t } from "./languageHandler";
/**
* Allows a user to reset their password on a homeserver.
@ -29,16 +29,17 @@ import { _t } from './languageHandler';
export default class PasswordReset {
private client: MatrixClient;
private clientSecret: string;
private password: string;
private sessionId: string;
private logoutDevices: boolean;
private password = "";
private sessionId = "";
private logoutDevices = false;
private sendAttempt = 0;
/**
* Configure the endpoints for password resetting.
* @param {string} homeserverUrl The URL to the HS which has the account to reset.
* @param {string} identityUrl The URL to the IS which has linked the email -> mxid mapping.
*/
constructor(homeserverUrl: string, identityUrl: string) {
public constructor(homeserverUrl: string, identityUrl: string) {
this.client = createClient({
baseUrl: homeserverUrl,
idBaseUrl: identityUrl,
@ -47,31 +48,34 @@ export default class PasswordReset {
}
/**
* Attempt to reset the user's password. This will trigger a side-effect of
* sending an email to the provided email address.
* @param {string} emailAddress The email address
* @param {string} newPassword The new password for the account.
* @param {boolean} logoutDevices Should all devices be signed out after the reset? Defaults to `true`.
* @return {Promise} Resolves when the email has been sent. Then call checkEmailLinkClicked().
* Request a password reset token.
* This will trigger a side-effect of sending an email to the provided email address.
*/
public resetPassword(
emailAddress: string,
newPassword: string,
logoutDevices = true,
): Promise<IRequestTokenResponse> {
this.password = newPassword;
public requestResetToken(emailAddress: string): Promise<IRequestTokenResponse> {
this.sendAttempt++;
return this.client.requestPasswordEmailToken(emailAddress, this.clientSecret, this.sendAttempt).then(
(res) => {
this.sessionId = res.sid;
return res;
},
function (err) {
if (err.errcode === "M_THREEPID_NOT_FOUND") {
err.message = _t("auth|reset_password_email_not_found_title");
} else if (err.httpStatus) {
err.message = err.message + ` (Status ${err.httpStatus})`;
}
throw err;
},
);
}
public setLogoutDevices(logoutDevices: boolean): void {
this.logoutDevices = logoutDevices;
return this.client.requestPasswordEmailToken(emailAddress, this.clientSecret, 1).then((res) => {
this.sessionId = res.sid;
return res;
}, function(err) {
if (err.errcode === 'M_THREEPID_NOT_FOUND') {
err.message = _t('This email address was not found');
} else if (err.httpStatus) {
err.message = err.message + ` (Status ${err.httpStatus})`;
}
throw err;
});
}
public async setNewPassword(password: string): Promise<void> {
this.password = password;
await this.checkEmailLinkClicked();
}
/**
@ -88,22 +92,25 @@ export default class PasswordReset {
};
try {
await this.client.setPassword({
// Note: Though this sounds like a login type for identity servers only, it
// has a dual purpose of being used for homeservers too.
type: "m.login.email.identity",
// TODO: Remove `threepid_creds` once servers support proper UIA
// See https://github.com/matrix-org/synapse/issues/5665
// See https://github.com/matrix-org/matrix-doc/issues/2220
threepid_creds: creds,
threepidCreds: creds,
}, this.password, this.logoutDevices);
} catch (err) {
await this.client.setPassword(
{
// Note: Though this sounds like a login type for identity servers only, it
// has a dual purpose of being used for homeservers too.
type: "m.login.email.identity",
// TODO: Remove `threepid_creds` once servers support proper UIA
// See https://github.com/matrix-org/synapse/issues/5665
// See https://github.com/matrix-org/matrix-doc/issues/2220
threepid_creds: creds,
threepidCreds: creds,
},
this.password,
this.logoutDevices,
);
} catch (err: any) {
if (err.httpStatus === 401) {
err.message = _t('Failed to verify email address: make sure you clicked the link in the email');
err.message = _t("settings|general|add_email_failed_verification");
} else if (err.httpStatus === 404) {
err.message =
_t('Your email address does not appear to be associated with a Matrix ID on this Homeserver.');
err.message = _t("auth|reset_password_email_not_associated");
} else if (err.httpStatus) {
err.message += ` (Status ${err.httpStatus})`;
}
@ -111,4 +118,3 @@ export default class PasswordReset {
}
}
}

View file

@ -32,13 +32,13 @@ import { PlatformSetPayload } from "./dispatcher/payloads/PlatformSetPayload";
* object.
*/
export class PlatformPeg {
private platform: BasePlatform = null;
private platform: BasePlatform | null = null;
/**
* Returns the current Platform object for the application.
* This should be an instance of a class extending BasePlatform.
*/
public get() {
public get(): BasePlatform | null {
return this.platform;
}
@ -46,7 +46,7 @@ export class PlatformPeg {
* Sets the current platform handler object to use for the application.
* @param {BasePlatform} platform an instance of a class extending BasePlatform.
*/
public set(platform: BasePlatform) {
public set(platform: BasePlatform): void {
this.platform = platform;
defaultDispatcher.dispatch<PlatformSetPayload>({
action: Action.PlatformSet,

View file

@ -14,17 +14,22 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import posthog, { PostHog } from 'posthog-js';
import { MatrixClient } from "matrix-js-sdk/src/client";
import posthog, { CaptureOptions, PostHog, Properties } from "posthog-js";
import { MatrixClient } from "matrix-js-sdk/src/matrix";
import { logger } from "matrix-js-sdk/src/logger";
import { UserProperties } from "@matrix-org/analytics-events/types/typescript/UserProperties";
import { Signup } from '@matrix-org/analytics-events/types/typescript/Signup';
import { Signup } from "@matrix-org/analytics-events/types/typescript/Signup";
import PlatformPeg from './PlatformPeg';
import SdkConfig from './SdkConfig';
import PlatformPeg from "./PlatformPeg";
import SdkConfig from "./SdkConfig";
import { MatrixClientPeg } from "./MatrixClientPeg";
import SettingsStore from "./settings/SettingsStore";
import { ScreenName } from "./PosthogTrackers";
import { ActionPayload } from "./dispatcher/payloads";
import { Action } from "./dispatcher/actions";
import { SettingUpdatedPayload } from "./dispatcher/payloads/SettingUpdatedPayload";
import dis from "./dispatcher/dispatcher";
import { Layout } from "./settings/enums/Layout";
/* Posthog analytics tracking.
*
@ -47,33 +52,39 @@ export interface IPosthogEvent {
eventName: string;
// do not allow these to be sent manually, we enqueue them all for caching purposes
"$set"?: void;
"$set_once"?: void;
}
export interface IPostHogEventOptions {
timestamp?: Date;
$set?: void;
$set_once?: void;
}
export enum Anonymity {
Disabled,
Anonymous,
Pseudonymous
Pseudonymous,
}
const whitelistedScreens = new Set([
"register", "login", "forgot_password", "soft_logout", "new", "settings", "welcome", "home", "start", "directory",
"start_sso", "start_cas", "complete_security", "post_registration", "room", "user",
"register",
"login",
"forgot_password",
"soft_logout",
"new",
"settings",
"welcome",
"home",
"start",
"directory",
"start_sso",
"start_cas",
"complete_security",
"post_registration",
"room",
"user",
]);
export function getRedactedCurrentLocation(
origin: string,
hash: string,
pathname: string,
): string {
export function getRedactedCurrentLocation(origin: string, hash: string, pathname: string): string {
// Redact PII from the current location.
// For known screens, assumes a URL structure of /<screen name>/might/be/pii
if (origin.startsWith('file://')) {
if (origin.startsWith("file://")) {
pathname = "/<redacted_file_scheme_url>/";
}
@ -117,12 +128,16 @@ export class PosthogAnalytics {
private anonymity = Anonymity.Disabled;
// set true during the constructor if posthog config is present, otherwise false
private readonly enabled: boolean = false;
private static _instance = null;
private platformSuperProperties = {};
private static ANALYTICS_EVENT_TYPE = "im.vector.analytics";
private static _instance: PosthogAnalytics | null = null;
private platformSuperProperties: Properties = {};
public static readonly ANALYTICS_EVENT_TYPE = "im.vector.analytics";
private propertiesForNextEvent: Partial<Record<"$set" | "$set_once", UserProperties>> = {};
private userPropertyCache: UserProperties = {};
private authenticationType: Signup["authenticationType"] = "Other";
private watchSettingRef?: string;
// Will be set when the matrixClient is passed to the analytics object (e.g. on login).
private currentCryptoBackend?: "Rust" | "Legacy" = undefined;
public static get instance(): PosthogAnalytics {
if (!this._instance) {
@ -131,7 +146,7 @@ export class PosthogAnalytics {
return this._instance;
}
constructor(private readonly posthog: PostHog) {
public constructor(private readonly posthog: PostHog) {
const posthogConfig = SdkConfig.getObject("posthog");
if (posthogConfig) {
this.posthog.init(posthogConfig.get("project_api_key"), {
@ -153,12 +168,46 @@ export class PosthogAnalytics {
} else {
this.enabled = false;
}
dis.register(this.onAction);
SettingsStore.monitorSetting("layout", null);
SettingsStore.monitorSetting("useCompactLayout", null);
this.onLayoutUpdated();
this.updateCryptoSuperProperty();
}
private onLayoutUpdated = (): void => {
let layout: UserProperties["WebLayout"];
switch (SettingsStore.getValue("layout")) {
case Layout.IRC:
layout = "IRC";
break;
case Layout.Bubble:
layout = "Bubble";
break;
case Layout.Group:
layout = SettingsStore.getValue("useCompactLayout") ? "Compact" : "Group";
break;
}
// This is known to clobber other devices but is a good enough solution
// to get an idea of how much use each layout gets.
this.setProperty("WebLayout", layout);
};
private onAction = (payload: ActionPayload): void => {
if (payload.action !== Action.SettingUpdated) return;
const settingsPayload = payload as SettingUpdatedPayload;
if (["layout", "useCompactLayout"].includes(settingsPayload.settingName)) {
this.onLayoutUpdated();
}
};
// we persist the last `$screen_name` and send it for all events until it is replaced
private lastScreen: ScreenName = "Loading";
private sanitizeProperties = (properties: posthog.Properties, eventName: string): posthog.Properties => {
private sanitizeProperties = (properties: Properties, eventName: string): Properties => {
// Callback from posthog to sanitize properties before sending them to the server.
//
// Here we sanitize posthog's built in properties which leak PII e.g. url reporting.
@ -172,29 +221,29 @@ export class PosthogAnalytics {
if (this.anonymity == Anonymity.Anonymous) {
// drop referrer information for anonymous users
properties['$referrer'] = null;
properties['$referring_domain'] = null;
properties['$initial_referrer'] = null;
properties['$initial_referring_domain'] = null;
properties["$referrer"] = null;
properties["$referring_domain"] = null;
properties["$initial_referrer"] = null;
properties["$initial_referring_domain"] = null;
// drop device ID, which is a UUID persisted in local storage
properties['$device_id'] = null;
properties["$device_id"] = null;
}
return properties;
};
private registerSuperProperties(properties: posthog.Properties) {
private registerSuperProperties(properties: Properties): void {
if (this.enabled) {
this.posthog.register(properties);
}
}
private static async getPlatformProperties(): Promise<PlatformProperties> {
private static async getPlatformProperties(): Promise<Partial<PlatformProperties>> {
const platform = PlatformPeg.get();
let appVersion;
let appVersion: string | undefined;
try {
appVersion = await platform.getAppVersion();
appVersion = await platform?.getAppVersion();
} catch (e) {
// this happens if no version is set i.e. in dev
appVersion = "unknown";
@ -202,24 +251,18 @@ export class PosthogAnalytics {
return {
appVersion,
appPlatform: platform.getHumanReadableName(),
appPlatform: platform?.getHumanReadableName(),
};
}
// eslint-disable-nextline no-unused-varsx
private capture(eventName: string, properties: posthog.Properties, options?: IPostHogEventOptions) {
// eslint-disable-nextline no-unused-vars
private capture(eventName: string, properties: Properties, options?: CaptureOptions): void {
if (!this.enabled) {
return;
}
const { origin, hash, pathname } = window.location;
properties["redactedCurrentUrl"] = getRedactedCurrentLocation(origin, hash, pathname);
this.posthog.capture(
eventName,
{ ...this.propertiesForNextEvent, ...properties },
// TODO: Uncomment below once https://github.com/PostHog/posthog-js/pull/391
// gets merged
/* options as any, */ // No proper type definition in the posthog library
);
this.posthog.capture(eventName, { ...this.propertiesForNextEvent, ...properties }, options);
this.propertiesForNextEvent = {};
}
@ -239,10 +282,12 @@ export class PosthogAnalytics {
this.registerSuperProperties(this.platformSuperProperties);
}
this.anonymity = anonymity;
// update anyhow, no-op if not enabled or Disabled.
this.updateCryptoSuperProperty();
}
private static getRandomAnalyticsId(): string {
return [...crypto.getRandomValues(new Uint8Array(16))].map((c) => c.toString(16)).join('');
return [...crypto.getRandomValues(new Uint8Array(16))].map((c) => c.toString(16)).join("");
}
public async identifyUser(client: MatrixClient, analyticsIdGenerator: () => string): Promise<void> {
@ -259,14 +304,24 @@ export class PosthogAnalytics {
// until the next time account data is refreshed and this function is called (most likely on next
// page load). This will happen pretty infrequently, so we can tolerate the possibility.
analyticsID = analyticsIdGenerator();
await client.setAccountData(PosthogAnalytics.ANALYTICS_EVENT_TYPE,
Object.assign({ id: analyticsID }, accountData));
await client.setAccountData(
PosthogAnalytics.ANALYTICS_EVENT_TYPE,
Object.assign({ id: analyticsID }, accountData),
);
}
if (this.posthog.get_distinct_id() === analyticsID) {
// No point identifying again
return;
}
if (this.posthog.persistence?.get_user_state() === "identified") {
// Analytics ID has changed, reset as Posthog will refuse to merge in this case
this.posthog.reset();
}
this.posthog.identify(analyticsID);
} catch (e) {
// The above could fail due to network requests, but not essential to starting the application,
// so swallow it.
logger.log("Unable to identify user for tracking" + e.toString());
logger.log("Unable to identify user for tracking", e);
}
}
}
@ -279,13 +334,11 @@ export class PosthogAnalytics {
if (this.enabled) {
this.posthog.reset();
}
if (this.watchSettingRef) SettingsStore.unwatchSetting(this.watchSettingRef);
this.setAnonymity(Anonymity.Disabled);
}
public trackEvent<E extends IPosthogEvent>(
{ eventName, ...properties }: E,
options?: IPostHogEventOptions,
): void {
public trackEvent<E extends IPosthogEvent>({ eventName, ...properties }: E, options?: CaptureOptions): void {
if (this.anonymity == Anonymity.Disabled || this.anonymity == Anonymity.Anonymous) return;
this.capture(eventName, properties, options);
}
@ -320,23 +373,45 @@ export class PosthogAnalytics {
this.registerSuperProperties(this.platformSuperProperties);
}
public async updateAnonymityFromSettings(pseudonymousOptIn: boolean): Promise<void> {
private updateCryptoSuperProperty(): void {
if (!this.enabled || this.anonymity === Anonymity.Disabled) return;
// Update super property for cryptoSDK in posthog.
// This property will be subsequently passed in every event.
if (this.currentCryptoBackend) {
this.registerSuperProperties({ cryptoSDK: this.currentCryptoBackend });
}
}
public async updateAnonymityFromSettings(client: MatrixClient, pseudonymousOptIn: boolean): Promise<void> {
// Temporary until we have migration code to switch crypto sdk.
if (client.getCrypto()) {
const cryptoVersion = client.getCrypto()!.getVersion();
// version for rust is something like "Rust SDK 0.6.0 (9c6b550), Vodozemac 0.5.0"
// for legacy it will be 'Olm x.x.x"
if (cryptoVersion.includes("Rust SDK")) {
this.currentCryptoBackend = "Rust";
} else {
this.currentCryptoBackend = "Legacy";
}
}
// Update this.anonymity based on the user's analytics opt-in settings
const anonymity = pseudonymousOptIn ? Anonymity.Pseudonymous : Anonymity.Disabled;
this.setAnonymity(anonymity);
if (anonymity === Anonymity.Pseudonymous) {
await this.identifyUser(MatrixClientPeg.get(), PosthogAnalytics.getRandomAnalyticsId);
await this.identifyUser(client, PosthogAnalytics.getRandomAnalyticsId);
if (MatrixClientPeg.currentUserIsJustRegistered()) {
this.trackNewUserEvent();
}
}
if (anonymity !== Anonymity.Disabled) {
await PosthogAnalytics.instance.updatePlatformSuperProperties();
await this.updatePlatformSuperProperties();
this.updateCryptoSuperProperty();
}
}
public startListeningToSettingsChanges(): void {
public startListeningToSettingsChanges(client: MatrixClient): void {
// Listen to account data changes from sync so we can observe changes to relevant flags and update.
// This is called -
// * On page load, when the account data is first received by sync
@ -345,10 +420,13 @@ export class PosthogAnalytics {
// * When the user changes their preferences on this device
// Note that for new accounts, pseudonymousAnalyticsOptIn won't be set, so updateAnonymityFromSettings
// won't be called (i.e. this.anonymity will be left as the default, until the setting changes)
SettingsStore.watchSetting("pseudonymousAnalyticsOptIn", null,
this.watchSettingRef = SettingsStore.watchSetting(
"pseudonymousAnalyticsOptIn",
null,
(originalSettingName, changedInRoomId, atLevel, newValueAtLevel, newValue) => {
this.updateAnonymityFromSettings(!!newValue);
});
this.updateAnonymityFromSettings(client, !!newValue);
},
);
}
public setAuthenticationType(authenticationType: Signup["authenticationType"]): void {
@ -360,15 +438,18 @@ export class PosthogAnalytics {
// that we want to accumulate before the user has given consent
// All other scenarios should not track a user before they have given
// explicit consent that they are ok with their analytics data being collected
const options: IPostHogEventOptions = {};
const registrationTime = parseInt(window.localStorage.getItem("mx_registration_time"), 10);
const options: CaptureOptions = {};
const registrationTime = parseInt(window.localStorage.getItem("mx_registration_time")!, 10);
if (!isNaN(registrationTime)) {
options.timestamp = new Date(registrationTime);
}
return this.trackEvent<Signup>({
eventName: "Signup",
authenticationType: this.authenticationType,
}, options);
return this.trackEvent<Signup>(
{
eventName: "Signup",
authenticationType: this.authenticationType,
},
options,
);
}
}

View file

@ -27,6 +27,7 @@ export type InteractionName = InteractionEvent["name"];
const notLoggedInMap: Record<Exclude<Views, Views.LOGGED_IN>, ScreenName> = {
[Views.LOADING]: "Loading",
[Views.CONFIRM_LOCK_THEFT]: "ConfirmStartup",
[Views.WELCOME]: "Welcome",
[Views.LOGIN]: "Login",
[Views.REGISTER]: "Register",
@ -35,6 +36,7 @@ const notLoggedInMap: Record<Exclude<Views, Views.LOGGED_IN>, ScreenName> = {
[Views.COMPLETE_SECURITY]: "CompleteSecurity",
[Views.E2E_SETUP]: "E2ESetup",
[Views.SOFT_LOGOUT]: "SoftLogout",
[Views.LOCK_STOLEN]: "SessionLockStolen",
};
const loggedInPageTypeMap: Record<PageType, ScreenName> = {
@ -54,8 +56,8 @@ export default class PosthogTrackers {
}
private view: Views = Views.LOADING;
private pageType?: PageType = null;
private override?: ScreenName = null;
private pageType?: PageType;
private override?: ScreenName;
public trackPageChange(view: Views, pageType: PageType | undefined, durationMs: number): void {
this.view = view;
@ -65,9 +67,8 @@ export default class PosthogTrackers {
}
private trackPage(durationMs?: number): void {
const screenName = this.view === Views.LOGGED_IN
? loggedInPageTypeMap[this.pageType]
: notLoggedInMap[this.view];
const screenName =
this.view === Views.LOGGED_IN ? loggedInPageTypeMap[this.pageType!] : notLoggedInMap[this.view];
PosthogAnalytics.instance.trackEvent<ScreenEvent>({
eventName: "$pageview",
$current_url: screenName,
@ -86,11 +87,11 @@ export default class PosthogTrackers {
public clearOverride(screenName: ScreenName): void {
if (screenName !== this.override) return;
this.override = null;
this.override = undefined;
this.trackPage();
}
public static trackInteraction(name: InteractionName, ev?: SyntheticEvent, index?: number): void {
public static trackInteraction(name: InteractionName, ev?: SyntheticEvent | Event, index?: number): void {
let interactionType: InteractionEvent["interactionType"];
if (ev?.type === "click") {
interactionType = "Pointer";
@ -108,20 +109,20 @@ export default class PosthogTrackers {
}
export class PosthogScreenTracker extends PureComponent<{ screenName: ScreenName }> {
componentDidMount() {
public componentDidMount(): void {
PosthogTrackers.instance.trackOverride(this.props.screenName);
}
componentDidUpdate() {
public componentDidUpdate(): void {
// We do not clear the old override here so that we do not send the non-override screen as a transition
PosthogTrackers.instance.trackOverride(this.props.screenName);
}
componentWillUnmount() {
public componentWillUnmount(): void {
PosthogTrackers.instance.clearOverride(this.props.screenName);
}
render() {
public render(): React.ReactNode {
return null; // no need to render anything, we just need to hook into the React lifecycle
}
}

View file

@ -17,46 +17,43 @@ limitations under the License.
*/
import { logger } from "matrix-js-sdk/src/logger";
import { SetPresence } from "matrix-js-sdk/src/matrix";
import { MatrixClientPeg } from "./MatrixClientPeg";
import dis from "./dispatcher/dispatcher";
import Timer from './utils/Timer';
import Timer from "./utils/Timer";
import { ActionPayload } from "./dispatcher/payloads";
// Time in ms after that a user is considered as unavailable/away
const UNAVAILABLE_TIME_MS = 3 * 60 * 1000; // 3 mins
enum State {
Online = "online",
Offline = "offline",
Unavailable = "unavailable",
}
class Presence {
private unavailableTimer: Timer = null;
private dispatcherRef: string = null;
private state: State = null;
private unavailableTimer: Timer | null = null;
private dispatcherRef: string | null = null;
private state: SetPresence | null = null;
/**
* Start listening the user activity to evaluate his presence state.
* Any state change will be sent to the homeserver.
*/
public async start() {
public async start(): Promise<void> {
this.unavailableTimer = new Timer(UNAVAILABLE_TIME_MS);
// the user_activity_start action starts the timer
this.dispatcherRef = dis.register(this.onAction);
while (this.unavailableTimer) {
try {
await this.unavailableTimer.finished();
this.setState(State.Unavailable);
} catch (e) { /* aborted, stop got called */ }
this.setState(SetPresence.Unavailable);
} catch (e) {
/* aborted, stop got called */
}
}
}
/**
* Stop tracking user activity
*/
public stop() {
public stop(): void {
if (this.dispatcherRef) {
dis.unregister(this.dispatcherRef);
this.dispatcherRef = null;
@ -71,14 +68,14 @@ class Presence {
* Get the current presence state.
* @returns {string} the presence state (see PRESENCE enum)
*/
public getState() {
public getState(): SetPresence | null {
return this.state;
}
private onAction = (payload: ActionPayload) => {
if (payload.action === 'user_activity') {
this.setState(State.Online);
this.unavailableTimer.restart();
private onAction = (payload: ActionPayload): void => {
if (payload.action === "user_activity") {
this.setState(SetPresence.Online);
this.unavailableTimer?.restart();
}
};
@ -87,7 +84,7 @@ class Presence {
* If the state has changed, the homeserver will be notified.
* @param {string} newState the new presence state (see PRESENCE enum)
*/
private async setState(newState: State) {
private async setState(newState: SetPresence): Promise<void> {
if (newState === this.state) {
return;
}
@ -95,13 +92,13 @@ class Presence {
const oldState = this.state;
this.state = newState;
if (MatrixClientPeg.get().isGuest()) {
if (MatrixClientPeg.safeGet().isGuest()) {
return; // don't try to set presence when a guest; it won't work.
}
try {
await MatrixClientPeg.get().setPresence({ presence: this.state });
logger.info("Presence:", newState);
await MatrixClientPeg.safeGet().setSyncPresence(this.state);
logger.debug("Presence:", newState);
} catch (err) {
logger.error("Failed to set presence:", err);
this.state = oldState;

View file

@ -22,11 +22,13 @@ limitations under the License.
import React from "react";
import dis from './dispatcher/dispatcher';
import Modal from './Modal';
import { _t } from './languageHandler';
import dis from "./dispatcher/dispatcher";
import Modal from "./Modal";
import { _t } from "./languageHandler";
import QuestionDialog from "./components/views/dialogs/QuestionDialog";
import { Action } from "./dispatcher/actions";
import SettingsStore from "./settings/SettingsStore";
import { UIFeature } from "./settings/UIFeature";
// Regex for what a "safe" or "Matrix-looking" localpart would be.
// TODO: Update as needed for https://github.com/matrix-org/matrix-doc/issues/1514
@ -46,33 +48,36 @@ export const SAFE_LOCALPART_REGEX = /^[a-z0-9=_\-./]+$/;
*/
export async function startAnyRegistrationFlow(
// eslint-disable-next-line camelcase
options: { go_home_on_cancel?: boolean, go_welcome_on_cancel?: boolean, screen_after?: boolean},
options: { go_home_on_cancel?: boolean; go_welcome_on_cancel?: boolean; screen_after?: boolean } = {},
): Promise<void> {
if (options === undefined) options = {};
const modal = Modal.createDialog(QuestionDialog, {
hasCancelButton: true,
quitOnly: true,
title: _t("Sign In or Create Account"),
description: _t("Use your account or create a new one to continue."),
button: _t("Create Account"),
extraButtons: [
<button
key="start_login"
onClick={() => {
modal.close();
dis.dispatch({ action: 'start_login', screenAfterLogin: options.screen_after });
}}
>
{ _t('Sign In') }
</button>,
],
title: SettingsStore.getValue(UIFeature.Registration) ? _t("auth|sign_in_or_register") : _t("action|sign_in"),
description: SettingsStore.getValue(UIFeature.Registration)
? _t("auth|sign_in_or_register_description")
: _t("auth|sign_in_description"),
button: _t("action|sign_in"),
extraButtons: SettingsStore.getValue(UIFeature.Registration)
? [
<button
key="register"
onClick={() => {
modal.close();
dis.dispatch({ action: "start_registration", screenAfterLogin: options.screen_after });
}}
>
{_t("auth|register_action")}
</button>,
]
: [],
onFinished: (proceed) => {
if (proceed) {
dis.dispatch({ action: 'start_registration', screenAfterLogin: options.screen_after });
dis.dispatch({ action: "start_login", screenAfterLogin: options.screen_after });
} else if (options.go_home_on_cancel) {
dis.dispatch({ action: Action.ViewHomePage });
} else if (options.go_welcome_on_cancel) {
dis.dispatch({ action: 'view_welcome_page' });
dis.dispatch({ action: "view_welcome_page" });
}
},
});

View file

@ -14,45 +14,53 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import { MatrixEvent, EventStatus } from 'matrix-js-sdk/src/models/event';
import { Room } from 'matrix-js-sdk/src/models/room';
import { MatrixEvent, EventStatus, Room, MatrixClient } from "matrix-js-sdk/src/matrix";
import { logger } from "matrix-js-sdk/src/logger";
import { MatrixClientPeg } from './MatrixClientPeg';
import dis from './dispatcher/dispatcher';
import dis from "./dispatcher/dispatcher";
export default class Resend {
public static resendUnsentEvents(room: Room): Promise<void[]> {
return Promise.all(room.getPendingEvents().filter(function(ev: MatrixEvent) {
return ev.status === EventStatus.NOT_SENT;
}).map(function(event: MatrixEvent) {
return Resend.resend(event);
}));
return Promise.all(
room
.getPendingEvents()
.filter(function (ev: MatrixEvent) {
return ev.status === EventStatus.NOT_SENT;
})
.map(function (event: MatrixEvent) {
return Resend.resend(room.client, event);
}),
);
}
public static cancelUnsentEvents(room: Room): void {
room.getPendingEvents().filter(function(ev: MatrixEvent) {
return ev.status === EventStatus.NOT_SENT;
}).forEach(function(event: MatrixEvent) {
Resend.removeFromQueue(event);
});
}
public static resend(event: MatrixEvent): Promise<void> {
const room = MatrixClientPeg.get().getRoom(event.getRoomId());
return MatrixClientPeg.get().resendEvent(event, room).then(function(res) {
dis.dispatch({
action: 'message_sent',
event: event,
room.getPendingEvents()
.filter(function (ev: MatrixEvent) {
return ev.status === EventStatus.NOT_SENT;
})
.forEach(function (event: MatrixEvent) {
Resend.removeFromQueue(room.client, event);
});
}, function(err: Error) {
// XXX: temporary logging to try to diagnose
// https://github.com/vector-im/element-web/issues/3148
logger.log('Resend got send failure: ' + err.name + '(' + err + ')');
});
}
public static removeFromQueue(event: MatrixEvent): void {
MatrixClientPeg.get().cancelPendingEvent(event);
public static resend(client: MatrixClient, event: MatrixEvent): Promise<void> {
const room = client.getRoom(event.getRoomId())!;
return client.resendEvent(event, room).then(
function (res) {
dis.dispatch({
action: "message_sent",
event: event,
});
},
function (err: Error) {
// XXX: temporary logging to try to diagnose
// https://github.com/vector-im/element-web/issues/3148
logger.log("Resend got send failure: " + err.name + "(" + err + ")");
},
);
}
public static removeFromQueue(client: MatrixClient, event: MatrixEvent): void {
client.cancelPendingEvent(event);
}
}

View file

@ -14,15 +14,15 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import { _t } from './languageHandler';
import { _t } from "./languageHandler";
export function levelRoleMap(usersDefault: number): Record<number | "undefined", string> {
return {
undefined: _t('Default'),
0: _t('Restricted'),
[usersDefault]: _t('Default'),
50: _t('Moderator'),
100: _t('Admin'),
undefined: _t("power_level|default"),
0: _t("power_level|restricted"),
[usersDefault]: _t("power_level|default"),
50: _t("power_level|moderator"),
100: _t("power_level|admin"),
};
}
@ -31,6 +31,6 @@ export function textualPowerLevel(level: number, usersDefault: number): string {
if (LEVEL_ROLE_MAP[level]) {
return LEVEL_ROLE_MAP[level];
} else {
return _t("Custom (%(level)s)", { level });
return _t("power_level|custom", { level });
}
}

View file

@ -30,6 +30,6 @@ export function storeRoomAliasInCache(alias: string, id: string): void {
aliasToIDMap.set(alias, id);
}
export function getCachedRoomIDForAlias(alias: string): string {
export function getCachedRoomIDForAlias(alias: string): string | undefined {
return aliasToIDMap.get(alias);
}

View file

@ -14,22 +14,18 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import React from "react";
import { Room } from "matrix-js-sdk/src/models/room";
import { MatrixEvent } from "matrix-js-sdk/src/models/event";
import { User } from "matrix-js-sdk/src/models/user";
import React, { ComponentProps } from "react";
import { Room, MatrixEvent, MatrixClient, User, EventType } from "matrix-js-sdk/src/matrix";
import { logger } from "matrix-js-sdk/src/logger";
import { EventType } from "matrix-js-sdk/src/@types/event";
import { MatrixClientPeg } from './MatrixClientPeg';
import MultiInviter, { CompletionStates } from './utils/MultiInviter';
import Modal from './Modal';
import { _t } from './languageHandler';
import MultiInviter, { CompletionStates } from "./utils/MultiInviter";
import Modal from "./Modal";
import { _t } from "./languageHandler";
import InviteDialog from "./components/views/dialogs/InviteDialog";
import BaseAvatar from "./components/views/avatars/BaseAvatar";
import { mediaFromMxc } from "./customisations/Media";
import ErrorDialog from "./components/views/dialogs/ErrorDialog";
import { KIND_DM, KIND_INVITE } from "./components/views/dialogs/InviteDialogTypes";
import { InviteKind } from "./components/views/dialogs/InviteDialogTypes";
import { Member } from "./utils/direct-messages";
export interface IInviteResult {
@ -49,33 +45,41 @@ export interface IInviteResult {
* @returns {Promise} Promise
*/
export function inviteMultipleToRoom(
client: MatrixClient,
roomId: string,
addresses: string[],
sendSharedHistoryKeys = false,
progressCallback?: () => void,
): Promise<IInviteResult> {
const inviter = new MultiInviter(roomId, progressCallback);
return inviter.invite(addresses, undefined, sendSharedHistoryKeys)
.then(states => Promise.resolve({ states, inviter }));
const inviter = new MultiInviter(client, roomId, progressCallback);
return inviter
.invite(addresses, undefined, sendSharedHistoryKeys)
.then((states) => Promise.resolve({ states, inviter }));
}
export function showStartChatInviteDialog(initialText = ""): void {
// This dialog handles the room creation internally - we don't need to worry about it.
Modal.createDialog(
InviteDialog, { kind: KIND_DM, initialText },
/*className=*/"mx_InviteDialog_flexWrapper", /*isPriority=*/false, /*isStatic=*/true,
InviteDialog,
{ kind: InviteKind.Dm, initialText },
/*className=*/ "mx_InviteDialog_flexWrapper",
/*isPriority=*/ false,
/*isStatic=*/ true,
);
}
export function showRoomInviteDialog(roomId: string, initialText = ""): void {
// This dialog handles the room creation internally - we don't need to worry about it.
Modal.createDialog(
InviteDialog, {
kind: KIND_INVITE,
InviteDialog,
{
kind: InviteKind.Invite,
initialText,
roomId,
},
/*className=*/"mx_InviteDialog_flexWrapper", /*isPriority=*/false, /*isStatic=*/true,
} as Omit<ComponentProps<typeof InviteDialog>, "onFinished">,
/*className=*/ "mx_InviteDialog_flexWrapper",
/*isPriority=*/ false,
/*isStatic=*/ true,
);
}
@ -88,8 +92,8 @@ export function isValid3pidInvite(event: MatrixEvent): boolean {
if (!event || event.getType() !== EventType.RoomThirdPartyInvite) return false;
// any events without these keys are not valid 3pid invites, so we ignore them
const requiredKeys = ['key_validity_url', 'public_key', 'display_name'];
if (requiredKeys.some(key => !event.getContent()[key])) {
const requiredKeys = ["key_validity_url", "public_key", "display_name"];
if (requiredKeys.some((key) => !event.getContent()[key])) {
return false;
}
@ -98,21 +102,24 @@ export function isValid3pidInvite(event: MatrixEvent): boolean {
}
export function inviteUsersToRoom(
client: MatrixClient,
roomId: string,
userIds: string[],
sendSharedHistoryKeys = false,
progressCallback?: () => void,
): Promise<void> {
return inviteMultipleToRoom(roomId, userIds, sendSharedHistoryKeys, progressCallback).then((result) => {
const room = MatrixClientPeg.get().getRoom(roomId);
showAnyInviteErrors(result.states, room, result.inviter);
}).catch((err) => {
logger.error(err.stack);
Modal.createDialog(ErrorDialog, {
title: _t("Failed to invite"),
description: ((err && err.message) ? err.message : _t("Operation failed")),
return inviteMultipleToRoom(client, roomId, userIds, sendSharedHistoryKeys, progressCallback)
.then((result) => {
const room = client.getRoom(roomId)!;
showAnyInviteErrors(result.states, room, result.inviter);
})
.catch((err) => {
logger.error(err.stack);
Modal.createDialog(ErrorDialog, {
title: _t("invite|failed_title"),
description: err && err.message ? err.message : _t("invite|failed_generic"),
});
});
});
}
export function showAnyInviteErrors(
@ -122,18 +129,18 @@ export function showAnyInviteErrors(
userMap?: Map<string, Member>,
): boolean {
// Show user any errors
const failedUsers = Object.keys(states).filter(a => states[a] === 'error');
const failedUsers = Object.keys(states).filter((a) => states[a] === "error");
if (failedUsers.length === 1 && inviter.fatal) {
// Just get the first message because there was a fatal problem on the first
// user. This usually means that no other users were attempted, making it
// pointless for us to list who failed exactly.
Modal.createDialog(ErrorDialog, {
title: _t("Failed to invite users to %(roomName)s", { roomName: room.name }),
title: _t("invite|room_failed_title", { roomName: room.name }),
description: inviter.getErrorText(failedUsers[0]),
});
return false;
} else {
const errorList = [];
const errorList: string[] = [];
for (const addr of failedUsers) {
if (states[addr] === "error") {
const reason = inviter.getErrorText(addr);
@ -141,42 +148,54 @@ export function showAnyInviteErrors(
}
}
const cli = MatrixClientPeg.get();
const cli = room.client;
if (errorList.length > 0) {
// React 16 doesn't let us use `errorList.join(<br />)` anymore, so this is our solution
const description = <div className="mx_InviteDialog_multiInviterError">
<h4>{ _t("We sent the others, but the below people couldn't be invited to <RoomName/>", {}, {
RoomName: () => <b>{ room.name }</b>,
}) }</h4>
<div>
{ failedUsers.map(addr => {
const user = userMap?.get(addr) || cli.getUser(addr);
const name = (user as Member).name || (user as User).rawDisplayName;
const avatarUrl = (user as Member).getMxcAvatarUrl?.() || (user as User).avatarUrl;
return <div key={addr} className="mx_InviteDialog_tile mx_InviteDialog_tile--inviterError">
<div className="mx_InviteDialog_tile_avatarStack">
<BaseAvatar
url={avatarUrl ? mediaFromMxc(avatarUrl).getSquareThumbnailHttp(24) : null}
name={name}
idName={user.userId}
width={36}
height={36}
/>
</div>
<div className="mx_InviteDialog_tile_nameStack">
<span className="mx_InviteDialog_tile_nameStack_name">{ name }</span>
<span className="mx_InviteDialog_tile_nameStack_userId">{ user.userId }</span>
</div>
<div className="mx_InviteDialog_tile--inviterError_errorText">
{ inviter.getErrorText(addr) }
</div>
</div>;
}) }
const description = (
<div className="mx_InviteDialog_multiInviterError">
<h4>
{_t(
"invite|room_failed_partial",
{},
{
RoomName: () => <b>{room.name}</b>,
},
)}
</h4>
<div>
{failedUsers.map((addr) => {
const user = userMap?.get(addr) || cli.getUser(addr);
const name = (user as Member).name || (user as User).rawDisplayName;
const avatarUrl = (user as Member).getMxcAvatarUrl?.() || (user as User).avatarUrl;
return (
<div key={addr} className="mx_InviteDialog_tile mx_InviteDialog_tile--inviterError">
<div className="mx_InviteDialog_tile_avatarStack">
<BaseAvatar
url={
(avatarUrl && mediaFromMxc(avatarUrl).getSquareThumbnailHttp(24)) ??
undefined
}
name={name!}
idName={user?.userId}
size="36px"
/>
</div>
<div className="mx_InviteDialog_tile_nameStack">
<span className="mx_InviteDialog_tile_nameStack_name">{name}</span>
<span className="mx_InviteDialog_tile_nameStack_userId">{user?.userId}</span>
</div>
<div className="mx_InviteDialog_tile--inviterError_errorText">
{inviter.getErrorText(addr)}
</div>
</div>
);
})}
</div>
</div>
</div>;
);
Modal.createDialog(ErrorDialog, {
title: _t("Some invites couldn't be sent"),
title: _t("invite|room_failed_partial_title"),
description,
});
return false;

View file

@ -1,6 +1,5 @@
/*
Copyright 2016 OpenMarket Ltd
Copyright 2019 The Matrix.org Foundation C.I.C.
Copyright 2016, 2019, 2023 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.
@ -15,40 +14,44 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import { PushProcessor } from 'matrix-js-sdk/src/pushprocessor';
import { NotificationCountType, Room } from "matrix-js-sdk/src/models/room";
import { PushProcessor } from "matrix-js-sdk/src/pushprocessor";
import {
NotificationCountType,
ConditionKind,
IPushRule,
PushRuleActionName,
PushRuleKind,
TweakName,
} from "matrix-js-sdk/src/@types/PushRules";
import { EventType } from 'matrix-js-sdk/src/@types/event';
} from "matrix-js-sdk/src/matrix";
import { MatrixClientPeg } from './MatrixClientPeg';
import type { IPushRule, Room, MatrixClient } from "matrix-js-sdk/src/matrix";
import { NotificationLevel } from "./stores/notifications/NotificationLevel";
import { getUnsentMessages } from "./components/structures/RoomStatusBar";
import { doesRoomHaveUnreadMessages, doesRoomOrThreadHaveUnreadMessages } from "./Unread";
import { EffectiveMembership, getEffectiveMembership, isKnockDenied } from "./utils/membership";
import SettingsStore from "./settings/SettingsStore";
import { getMarkedUnreadState } from "./utils/notifications";
export enum RoomNotifState {
AllMessagesLoud = 'all_messages_loud',
AllMessages = 'all_messages',
MentionsOnly = 'mentions_only',
Mute = 'mute',
AllMessagesLoud = "all_messages_loud",
AllMessages = "all_messages",
MentionsOnly = "mentions_only",
Mute = "mute",
}
export function getRoomNotifsState(roomId: string): RoomNotifState {
if (MatrixClientPeg.get().isGuest()) return RoomNotifState.AllMessages;
export function getRoomNotifsState(client: MatrixClient, roomId: string): RoomNotifState | null {
if (client.isGuest()) return RoomNotifState.AllMessages;
// look through the override rules for a rule affecting this room:
// if one exists, it will take precedence.
const muteRule = findOverrideMuteRule(roomId);
const muteRule = findOverrideMuteRule(client, roomId);
if (muteRule) {
return RoomNotifState.Mute;
}
// for everything else, look at the room rule.
let roomRule = null;
let roomRule: IPushRule | undefined;
try {
roomRule = MatrixClientPeg.get().getRoomPushRule('global', roomId);
roomRule = client.getRoomPushRule("global", roomId);
} catch (err) {
// Possible that the client doesn't have pushRules yet. If so, it
// hasn't started either, so indicate that this room is not notifying.
@ -70,52 +73,56 @@ export function getRoomNotifsState(roomId: string): RoomNotifState {
return null;
}
export function setRoomNotifsState(roomId: string, newState: RoomNotifState): Promise<void> {
export function setRoomNotifsState(client: MatrixClient, roomId: string, newState: RoomNotifState): Promise<void> {
if (newState === RoomNotifState.Mute) {
return setRoomNotifsStateMuted(roomId);
return setRoomNotifsStateMuted(client, roomId);
} else {
return setRoomNotifsStateUnmuted(roomId, newState);
return setRoomNotifsStateUnmuted(client, roomId, newState);
}
}
export function getUnreadNotificationCount(
room: Room,
type: NotificationCountType,
includeThreads: boolean,
threadId?: string,
): number {
let notificationCount = (!!threadId
const getCountShownForRoom = (r: Room, type: NotificationCountType): number => {
return includeThreads ? r.getUnreadNotificationCount(type) : r.getRoomUnreadNotificationCount(type);
};
let notificationCount = !!threadId
? room.getThreadUnreadNotificationCount(threadId, type)
: room.getUnreadNotificationCount(type));
: getCountShownForRoom(room, type);
// Check notification counts in the old room just in case there's some lost
// there. We only go one level down to avoid performance issues, and theory
// is that 1st generation rooms will have already been read by the 3rd generation.
const createEvent = room.currentState.getStateEvents(EventType.RoomCreate, "");
const predecessor = createEvent?.getContent().predecessor;
const msc3946ProcessDynamicPredecessor = SettingsStore.getValue("feature_dynamic_room_predecessors");
const predecessor = room.findPredecessor(msc3946ProcessDynamicPredecessor);
// Exclude threadId, as the same thread can't continue over a room upgrade
if (!threadId && predecessor) {
const oldRoomId = predecessor.room_id;
const oldRoom = MatrixClientPeg.get().getRoom(oldRoomId);
if (!threadId && predecessor?.roomId) {
const oldRoomId = predecessor.roomId;
const oldRoom = room.client.getRoom(oldRoomId);
if (oldRoom) {
// We only ever care if there's highlights in the old room. No point in
// notifying the user for unread messages because they would have extreme
// difficulty changing their notification preferences away from "All Messages"
// and "Noisy".
notificationCount += oldRoom.getUnreadNotificationCount(NotificationCountType.Highlight);
notificationCount += getCountShownForRoom(oldRoom, NotificationCountType.Highlight);
}
}
return notificationCount;
}
function setRoomNotifsStateMuted(roomId: string): Promise<any> {
const cli = MatrixClientPeg.get();
const promises = [];
function setRoomNotifsStateMuted(cli: MatrixClient, roomId: string): Promise<any> {
const promises: Promise<unknown>[] = [];
// delete the room rule
const roomRule = cli.getRoomPushRule('global', roomId);
const roomRule = cli.getRoomPushRule("global", roomId);
if (roomRule) {
promises.push(cli.deletePushRule('global', PushRuleKind.RoomSpecific, roomRule.rule_id));
promises.push(cli.deletePushRule("global", PushRuleKind.RoomSpecific, roomRule.rule_id));
}
// add/replace an override rule to squelch everything in this room
@ -123,82 +130,176 @@ function setRoomNotifsStateMuted(roomId: string): Promise<any> {
// is an override rule, not a room rule: it still pertains to this room
// though, so using the room ID as the rule ID is logical and prevents
// duplicate copies of the rule.
promises.push(cli.addPushRule('global', PushRuleKind.Override, roomId, {
conditions: [
{
kind: ConditionKind.EventMatch,
key: 'room_id',
pattern: roomId,
},
],
actions: [
PushRuleActionName.DontNotify,
],
}));
promises.push(
cli.addPushRule("global", PushRuleKind.Override, roomId, {
conditions: [
{
kind: ConditionKind.EventMatch,
key: "room_id",
pattern: roomId,
},
],
actions: [PushRuleActionName.DontNotify],
}),
);
return Promise.all(promises);
}
function setRoomNotifsStateUnmuted(roomId: string, newState: RoomNotifState): Promise<any> {
const cli = MatrixClientPeg.get();
const promises = [];
function setRoomNotifsStateUnmuted(cli: MatrixClient, roomId: string, newState: RoomNotifState): Promise<any> {
const promises: Promise<unknown>[] = [];
const overrideMuteRule = findOverrideMuteRule(roomId);
const overrideMuteRule = findOverrideMuteRule(cli, roomId);
if (overrideMuteRule) {
promises.push(cli.deletePushRule('global', PushRuleKind.Override, overrideMuteRule.rule_id));
promises.push(cli.deletePushRule("global", PushRuleKind.Override, overrideMuteRule.rule_id));
}
if (newState === RoomNotifState.AllMessages) {
const roomRule = cli.getRoomPushRule('global', roomId);
const roomRule = cli.getRoomPushRule("global", roomId);
if (roomRule) {
promises.push(cli.deletePushRule('global', PushRuleKind.RoomSpecific, roomRule.rule_id));
promises.push(cli.deletePushRule("global", PushRuleKind.RoomSpecific, roomRule.rule_id));
}
} else if (newState === RoomNotifState.MentionsOnly) {
promises.push(cli.addPushRule('global', PushRuleKind.RoomSpecific, roomId, {
actions: [
PushRuleActionName.DontNotify,
],
}));
// https://matrix.org/jira/browse/SPEC-400
promises.push(cli.setPushRuleEnabled('global', PushRuleKind.RoomSpecific, roomId, true));
promises.push(
cli.addPushRule("global", PushRuleKind.RoomSpecific, roomId, {
actions: [PushRuleActionName.DontNotify],
}),
);
} else if (newState === RoomNotifState.AllMessagesLoud) {
promises.push(cli.addPushRule('global', PushRuleKind.RoomSpecific, roomId, {
actions: [
PushRuleActionName.Notify,
{
set_tweak: TweakName.Sound,
value: 'default',
},
],
}));
// https://matrix.org/jira/browse/SPEC-400
promises.push(cli.setPushRuleEnabled('global', PushRuleKind.RoomSpecific, roomId, true));
promises.push(
cli.addPushRule("global", PushRuleKind.RoomSpecific, roomId, {
actions: [
PushRuleActionName.Notify,
{
set_tweak: TweakName.Sound,
value: "default",
},
],
}),
);
}
return Promise.all(promises);
}
function findOverrideMuteRule(roomId: string): IPushRule {
const cli = MatrixClientPeg.get();
function findOverrideMuteRule(cli: MatrixClient | undefined, roomId: string): IPushRule | null {
if (!cli?.pushRules?.global?.override) {
return null;
}
for (const rule of cli.pushRules.global.override) {
if (rule.enabled && isRuleForRoom(roomId, rule) && isMuteRule(rule)) {
if (rule.enabled && isRuleRoomMuteRuleForRoomId(roomId, rule)) {
return rule;
}
}
return null;
}
function isRuleForRoom(roomId: string, rule: IPushRule): boolean {
if (rule.conditions?.length !== 1) {
/**
* Checks if a given rule is a room mute rule as implemented by EW
* - matches every event in one room (one condition that is an event match on roomId)
* - silences notifications (one action that is `DontNotify`)
* @param rule - push rule
* @returns {boolean} - true when rule mutes a room
*/
export function isRuleMaybeRoomMuteRule(rule: IPushRule): boolean {
return (
// matches every event in one room
rule.conditions?.length === 1 &&
rule.conditions[0].kind === ConditionKind.EventMatch &&
rule.conditions[0].key === "room_id" &&
// silences notifications
isMuteRule(rule)
);
}
/**
* Checks if a given rule is a room mute rule as implemented by EW
* @param roomId - id of room to match
* @param rule - push rule
* @returns {boolean} true when rule mutes the given room
*/
function isRuleRoomMuteRuleForRoomId(roomId: string, rule: IPushRule): boolean {
if (!isRuleMaybeRoomMuteRule(rule)) {
return false;
}
const cond = rule.conditions[0];
return (cond.kind === ConditionKind.EventMatch && cond.key === 'room_id' && cond.pattern === roomId);
// isRuleMaybeRoomMuteRule checks this condition exists
const cond = rule.conditions![0]!;
return cond.pattern === roomId;
}
function isMuteRule(rule: IPushRule): boolean {
return (rule.actions.length === 1 && rule.actions[0] === PushRuleActionName.DontNotify);
// DontNotify is equivalent to the empty actions array
return (
rule.actions.length === 0 || (rule.actions.length === 1 && rule.actions[0] === PushRuleActionName.DontNotify)
);
}
/**
* Returns an object giving information about the unread state of a room or thread
* @param room The room to query, or the room the thread is in
* @param threadId The thread to check the unread state of, or undefined to query the main thread
* @param includeThreads If threadId is undefined, true to include threads other than the main thread, or
* false to exclude them. Ignored if threadId is specified.
* @returns
*/
export function determineUnreadState(
room?: Room,
threadId?: string,
includeThreads?: boolean,
): { level: NotificationLevel; symbol: string | null; count: number } {
if (!room) {
return { symbol: null, count: 0, level: NotificationLevel.None };
}
if (getUnsentMessages(room, threadId).length > 0) {
return { symbol: "!", count: 1, level: NotificationLevel.Unsent };
}
if (getEffectiveMembership(room.getMyMembership()) === EffectiveMembership.Invite) {
return { symbol: "!", count: 1, level: NotificationLevel.Highlight };
}
if (SettingsStore.getValue("feature_ask_to_join") && isKnockDenied(room)) {
return { symbol: "!", count: 1, level: NotificationLevel.Highlight };
}
if (getRoomNotifsState(room.client, room.roomId) === RoomNotifState.Mute) {
return { symbol: null, count: 0, level: NotificationLevel.None };
}
const redNotifs = getUnreadNotificationCount(
room,
NotificationCountType.Highlight,
includeThreads ?? false,
threadId,
);
const greyNotifs = getUnreadNotificationCount(room, NotificationCountType.Total, includeThreads ?? false, threadId);
const trueCount = greyNotifs || redNotifs;
if (redNotifs > 0) {
return { symbol: null, count: trueCount, level: NotificationLevel.Highlight };
}
const markedUnreadState = getMarkedUnreadState(room);
if (greyNotifs > 0 || markedUnreadState) {
return { symbol: null, count: trueCount, level: NotificationLevel.Notification };
}
// We don't have any notified messages, but we might have unread messages. Let's find out.
let hasUnread = false;
if (threadId) {
const thread = room.getThread(threadId);
if (thread) {
hasUnread = doesRoomOrThreadHaveUnreadMessages(thread);
}
// If the thread does not exist, assume it contains no unreads
} else {
hasUnread = doesRoomHaveUnreadMessages(room, includeThreads ?? false);
}
return {
symbol: null,
count: trueCount,
level: hasUnread ? NotificationLevel.Activity : NotificationLevel.None,
};
}

View file

@ -14,11 +14,9 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import { Room } from "matrix-js-sdk/src/models/room";
import { EventType } from "matrix-js-sdk/src/@types/event";
import { Room, EventType, RoomMember, MatrixClient } from "matrix-js-sdk/src/matrix";
import { MatrixClientPeg } from './MatrixClientPeg';
import AliasCustomisations from './customisations/Alias';
import AliasCustomisations from "./customisations/Alias";
/**
* Given a room object, return the alias we should use for it,
@ -29,74 +27,76 @@ import AliasCustomisations from './customisations/Alias';
* @param {Object} room The room object
* @returns {string} A display alias for the given room
*/
export function getDisplayAliasForRoom(room: Room): string {
return getDisplayAliasForAliasSet(
room.getCanonicalAlias(), room.getAltAliases(),
);
export function getDisplayAliasForRoom(room: Room): string | null {
return getDisplayAliasForAliasSet(room.getCanonicalAlias(), room.getAltAliases());
}
// The various display alias getters should all feed through this one path so
// there's a single place to change the logic.
export function getDisplayAliasForAliasSet(canonicalAlias: string, altAliases: string[]): string {
export function getDisplayAliasForAliasSet(canonicalAlias: string | null, altAliases: string[]): string | null {
if (AliasCustomisations.getDisplayAliasForAliasSet) {
return AliasCustomisations.getDisplayAliasForAliasSet(canonicalAlias, altAliases);
}
return canonicalAlias || altAliases?.[0];
return (canonicalAlias || altAliases?.[0]) ?? "";
}
export function guessAndSetDMRoom(room: Room, isDirect: boolean): Promise<void> {
let newTarget;
if (isDirect) {
const guessedUserId = guessDMRoomTargetId(
room, MatrixClientPeg.get().getUserId(),
);
const guessedUserId = guessDMRoomTargetId(room, room.client.getSafeUserId());
newTarget = guessedUserId;
} else {
newTarget = null;
}
return setDMRoom(room.roomId, newTarget);
return setDMRoom(room.client, room.roomId, newTarget);
}
/**
* Marks or unmarks the given room as being as a DM room.
* @param client the Matrix Client instance of the logged-in user
* @param {string} roomId The ID of the room to modify
* @param {string} userId The user ID of the desired DM
room target user or null to un-mark
this room as a DM room
* @param {string | null} userId The user ID of the desired DM room target user or
* null to un-mark this room as a DM room
* @returns {object} A promise
*/
export async function setDMRoom(roomId: string, userId: string): Promise<void> {
if (MatrixClientPeg.get().isGuest()) return;
export async function setDMRoom(client: MatrixClient, roomId: string, userId: string | null): Promise<void> {
if (client.isGuest()) return;
const mDirectEvent = MatrixClientPeg.get().getAccountData(EventType.Direct);
let dmRoomMap = {};
const mDirectEvent = client.getAccountData(EventType.Direct);
const currentContent = mDirectEvent?.getContent() || {};
if (mDirectEvent !== undefined) dmRoomMap = { ...mDirectEvent.getContent() }; // copy as we will mutate
const dmRoomMap = new Map(Object.entries(currentContent));
let modified = false;
// remove it from the lists of any others users
// (it can only be a DM room for one person)
for (const thisUserId of Object.keys(dmRoomMap)) {
const roomList = dmRoomMap[thisUserId];
for (const thisUserId of dmRoomMap.keys()) {
const roomList = dmRoomMap.get(thisUserId) || [];
if (thisUserId != userId) {
const indexOfRoom = roomList.indexOf(roomId);
if (indexOfRoom > -1) {
roomList.splice(indexOfRoom, 1);
modified = true;
}
}
}
// now add it, if it's not already there
if (userId) {
const roomList = dmRoomMap[userId] || [];
const roomList = dmRoomMap.get(userId) || [];
if (roomList.indexOf(roomId) == -1) {
roomList.push(roomId);
modified = true;
}
dmRoomMap[userId] = roomList;
dmRoomMap.set(userId, roomList);
}
await MatrixClientPeg.get().setAccountData(EventType.Direct, dmRoomMap);
// prevent unnecessary calls to setAccountData
if (!modified) return;
await client.setAccountData(EventType.Direct, Object.fromEntries(dmRoomMap));
}
/**
@ -108,8 +108,8 @@ export async function setDMRoom(roomId: string, userId: string): Promise<void> {
* @returns {string} User ID of the user that the room is probably a DM with
*/
function guessDMRoomTargetId(room: Room, myUserId: string): string {
let oldestTs;
let oldestUser;
let oldestTs: number | undefined;
let oldestUser: RoomMember | undefined;
// Pick the joined user who's been here longest (and isn't us),
for (const user of room.getJoinedMembers()) {
@ -117,7 +117,7 @@ function guessDMRoomTargetId(room: Room, myUserId: string): string {
if (oldestTs === undefined || (user.events.member && user.events.member.getTs() < oldestTs)) {
oldestUser = user;
oldestTs = user.events.member.getTs();
oldestTs = user.events.member?.getTs();
}
}
if (oldestUser) return oldestUser.userId;
@ -128,7 +128,7 @@ function guessDMRoomTargetId(room: Room, myUserId: string): string {
if (oldestTs === undefined || (user.events.member && user.events.member.getTs() < oldestTs)) {
oldestUser = user;
oldestTs = user.events.member.getTs();
oldestTs = user.events.member?.getTs();
}
}

View file

@ -14,17 +14,15 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import url from 'url';
import { SERVICE_TYPES } from "matrix-js-sdk/src/service-types";
import { Room } from "matrix-js-sdk/src/models/room";
import { logger } from "matrix-js-sdk/src/logger";
import { IOpenIDToken } from 'matrix-js-sdk/src/matrix';
import { SERVICE_TYPES, Room, IOpenIDToken } from "matrix-js-sdk/src/matrix";
import SettingsStore from "./settings/SettingsStore";
import { Service, startTermsFlow, TermsInteractionCallback, TermsNotSignedError } from './Terms';
import { Service, startTermsFlow, TermsInteractionCallback, TermsNotSignedError } from "./Terms";
import { MatrixClientPeg } from "./MatrixClientPeg";
import SdkConfig from "./SdkConfig";
import { WidgetType } from "./widgets/WidgetType";
import { parseUrl } from "./utils/UrlUtils";
// The version of the integration manager API we're intending to work with
const imApiVersion = "1.1";
@ -32,11 +30,14 @@ const imApiVersion = "1.1";
// TODO: Generify the name of this class and all components within - it's not just for Scalar.
export default class ScalarAuthClient {
private scalarToken: string;
private termsInteractionCallback: TermsInteractionCallback;
private scalarToken: string | null;
private termsInteractionCallback?: TermsInteractionCallback;
private isDefaultManager: boolean;
constructor(private apiUrl: string, private uiUrl: string) {
public constructor(
private apiUrl: string,
private uiUrl: string,
) {
this.scalarToken = null;
// `undefined` to allow `startTermsFlow` to fallback to a default
// callback if this is unset.
@ -49,8 +50,8 @@ export default class ScalarAuthClient {
this.isDefaultManager = apiUrl === configApiUrl && configUiUrl === uiUrl;
}
private writeTokenToStore() {
window.localStorage.setItem("mx_scalar_token_at_" + this.apiUrl, this.scalarToken);
private writeTokenToStore(): void {
window.localStorage.setItem("mx_scalar_token_at_" + this.apiUrl, this.scalarToken ?? "");
if (this.isDefaultManager) {
// We remove the old token from storage to migrate upwards. This is safe
// to do because even if the user switches to /app when this is on /develop
@ -59,7 +60,7 @@ export default class ScalarAuthClient {
}
}
private readTokenFromStore(): string {
private readTokenFromStore(): string | null {
let token = window.localStorage.getItem("mx_scalar_token_at_" + this.apiUrl);
if (!token && this.isDefaultManager) {
token = window.localStorage.getItem("mx_scalar_token");
@ -67,27 +68,27 @@ export default class ScalarAuthClient {
return token;
}
private readToken(): string {
private readToken(): string | null {
if (this.scalarToken) return this.scalarToken;
return this.readTokenFromStore();
}
setTermsInteractionCallback(callback) {
public setTermsInteractionCallback(callback: TermsInteractionCallback): void {
this.termsInteractionCallback = callback;
}
connect(): Promise<void> {
public connect(): Promise<void> {
return this.getScalarToken().then((tok) => {
this.scalarToken = tok;
});
}
hasCredentials(): boolean {
public hasCredentials(): boolean {
return this.scalarToken != null; // undef or null
}
// Returns a promise that resolves to a scalar_token string
getScalarToken(): Promise<string> {
public getScalarToken(): Promise<string> {
const token = this.readToken();
if (!token) {
@ -129,58 +130,63 @@ export default class ScalarAuthClient {
}
private checkToken(token: string): Promise<string> {
return this.getAccountName(token).then(userId => {
const me = MatrixClientPeg.get().getUserId();
if (userId !== me) {
throw new Error("Scalar token is owned by someone else: " + me);
}
return token;
}).catch((e) => {
if (e instanceof TermsNotSignedError) {
logger.log("Integration manager requires new terms to be agreed to");
// The terms endpoints are new and so live on standard _matrix prefixes,
// but IM rest urls are currently configured with paths, so remove the
// path from the base URL before passing it to the js-sdk
return this.getAccountName(token)
.then((userId) => {
const me = MatrixClientPeg.safeGet().getUserId();
if (userId !== me) {
throw new Error("Scalar token is owned by someone else: " + me);
}
return token;
})
.catch((e) => {
if (e instanceof TermsNotSignedError) {
logger.log("Integration manager requires new terms to be agreed to");
// The terms endpoints are new and so live on standard _matrix prefixes,
// but IM rest urls are currently configured with paths, so remove the
// path from the base URL before passing it to the js-sdk
// We continue to use the full URL for the calls done by
// matrix-react-sdk, but the standard terms API called
// by the js-sdk lives on the standard _matrix path. This means we
// don't support running IMs on a non-root path, but it's the only
// realistic way of transitioning to _matrix paths since configs in
// the wild contain bits of the API path.
// We continue to use the full URL for the calls done by
// matrix-react-sdk, but the standard terms API called
// by the js-sdk lives on the standard _matrix path. This means we
// don't support running IMs on a non-root path, but it's the only
// realistic way of transitioning to _matrix paths since configs in
// the wild contain bits of the API path.
// Once we've fully transitioned to _matrix URLs, we can give people
// a grace period to update their configs, then use the rest url as
// a regular base url.
const parsedImRestUrl = url.parse(this.apiUrl);
parsedImRestUrl.path = '';
parsedImRestUrl.pathname = '';
return startTermsFlow([new Service(
SERVICE_TYPES.IM,
url.format(parsedImRestUrl),
token,
)], this.termsInteractionCallback).then(() => {
return token;
});
} else {
throw e;
}
});
// Once we've fully transitioned to _matrix URLs, we can give people
// a grace period to update their configs, then use the rest url as
// a regular base url.
const parsedImRestUrl = parseUrl(this.apiUrl);
parsedImRestUrl.pathname = "";
return startTermsFlow(
MatrixClientPeg.safeGet(),
[new Service(SERVICE_TYPES.IM, parsedImRestUrl.toString(), token)],
this.termsInteractionCallback,
).then(() => {
return token;
});
} else {
throw e;
}
});
}
registerForToken(): Promise<string> {
public registerForToken(): Promise<string> {
// Get openid bearer token from the HS as the first part of our dance
return MatrixClientPeg.get().getOpenIdToken().then((tokenObject) => {
// Now we can send that to scalar and exchange it for a scalar token
return this.exchangeForScalarToken(tokenObject);
}).then((token) => {
// Validate it (this mostly checks to see if the IM needs us to agree to some terms)
return this.checkToken(token);
}).then((token) => {
this.scalarToken = token;
this.writeTokenToStore();
return token;
});
return MatrixClientPeg.safeGet()
.getOpenIdToken()
.then((tokenObject) => {
// Now we can send that to scalar and exchange it for a scalar token
return this.exchangeForScalarToken(tokenObject);
})
.then((token) => {
// Validate it (this mostly checks to see if the IM needs us to agree to some terms)
return this.checkToken(token);
})
.then((token) => {
this.scalarToken = token;
this.writeTokenToStore();
return token;
});
}
public async exchangeForScalarToken(openidTokenObject: IOpenIDToken): Promise<string> {
@ -208,7 +214,7 @@ export default class ScalarAuthClient {
}
public async getScalarPageTitle(url: string): Promise<string> {
const scalarPageLookupUrl = new URL(this.getStarterLink(this.apiUrl + '/widgets/title_lookup'));
const scalarPageLookupUrl = new URL(this.getStarterLink(this.apiUrl + "/widgets/title_lookup"));
scalarPageLookupUrl.searchParams.set("curl", encodeURIComponent(url));
const res = await fetch(scalarPageLookupUrl, {
@ -251,24 +257,25 @@ export default class ScalarAuthClient {
}
}
getScalarInterfaceUrlForRoom(room: Room, screen: string, id: string): string {
public getScalarInterfaceUrlForRoom(room: Room, screen?: string, id?: string): string {
const roomId = room.roomId;
const roomName = room.name;
let url = this.uiUrl;
url += "?scalar_token=" + encodeURIComponent(this.scalarToken);
if (this.scalarToken) url += "?scalar_token=" + encodeURIComponent(this.scalarToken);
url += "&room_id=" + encodeURIComponent(roomId);
url += "&room_name=" + encodeURIComponent(roomName);
url += "&theme=" + encodeURIComponent(SettingsStore.getValue("theme"));
if (id) {
url += '&integ_id=' + encodeURIComponent(id);
url += "&integ_id=" + encodeURIComponent(id);
}
if (screen) {
url += '&screen=' + encodeURIComponent(screen);
url += "&screen=" + encodeURIComponent(screen);
}
return url;
}
getStarterLink(starterLinkUrl: string): string {
public getStarterLink(starterLinkUrl: string): string {
if (!this.scalarToken) return starterLinkUrl;
return starterLinkUrl + "?scalar_token=" + encodeURIComponent(this.scalarToken);
}
}

View file

@ -264,20 +264,45 @@ Get an openID token for the current user session.
Request: No parameters
Response:
- The openId token object as described in https://spec.matrix.org/v1.2/client-server-api/#post_matrixclientv3useruseridopenidrequest_token
send_event
----------
Sends an event in a room.
Request:
- type is the event type to send.
- state_key is the state key to send. Omitted if not a state event.
- content is the event content to send.
Response:
- room_id is the room ID where the event was sent.
- event_id is the event ID of the event which was sent.
read_events
-----------
Read events from a room.
Request:
- type is the event type to read.
- state_key is the state key to read, or `true` to read all events of the type. Omitted if not a state event.
Response:
- events: Array of events. If none found, this will be an empty array.
*/
import { MatrixEvent } from 'matrix-js-sdk/src/models/event';
import { IContent, MatrixEvent, IEvent, StateEvents } from "matrix-js-sdk/src/matrix";
import { logger } from "matrix-js-sdk/src/logger";
import { MatrixClientPeg } from './MatrixClientPeg';
import dis from './dispatcher/dispatcher';
import WidgetUtils from './utils/WidgetUtils';
import { _t } from './languageHandler';
import { MatrixClientPeg } from "./MatrixClientPeg";
import dis from "./dispatcher/dispatcher";
import WidgetUtils from "./utils/WidgetUtils";
import { _t } from "./languageHandler";
import { IntegrationManagers } from "./integrations/IntegrationManagers";
import { WidgetType } from "./widgets/WidgetType";
import { objectClone } from "./utils/objects";
import { EffectiveMembership, getEffectiveMembership } from './utils/membership';
import { SdkContextClass } from './contexts/SDKContext';
import { EffectiveMembership, getEffectiveMembership } from "./utils/membership";
import { SdkContextClass } from "./contexts/SDKContext";
enum Action {
CloseScalar = "close_scalar",
@ -294,7 +319,9 @@ enum Action {
BotOptions = "bot_options",
SetBotOptions = "set_bot_options",
SetBotPower = "set_bot_power",
GetOpenIdToken = "get_open_id_token"
GetOpenIdToken = "get_open_id_token",
SendEvent = "send_event",
ReadEvents = "read_events",
}
function sendResponse(event: MessageEvent<any>, res: any): void {
@ -323,14 +350,14 @@ function inviteUser(event: MessageEvent<any>, roomId: string, userId: string): v
logger.log(`Received request to invite ${userId} into room ${roomId}`);
const client = MatrixClientPeg.get();
if (!client) {
sendError(event, _t('You need to be logged in.'));
sendError(event, _t("widget|error_need_to_be_logged_in"));
return;
}
const room = client.getRoom(roomId);
if (room) {
// if they are already invited or joined we can resolve immediately.
const member = room.getMember(userId);
if (member && ["join", "invite"].includes(member.membership)) {
if (member && ["join", "invite"].includes(member.membership!)) {
sendResponse(event, {
success: true,
});
@ -338,27 +365,30 @@ function inviteUser(event: MessageEvent<any>, roomId: string, userId: string): v
}
}
client.invite(roomId, userId).then(function() {
sendResponse(event, {
success: true,
});
}, function(err) {
sendError(event, _t('You need to be able to invite users to do that.'), err);
});
client.invite(roomId, userId).then(
function () {
sendResponse(event, {
success: true,
});
},
function (err) {
sendError(event, _t("widget|error_need_invite_permission"), err);
},
);
}
function kickUser(event: MessageEvent<any>, roomId: string, userId: string): void {
logger.log(`Received request to kick ${userId} from room ${roomId}`);
const client = MatrixClientPeg.get();
if (!client) {
sendError(event, _t("You need to be logged in."));
sendError(event, _t("widget|error_need_to_be_logged_in"));
return;
}
const room = client.getRoom(roomId);
if (room) {
// if they are already not in the room we can resolve immediately.
const member = room.getMember(userId);
if (!member || getEffectiveMembership(member.membership) === EffectiveMembership.Leave) {
if (!member || getEffectiveMembership(member.membership!) === EffectiveMembership.Leave) {
sendResponse(event, {
success: true,
});
@ -367,16 +397,20 @@ function kickUser(event: MessageEvent<any>, roomId: string, userId: string): voi
}
const reason = event.data.reason;
client.kick(roomId, userId, reason).then(() => {
sendResponse(event, {
success: true,
client
.kick(roomId, userId, reason)
.then(() => {
sendResponse(event, {
success: true,
});
})
.catch((err) => {
sendError(event, _t("widget|error_need_kick_permission"), err);
});
}).catch((err) => {
sendError(event, _t("You need to be able to kick users to do that."), err);
});
}
function setWidget(event: MessageEvent<any>, roomId: string): void {
function setWidget(event: MessageEvent<any>, roomId: string | null): void {
const client = MatrixClientPeg.safeGet();
const widgetId = event.data.widget_id;
let widgetType = event.data.type;
const widgetUrl = event.data.url;
@ -387,34 +421,31 @@ function setWidget(event: MessageEvent<any>, roomId: string): void {
// both adding/removing widgets need these checks
if (!widgetId || widgetUrl === undefined) {
sendError(event, _t("Unable to create widget."), new Error("Missing required widget fields."));
sendError(event, _t("scalar|error_create"), new Error("Missing required widget fields."));
return;
}
if (widgetUrl !== null) { // if url is null it is being deleted, don't need to check name/type/etc
if (widgetUrl !== null) {
// if url is null it is being deleted, don't need to check name/type/etc
// check types of fields
if (widgetName !== undefined && typeof widgetName !== 'string') {
sendError(event, _t("Unable to create widget."), new Error("Optional field 'name' must be a string."));
if (widgetName !== undefined && typeof widgetName !== "string") {
sendError(event, _t("scalar|error_create"), new Error("Optional field 'name' must be a string."));
return;
}
if (widgetData !== undefined && !(widgetData instanceof Object)) {
sendError(event, _t("Unable to create widget."), new Error("Optional field 'data' must be an Object."));
sendError(event, _t("scalar|error_create"), new Error("Optional field 'data' must be an Object."));
return;
}
if (widgetAvatarUrl !== undefined && typeof widgetAvatarUrl !== 'string') {
sendError(
event,
_t("Unable to create widget."),
new Error("Optional field 'avatar_url' must be a string."),
);
if (widgetAvatarUrl !== undefined && typeof widgetAvatarUrl !== "string") {
sendError(event, _t("scalar|error_create"), new Error("Optional field 'avatar_url' must be a string."));
return;
}
if (typeof widgetType !== 'string') {
sendError(event, _t("Unable to create widget."), new Error("Field 'type' must be a string."));
if (typeof widgetType !== "string") {
sendError(event, _t("scalar|error_create"), new Error("Field 'type' must be a string."));
return;
}
if (typeof widgetUrl !== 'string') {
sendError(event, _t("Unable to create widget."), new Error("Field 'url' must be a string or null."));
if (typeof widgetUrl !== "string") {
sendError(event, _t("scalar|error_create"), new Error("Field 'url' must be a string or null."));
return;
}
}
@ -423,42 +454,57 @@ function setWidget(event: MessageEvent<any>, roomId: string): void {
widgetType = WidgetType.fromString(widgetType);
if (userWidget) {
WidgetUtils.setUserWidget(widgetId, widgetType, widgetUrl, widgetName, widgetData).then(() => {
sendResponse(event, {
success: true,
});
dis.dispatch({ action: "user_widget_updated" });
}).catch((e) => {
sendError(event, _t('Unable to create widget.'), e);
});
} else { // Room widget
if (!roomId) {
sendError(event, _t('Missing roomId.'), null);
}
WidgetUtils.setRoomWidget(roomId, widgetId, widgetType, widgetUrl, widgetName, widgetData, widgetAvatarUrl)
WidgetUtils.setUserWidget(client, widgetId, widgetType, widgetUrl, widgetName, widgetData)
.then(() => {
sendResponse(event, {
success: true,
});
}, (err) => {
sendError(event, _t('Failed to send request.'), err);
dis.dispatch({ action: "user_widget_updated" });
})
.catch((e) => {
sendError(event, _t("scalar|error_create"), e);
});
} else {
// Room widget
if (!roomId) {
sendError(event, _t("scalar|error_missing_room_id"));
return;
}
WidgetUtils.setRoomWidget(
client,
roomId,
widgetId,
widgetType,
widgetUrl,
widgetName,
widgetData,
widgetAvatarUrl,
).then(
() => {
sendResponse(event, {
success: true,
});
},
(err) => {
sendError(event, _t("scalar|error_send_request"), err);
},
);
}
}
function getWidgets(event: MessageEvent<any>, roomId: string): void {
function getWidgets(event: MessageEvent<any>, roomId: string | null): void {
const client = MatrixClientPeg.get();
if (!client) {
sendError(event, _t('You need to be logged in.'));
sendError(event, _t("widget|error_need_to_be_logged_in"));
return;
}
let widgetStateEvents = [];
let widgetStateEvents: Partial<IEvent>[] = [];
if (roomId) {
const room = client.getRoom(roomId);
if (!room) {
sendError(event, _t('This room is not recognised.'));
sendError(event, _t("scalar|error_room_unknown"));
return;
}
// XXX: This gets the raw event object (I think because we can't
@ -467,7 +513,7 @@ function getWidgets(event: MessageEvent<any>, roomId: string): void {
}
// Add user widgets (not linked to a specific room)
const userWidgets = WidgetUtils.getUserWidgetsArray();
const userWidgets = WidgetUtils.getUserWidgetsArray(client);
widgetStateEvents = widgetStateEvents.concat(userWidgets);
sendResponse(event, widgetStateEvents);
@ -476,66 +522,76 @@ function getWidgets(event: MessageEvent<any>, roomId: string): void {
function getRoomEncState(event: MessageEvent<any>, roomId: string): void {
const client = MatrixClientPeg.get();
if (!client) {
sendError(event, _t('You need to be logged in.'));
sendError(event, _t("widget|error_need_to_be_logged_in"));
return;
}
const room = client.getRoom(roomId);
if (!room) {
sendError(event, _t('This room is not recognised.'));
sendError(event, _t("scalar|error_room_unknown"));
return;
}
const roomIsEncrypted = MatrixClientPeg.get().isRoomEncrypted(roomId);
const roomIsEncrypted = MatrixClientPeg.safeGet().isRoomEncrypted(roomId);
sendResponse(event, roomIsEncrypted);
}
function setPlumbingState(event: MessageEvent<any>, roomId: string, status: string): void {
if (typeof status !== 'string') {
throw new Error('Plumbing state status should be a string');
if (typeof status !== "string") {
throw new Error("Plumbing state status should be a string");
}
logger.log(`Received request to set plumbing state to status "${status}" in room ${roomId}`);
const client = MatrixClientPeg.get();
if (!client) {
sendError(event, _t('You need to be logged in.'));
sendError(event, _t("widget|error_need_to_be_logged_in"));
return;
}
client.sendStateEvent(roomId, "m.room.plumbing", { status: status }).then(() => {
sendResponse(event, {
success: true,
});
}, (err) => {
sendError(event, err.message ? err.message : _t('Failed to send request.'), err);
});
client.sendStateEvent(roomId, "m.room.plumbing", { status: status }).then(
() => {
sendResponse(event, {
success: true,
});
},
(err) => {
sendError(event, err.message ? err.message : _t("scalar|error_send_request"), err);
},
);
}
function setBotOptions(event: MessageEvent<any>, roomId: string, userId: string): void {
logger.log(`Received request to set options for bot ${userId} in room ${roomId}`);
const client = MatrixClientPeg.get();
if (!client) {
sendError(event, _t('You need to be logged in.'));
sendError(event, _t("widget|error_need_to_be_logged_in"));
return;
}
client.sendStateEvent(roomId, "m.room.bot.options", event.data.content, "_" + userId).then(() => {
sendResponse(event, {
success: true,
});
}, (err) => {
sendError(event, err.message ? err.message : _t('Failed to send request.'), err);
});
client.sendStateEvent(roomId, "m.room.bot.options", event.data.content, "_" + userId).then(
() => {
sendResponse(event, {
success: true,
});
},
(err) => {
sendError(event, err.message ? err.message : _t("scalar|error_send_request"), err);
},
);
}
async function setBotPower(
event: MessageEvent<any>, roomId: string, userId: string, level: number, ignoreIfGreater?: boolean,
event: MessageEvent<any>,
roomId: string,
userId: string,
level: number,
ignoreIfGreater?: boolean,
): Promise<void> {
if (!(Number.isInteger(level) && level >= 0)) {
sendError(event, _t('Power level must be positive integer.'));
sendError(event, _t("scalar|error_power_level_invalid"));
return;
}
logger.log(`Received request to set power level to ${level} for bot ${userId} in room ${roomId}.`);
const client = MatrixClientPeg.get();
if (!client) {
sendError(event, _t('You need to be logged in.'));
sendError(event, _t("widget|error_need_to_be_logged_in"));
return;
}
@ -552,17 +608,21 @@ async function setBotPower(
});
}
}
await client.setPowerLevel(roomId, userId, level, new MatrixEvent(
{
await client.setPowerLevel(
roomId,
userId,
level,
new MatrixEvent({
type: "m.room.power_levels",
content: powerLevels,
},
));
}),
);
return sendResponse(event, {
success: true,
});
} catch (err) {
sendError(event, err.message ? err.message : _t('Failed to send request.'), err);
const error = err instanceof Error ? err : undefined;
sendError(event, error?.message ?? _t("scalar|error_send_request"), error);
}
}
@ -584,12 +644,12 @@ function botOptions(event: MessageEvent<any>, roomId: string, userId: string): v
function getMembershipCount(event: MessageEvent<any>, roomId: string): void {
const client = MatrixClientPeg.get();
if (!client) {
sendError(event, _t('You need to be logged in.'));
sendError(event, _t("widget|error_need_to_be_logged_in"));
return;
}
const room = client.getRoom(roomId);
if (!room) {
sendError(event, _t('This room is not recognised.'));
sendError(event, _t("scalar|error_room_unknown"));
return;
}
const count = room.getJoinedMemberCount();
@ -601,21 +661,21 @@ function canSendEvent(event: MessageEvent<any>, roomId: string): void {
const isState = Boolean(event.data.is_state);
const client = MatrixClientPeg.get();
if (!client) {
sendError(event, _t('You need to be logged in.'));
sendError(event, _t("widget|error_need_to_be_logged_in"));
return;
}
const room = client.getRoom(roomId);
if (!room) {
sendError(event, _t('This room is not recognised.'));
sendError(event, _t("scalar|error_room_unknown"));
return;
}
if (room.getMyMembership() !== "join") {
sendError(event, _t('You are not in this room.'));
sendError(event, _t("scalar|error_membership"));
return;
}
const me = client.credentials.userId;
const me = client.credentials.userId!;
let canSend = false;
let canSend: boolean;
if (isState) {
canSend = room.currentState.maySendStateEvent(evType, me);
} else {
@ -623,7 +683,7 @@ function canSendEvent(event: MessageEvent<any>, roomId: string): void {
}
if (!canSend) {
sendError(event, _t('You do not have permission to do that in this room.'));
sendError(event, _t("scalar|error_permission"));
return;
}
@ -633,12 +693,12 @@ function canSendEvent(event: MessageEvent<any>, roomId: string): void {
function returnStateEvent(event: MessageEvent<any>, roomId: string, eventType: string, stateKey: string): void {
const client = MatrixClientPeg.get();
if (!client) {
sendError(event, _t('You need to be logged in.'));
sendError(event, _t("widget|error_need_to_be_logged_in"));
return;
}
const room = client.getRoom(roomId);
if (!room) {
sendError(event, _t('This room is not recognised.'));
sendError(event, _t("scalar|error_room_unknown"));
return;
}
const stateEvent = room.currentState.getStateEvents(eventType, stateKey);
@ -649,19 +709,154 @@ function returnStateEvent(event: MessageEvent<any>, roomId: string, eventType: s
sendResponse(event, stateEvent.getContent());
}
async function getOpenIdToken(event: MessageEvent<any>) {
async function getOpenIdToken(event: MessageEvent<any>): Promise<void> {
try {
const tokenObject = MatrixClientPeg.get().getOpenIdToken();
const tokenObject = await MatrixClientPeg.safeGet().getOpenIdToken();
sendResponse(event, tokenObject);
} catch (ex) {
logger.warn("Unable to fetch openId token.", ex);
sendError(event, 'Unable to fetch openId token.');
sendError(event, "Unable to fetch openId token.");
}
}
const onMessage = function(event: MessageEvent<any>): void {
if (!event.origin) { // stupid chrome
// @ts-ignore
async function sendEvent(
event: MessageEvent<{
type: keyof StateEvents;
state_key?: string;
content?: IContent;
}>,
roomId: string,
): Promise<void> {
const eventType = event.data.type;
const stateKey = event.data.state_key;
const content = event.data.content;
if (typeof eventType !== "string") {
sendError(event, _t("scalar|failed_send_event"), new Error("Invalid 'type' in request"));
return;
}
const allowedEventTypes = ["m.widgets", "im.vector.modular.widgets", "io.element.integrations.installations"];
if (!allowedEventTypes.includes(eventType)) {
sendError(event, _t("scalar|failed_send_event"), new Error("Disallowed 'type' in request"));
return;
}
if (!content || typeof content !== "object") {
sendError(event, _t("scalar|failed_send_event"), new Error("Invalid 'content' in request"));
return;
}
const client = MatrixClientPeg.get();
if (!client) {
sendError(event, _t("widget|error_need_to_be_logged_in"));
return;
}
const room = client.getRoom(roomId);
if (!room) {
sendError(event, _t("scalar|error_room_unknown"));
return;
}
if (stateKey !== undefined) {
// state event
try {
const res = await client.sendStateEvent(roomId, eventType, content, stateKey);
sendResponse(event, {
room_id: roomId,
event_id: res.event_id,
});
} catch (e) {
sendError(event, _t("scalar|failed_send_event"), e as Error);
return;
}
} else {
// message event
sendError(event, _t("scalar|failed_send_event"), new Error("Sending message events is not implemented"));
return;
}
}
async function readEvents(
event: MessageEvent<{
type: string;
state_key?: string | boolean;
limit?: number;
}>,
roomId: string,
): Promise<void> {
const eventType = event.data.type;
const stateKey = event.data.state_key;
const limit = event.data.limit;
if (typeof eventType !== "string") {
sendError(event, _t("scalar|failed_read_event"), new Error("Invalid 'type' in request"));
return;
}
const allowedEventTypes = [
"m.room.power_levels",
"m.room.encryption",
"m.room.member",
"m.room.name",
"m.widgets",
"im.vector.modular.widgets",
"io.element.integrations.installations",
];
if (!allowedEventTypes.includes(eventType)) {
sendError(event, _t("scalar|failed_read_event"), new Error("Disallowed 'type' in request"));
return;
}
let effectiveLimit: number;
if (limit !== undefined) {
if (typeof limit !== "number" || limit < 0) {
sendError(event, _t("scalar|failed_read_event"), new Error("Invalid 'limit' in request"));
return;
}
effectiveLimit = Math.min(limit, Number.MAX_SAFE_INTEGER);
} else {
effectiveLimit = Number.MAX_SAFE_INTEGER;
}
const client = MatrixClientPeg.get();
if (!client) {
sendError(event, _t("widget|error_need_to_be_logged_in"));
return;
}
const room = client.getRoom(roomId);
if (!room) {
sendError(event, _t("scalar|error_room_unknown"));
return;
}
if (stateKey !== undefined) {
// state events
if (typeof stateKey !== "string" && stateKey !== true) {
sendError(event, _t("scalar|failed_read_event"), new Error("Invalid 'state_key' in request"));
return;
}
// When `true` is passed for state key, get events with any state key.
const effectiveStateKey = stateKey === true ? undefined : stateKey;
let events: MatrixEvent[] = [];
events = events.concat(room.currentState.getStateEvents(eventType, effectiveStateKey as string) || []);
events = events.slice(0, effectiveLimit);
sendResponse(event, {
events: events.map((e) => e.getEffectiveEvent()),
});
return;
} else {
// message events
sendError(event, _t("scalar|failed_read_event"), new Error("Reading message events is not implemented"));
return;
}
}
const onMessage = function (event: MessageEvent<any>): void {
if (!event.origin) {
// @ts-ignore - stupid chrome
event.origin = event.originalEvent.origin;
}
@ -669,15 +864,15 @@ const onMessage = function(event: MessageEvent<any>): void {
// This means the URL could contain a path (like /develop) and still be used
// to validate event origins, which do not specify paths.
// (See https://developer.mozilla.org/en-US/docs/Web/API/Window/postMessage)
let configUrl;
let configUrl: URL | undefined;
try {
if (!openManagerUrl) openManagerUrl = IntegrationManagers.sharedInstance().getPrimaryManager().uiUrl;
configUrl = new URL(openManagerUrl);
if (!openManagerUrl) openManagerUrl = IntegrationManagers.sharedInstance().getPrimaryManager()?.uiUrl;
configUrl = new URL(openManagerUrl!);
} catch (e) {
// No integrations UI URL, ignore silently.
return;
}
let eventOriginUrl;
let eventOriginUrl: URL;
try {
eventOriginUrl = new URL(event.origin);
} catch (e) {
@ -706,23 +901,23 @@ const onMessage = function(event: MessageEvent<any>): void {
if (!roomId) {
// These APIs don't require roomId
// Get and set user widgets (not associated with a specific room)
// If roomId is specified, it must be validated, so room-based widgets agreed
// handled further down.
if (event.data.action === Action.GetWidgets) {
getWidgets(event, null);
return;
} else if (event.data.action === Action.SetWidget) {
setWidget(event, null);
return;
} else if (event.data.action === Action.GetOpenIdToken) {
getOpenIdToken(event);
return;
} else {
sendError(event, _t('Missing room_id in request'));
sendError(event, _t("scalar|error_missing_room_id_request"));
return;
}
}
if (roomId !== SdkContextClass.instance.roomViewStore.getRoomId()) {
sendError(event, _t('Room %(roomId)s not visible', { roomId: roomId }));
sendError(event, _t("scalar|error_room_not_visible", { roomId: roomId }));
return;
}
@ -751,10 +946,16 @@ const onMessage = function(event: MessageEvent<any>): void {
} else if (event.data.action === Action.CanSendEvent) {
canSendEvent(event, roomId);
return;
} else if (event.data.action === Action.SendEvent) {
sendEvent(event, roomId);
return;
} else if (event.data.action === Action.ReadEvents) {
readEvents(event, roomId);
return;
}
if (!userId) {
sendError(event, _t('Missing user_id in request'));
sendError(event, _t("scalar|error_missing_user_id_request"));
return;
}
switch (event.data.action) {
@ -776,17 +977,14 @@ const onMessage = function(event: MessageEvent<any>): void {
case Action.SetBotPower:
setBotPower(event, roomId, userId, event.data.level, event.data.ignoreIfGreater);
break;
case Action.GetOpenIdToken:
getOpenIdToken(event);
break;
default:
logger.warn("Unhandled postMessage event with action '" + event.data.action +"'");
logger.warn("Unhandled postMessage event with action '" + event.data.action + "'");
break;
}
};
let listenerCount = 0;
let openManagerUrl: string | null = null;
let openManagerUrl: string | undefined;
export function startListening(): void {
if (listenerCount === 0) {
@ -802,10 +1000,7 @@ export function stopListening(): void {
}
if (listenerCount < 0) {
// Make an error so we get a stack trace
const e = new Error(
"ScalarMessaging: mismatched startListening / stopListening detected." +
" Negative count",
);
const e = new Error("ScalarMessaging: mismatched startListening / stopListening detected." + " Negative count");
logger.error(e);
}
}

View file

@ -16,17 +16,23 @@ limitations under the License.
*/
import { Optional } from "matrix-events-sdk";
import { mergeWith } from "lodash";
import { SnakedObject } from "./utils/SnakedObject";
import { IConfigOptions, ISsoRedirectOptions } from "./IConfigOptions";
import { KeysWithObjectShape } from "./@types/common";
import { isObject, objectClone } from "./utils/objects";
import { DeepReadonly, Defaultize } from "./@types/common";
// see element-web config.md for docs, or the IConfigOptions interface for dev docs
export const DEFAULTS: IConfigOptions = {
export const DEFAULTS: DeepReadonly<IConfigOptions> = {
brand: "Element",
help_url: "https://element.io/help",
help_encryption_url: "https://element.io/help#encryption",
integrations_ui_url: "https://scalar.vector.im/",
integrations_rest_url: "https://scalar.vector.im/api",
bug_report_endpoint_url: null,
uisi_autorageshake_app: "element-auto-uisi",
show_labs_settings: false,
jitsi: {
preferred_domain: "meet.element.io",
},
@ -47,15 +53,57 @@ export const DEFAULTS: IConfigOptions = {
url: "https://element.io/get-started",
},
voice_broadcast: {
chunk_length: 120, // two minutes
chunk_length: 2 * 60, // two minutes
max_length: 4 * 60 * 60, // four hours
},
feedback: {
existing_issues_url:
"https://github.com/vector-im/element-web/issues?q=is%3Aopen+is%3Aissue+sort%3Areactions-%2B1-desc",
new_issue_url: "https://github.com/vector-im/element-web/issues/new/choose",
},
desktop_builds: {
available: true,
logo: "vector-icons/1024.png",
url: "https://element.io/download",
},
mobile_builds: {
ios: "https://apps.apple.com/app/vector/id1083446067",
android: "https://play.google.com/store/apps/details?id=im.vector.app",
fdroid: "https://f-droid.org/repository/browse/?fdid=im.vector.app",
},
};
export default class SdkConfig {
private static instance: IConfigOptions;
private static fallback: SnakedObject<IConfigOptions>;
export type ConfigOptions = Defaultize<IConfigOptions, typeof DEFAULTS>;
private static setInstance(i: IConfigOptions) {
function mergeConfig(
config: DeepReadonly<IConfigOptions>,
changes: DeepReadonly<Partial<IConfigOptions>>,
): DeepReadonly<IConfigOptions> {
// return { ...config, ...changes };
return mergeWith(objectClone(config), changes, (objValue, srcValue) => {
// Don't merge arrays, prefer values from newer object
if (Array.isArray(objValue)) {
return srcValue;
}
// Don't allow objects to get nulled out, this will break our types
if (isObject(objValue) && !isObject(srcValue)) {
return objValue;
}
});
}
type ObjectType<K extends keyof IConfigOptions> = IConfigOptions[K] extends object
? SnakedObject<NonNullable<IConfigOptions[K]>>
: Optional<SnakedObject<NonNullable<IConfigOptions[K]>>>;
export default class SdkConfig {
private static instance: DeepReadonly<IConfigOptions>;
private static fallback: SnakedObject<DeepReadonly<IConfigOptions>>;
private static setInstance(i: DeepReadonly<IConfigOptions>): void {
SdkConfig.instance = i;
SdkConfig.fallback = new SnakedObject(i);
@ -66,8 +114,9 @@ export default class SdkConfig {
public static get(): IConfigOptions;
public static get<K extends keyof IConfigOptions>(key: K, altCaseName?: string): IConfigOptions[K];
public static get<K extends keyof IConfigOptions = never>(
key?: K, altCaseName?: string,
): IConfigOptions | IConfigOptions[K] {
key?: K,
altCaseName?: string,
): DeepReadonly<IConfigOptions> | DeepReadonly<IConfigOptions>[K] {
if (key === undefined) {
// safe to cast as a fallback - we want to break the runtime contract in this case
return SdkConfig.instance || <IConfigOptions>{};
@ -75,31 +124,29 @@ export default class SdkConfig {
return SdkConfig.fallback.get(key, altCaseName);
}
public static getObject<K extends KeysWithObjectShape<IConfigOptions>>(
key: K, altCaseName?: string,
): Optional<SnakedObject<IConfigOptions[K]>> {
public static getObject<K extends keyof IConfigOptions>(key: K, altCaseName?: string): ObjectType<K> {
const val = SdkConfig.get(key, altCaseName);
if (val !== null && val !== undefined) {
if (isObject(val)) {
return new SnakedObject(val);
}
// return the same type for sensitive callers (some want `undefined` specifically)
return val === undefined ? undefined : null;
return (val === undefined ? undefined : null) as ObjectType<K>;
}
public static put(cfg: Partial<IConfigOptions>) {
SdkConfig.setInstance({ ...DEFAULTS, ...cfg });
public static put(cfg: DeepReadonly<ConfigOptions>): void {
SdkConfig.setInstance(mergeConfig(DEFAULTS, cfg));
}
/**
* Resets the config to be completely empty.
* Resets the config.
*/
public static unset() {
SdkConfig.setInstance(<IConfigOptions>{}); // safe to cast - defaults will be applied
public static reset(): void {
SdkConfig.setInstance(mergeConfig(DEFAULTS, {})); // safe to cast - defaults will be applied
}
public static add(cfg: Partial<IConfigOptions>) {
SdkConfig.put({ ...SdkConfig.get(), ...cfg });
public static add(cfg: Partial<ConfigOptions>): void {
SdkConfig.put(mergeConfig(SdkConfig.get(), cfg));
}
}

View file

@ -21,23 +21,24 @@ import {
ISearchResult,
ISearchResults,
SearchOrderBy,
} from "matrix-js-sdk/src/@types/search";
import { IRoomEventFilter } from "matrix-js-sdk/src/filter";
import { EventType } from "matrix-js-sdk/src/@types/event";
import { SearchResult } from "matrix-js-sdk/src/models/search-result";
IRoomEventFilter,
EventType,
MatrixClient,
SearchResult,
} from "matrix-js-sdk/src/matrix";
import { ISearchArgs } from "./indexing/BaseEventIndexManager";
import EventIndexPeg from "./indexing/EventIndexPeg";
import { MatrixClientPeg } from "./MatrixClientPeg";
import { isNotUndefined } from "./Typeguards";
const SEARCH_LIMIT = 10;
async function serverSideSearch(
client: MatrixClient,
term: string,
roomId: string = undefined,
): Promise<{ response: ISearchResponse, query: ISearchRequestBody }> {
const client = MatrixClientPeg.get();
roomId?: string,
abortSignal?: AbortSignal,
): Promise<{ response: ISearchResponse; query: ISearchRequestBody }> {
const filter: IRoomEventFilter = {
limit: SEARCH_LIMIT,
};
@ -59,19 +60,24 @@ async function serverSideSearch(
},
};
const response = await client.search({ body: body });
const response = await client.search({ body: body }, abortSignal);
return { response, query: body };
}
async function serverSideSearchProcess(term: string, roomId: string = undefined): Promise<ISearchResults> {
const client = MatrixClientPeg.get();
const result = await serverSideSearch(term, roomId);
async function serverSideSearchProcess(
client: MatrixClient,
term: string,
roomId?: string,
abortSignal?: AbortSignal,
): Promise<ISearchResults> {
const result = await serverSideSearch(client, term, roomId, abortSignal);
// The js-sdk method backPaginateRoomEventsSearch() uses _query internally
// so we're reusing the concept here since we want to delegate the
// pagination back to backPaginateRoomEventsSearch() in some cases.
const searchResults: ISearchResults = {
abortSignal,
_query: result.query,
results: [],
highlights: [],
@ -90,12 +96,14 @@ function compareEvents(a: ISearchResult, b: ISearchResult): number {
return 0;
}
async function combinedSearch(searchTerm: string): Promise<ISearchResults> {
const client = MatrixClientPeg.get();
async function combinedSearch(
client: MatrixClient,
searchTerm: string,
abortSignal?: AbortSignal,
): Promise<ISearchResults> {
// Create two promises, one for the local search, one for the
// server-side search.
const serverSidePromise = serverSideSearch(searchTerm);
const serverSidePromise = serverSideSearch(client, searchTerm, undefined, abortSignal);
const localPromise = localSearch(searchTerm);
// Wait for both promises to resolve.
@ -152,9 +160,9 @@ async function combinedSearch(searchTerm: string): Promise<ISearchResults> {
async function localSearch(
searchTerm: string,
roomId: string = undefined,
roomId?: string,
processResult = true,
): Promise<{ response: IResultRoomEvents, query: ISearchArgs }> {
): Promise<{ response: IResultRoomEvents; query: ISearchArgs }> {
const eventIndex = EventIndexPeg.get();
const searchArgs: ISearchArgs = {
@ -170,7 +178,10 @@ async function localSearch(
searchArgs.room_id = roomId;
}
const localResult = await eventIndex.search(searchArgs);
const localResult = await eventIndex!.search(searchArgs);
if (!localResult) {
throw new Error("Local search failed");
}
searchArgs.next_batch = localResult.next_batch;
@ -189,7 +200,11 @@ export interface ISeshatSearchResults extends ISearchResults {
serverSideNextBatch?: string;
}
async function localSearchProcess(searchTerm: string, roomId: string = undefined): Promise<ISeshatSearchResults> {
async function localSearchProcess(
client: MatrixClient,
searchTerm: string,
roomId?: string,
): Promise<ISeshatSearchResults> {
const emptyResult = {
results: [],
highlights: [],
@ -207,19 +222,28 @@ async function localSearchProcess(searchTerm: string, roomId: string = undefined
},
};
const processedResult = MatrixClientPeg.get().processRoomEventsSearch(emptyResult, response);
const processedResult = client.processRoomEventsSearch(emptyResult, response);
// Restore our encryption info so we can properly re-verify the events.
restoreEncryptionInfo(processedResult.results);
return processedResult;
}
async function localPagination(searchResult: ISeshatSearchResults): Promise<ISeshatSearchResults> {
async function localPagination(
client: MatrixClient,
searchResult: ISeshatSearchResults,
): Promise<ISeshatSearchResults> {
const eventIndex = EventIndexPeg.get();
const searchArgs = searchResult.seshatQuery;
if (!searchResult.seshatQuery) {
throw new Error("localSearchProcess must be called first");
}
const localResult = await eventIndex!.search(searchResult.seshatQuery);
if (!localResult) {
throw new Error("Local search pagination failed");
}
const localResult = await eventIndex.search(searchArgs);
searchResult.seshatQuery.next_batch = localResult.next_batch;
// We only need to restore the encryption state for the new results, so
@ -232,13 +256,13 @@ async function localPagination(searchResult: ISeshatSearchResults): Promise<ISes
},
};
const result = MatrixClientPeg.get().processRoomEventsSearch(searchResult, response);
const result = client.processRoomEventsSearch(searchResult, response);
// Restore our encryption info so we can properly re-verify the events.
const newSlice = result.results.slice(Math.max(result.results.length - newResultCount, 0));
restoreEncryptionInfo(newSlice);
searchResult.pendingRequest = null;
searchResult.pendingRequest = undefined;
return result;
}
@ -382,12 +406,12 @@ function combineEventSources(
*/
function combineEvents(
previousSearchResult: ISeshatSearchResults,
localEvents: IResultRoomEvents = undefined,
serverEvents: IResultRoomEvents = undefined,
localEvents?: IResultRoomEvents,
serverEvents?: IResultRoomEvents,
): IResultRoomEvents {
const response = {} as IResultRoomEvents;
const cachedEvents = previousSearchResult.cachedEvents;
const cachedEvents = previousSearchResult.cachedEvents ?? [];
let oldestEventFrom = previousSearchResult.oldestEventFrom;
response.highlights = previousSearchResult.highlights;
@ -445,8 +469,8 @@ function combineEvents(
*/
function combineResponses(
previousSearchResult: ISeshatSearchResults,
localEvents: IResultRoomEvents = undefined,
serverEvents: IResultRoomEvents = undefined,
localEvents?: IResultRoomEvents,
serverEvents?: IResultRoomEvents,
): IResultRoomEvents {
// Combine our events first.
const response = combineEvents(previousSearchResult, localEvents, serverEvents);
@ -457,11 +481,14 @@ function combineResponses(
if (previousSearchResult.count) {
response.count = previousSearchResult.count;
} else {
response.count = localEvents.count + serverEvents.count;
const localEventCount = localEvents?.count ?? 0;
const serverEventCount = serverEvents?.count ?? 0;
response.count = localEventCount + serverEventCount;
}
// Update our next batch tokens for the given search sources.
if (localEvents) {
if (localEvents && isNotUndefined(previousSearchResult.seshatQuery)) {
previousSearchResult.seshatQuery.next_batch = localEvents.next_batch;
}
if (serverEvents) {
@ -471,7 +498,7 @@ function combineResponses(
// Set the response next batch token to one of the tokens from the sources,
// this makes sure that if we exhaust one of the sources we continue with
// the other one.
if (previousSearchResult.seshatQuery.next_batch) {
if (previousSearchResult.seshatQuery?.next_batch) {
response.next_batch = previousSearchResult.seshatQuery.next_batch;
} else if (previousSearchResult.serverSideNextBatch) {
response.next_batch = previousSearchResult.serverSideNextBatch;
@ -482,7 +509,11 @@ function combineResponses(
// pagination request.
//
// Provide a fake next batch token for that case.
if (!response.next_batch && previousSearchResult.cachedEvents.length > 0) {
if (
!response.next_batch &&
isNotUndefined(previousSearchResult.cachedEvents) &&
previousSearchResult.cachedEvents.length > 0
) {
response.next_batch = "cached";
}
@ -490,18 +521,17 @@ function combineResponses(
}
interface IEncryptedSeshatEvent {
curve25519Key: string;
ed25519Key: string;
algorithm: string;
forwardingCurve25519KeyChain: string[];
curve25519Key?: string;
ed25519Key?: string;
algorithm?: string;
forwardingCurve25519KeyChain?: string[];
}
function restoreEncryptionInfo(searchResultSlice: SearchResult[] = []): void {
for (let i = 0; i < searchResultSlice.length; i++) {
const timeline = searchResultSlice[i].context.getTimeline();
for (const result of searchResultSlice) {
const timeline = result.context.getTimeline();
for (let j = 0; j < timeline.length; j++) {
const mxEv = timeline[j];
for (const mxEv of timeline) {
const ev = mxEv.event as IEncryptedSeshatEvent;
if (ev.curve25519Key) {
@ -509,7 +539,7 @@ function restoreEncryptionInfo(searchResultSlice: SearchResult[] = []): void {
EventType.RoomMessageEncrypted,
{ algorithm: ev.algorithm },
ev.curve25519Key,
ev.ed25519Key,
ev.ed25519Key!,
);
// @ts-ignore
mxEv.forwardingCurve25519KeyChain = ev.forwardingCurve25519KeyChain;
@ -523,34 +553,32 @@ function restoreEncryptionInfo(searchResultSlice: SearchResult[] = []): void {
}
}
async function combinedPagination(searchResult: ISeshatSearchResults): Promise<ISeshatSearchResults> {
async function combinedPagination(
client: MatrixClient,
searchResult: ISeshatSearchResults,
): Promise<ISeshatSearchResults> {
const eventIndex = EventIndexPeg.get();
const client = MatrixClientPeg.get();
const searchArgs = searchResult.seshatQuery;
const oldestEventFrom = searchResult.oldestEventFrom;
let localResult: IResultRoomEvents;
let serverSideResult: ISearchResponse;
let localResult: IResultRoomEvents | undefined;
let serverSideResult: ISearchResponse | undefined;
// Fetch events from the local index if we have a token for it and if it's
// the local indexes turn or the server has exhausted its results.
if (searchArgs.next_batch && (!searchResult.serverSideNextBatch || oldestEventFrom === "server")) {
localResult = await eventIndex.search(searchArgs);
if (searchArgs?.next_batch && (!searchResult.serverSideNextBatch || oldestEventFrom === "server")) {
localResult = await eventIndex!.search(searchArgs);
}
// Fetch events from the server if we have a token for it and if it's the
// local indexes turn or the local index has exhausted its results.
if (searchResult.serverSideNextBatch && (oldestEventFrom === "local" || !searchArgs.next_batch)) {
const body = { body: searchResult._query, next_batch: searchResult.serverSideNextBatch };
if (searchResult.serverSideNextBatch && (oldestEventFrom === "local" || !searchArgs?.next_batch)) {
const body = { body: searchResult._query!, next_batch: searchResult.serverSideNextBatch };
serverSideResult = await client.search(body);
}
let serverEvents: IResultRoomEvents;
if (serverSideResult) {
serverEvents = serverSideResult.search_categories.room_events;
}
const serverEvents: IResultRoomEvents | undefined = serverSideResult?.search_categories.room_events;
// Combine our events.
const combinedResult = combineResponses(searchResult, localResult, serverEvents);
@ -571,36 +599,42 @@ async function combinedPagination(searchResult: ISeshatSearchResults): Promise<I
const newSlice = result.results.slice(Math.max(result.results.length - newResultCount, 0));
restoreEncryptionInfo(newSlice);
searchResult.pendingRequest = null;
searchResult.pendingRequest = undefined;
return result;
}
function eventIndexSearch(term: string, roomId: string = undefined): Promise<ISearchResults> {
function eventIndexSearch(
client: MatrixClient,
term: string,
roomId?: string,
abortSignal?: AbortSignal,
): Promise<ISearchResults> {
let searchPromise: Promise<ISearchResults>;
if (roomId !== undefined) {
if (MatrixClientPeg.get().isRoomEncrypted(roomId)) {
if (client.isRoomEncrypted(roomId)) {
// The search is for a single encrypted room, use our local
// search method.
searchPromise = localSearchProcess(term, roomId);
searchPromise = localSearchProcess(client, term, roomId);
} else {
// The search is for a single non-encrypted room, use the
// server-side search.
searchPromise = serverSideSearchProcess(term, roomId);
searchPromise = serverSideSearchProcess(client, term, roomId, abortSignal);
}
} else {
// Search across all rooms, combine a server side search and a
// local search.
searchPromise = combinedSearch(term);
searchPromise = combinedSearch(client, term, abortSignal);
}
return searchPromise;
}
function eventIndexSearchPagination(searchResult: ISeshatSearchResults): Promise<ISeshatSearchResults> {
const client = MatrixClientPeg.get();
function eventIndexSearchPagination(
client: MatrixClient,
searchResult: ISeshatSearchResults,
): Promise<ISeshatSearchResults> {
const seshatQuery = searchResult.seshatQuery;
const serverQuery = searchResult._query;
@ -610,33 +644,40 @@ function eventIndexSearchPagination(searchResult: ISeshatSearchResults): Promise
return client.backPaginateRoomEventsSearch(searchResult);
} else if (!serverQuery) {
// This is a search in a encrypted room. Do a local pagination.
const promise = localPagination(searchResult);
const promise = localPagination(client, searchResult);
searchResult.pendingRequest = promise;
return promise;
} else {
// We have both queries around, this is a search across all rooms so a
// combined pagination needs to be done.
const promise = combinedPagination(searchResult);
const promise = combinedPagination(client, searchResult);
searchResult.pendingRequest = promise;
return promise;
}
}
export function searchPagination(searchResult: ISearchResults): Promise<ISearchResults> {
export function searchPagination(client: MatrixClient, searchResult: ISearchResults): Promise<ISearchResults> {
const eventIndex = EventIndexPeg.get();
const client = MatrixClientPeg.get();
if (searchResult.pendingRequest) return searchResult.pendingRequest;
if (eventIndex === null) return client.backPaginateRoomEventsSearch(searchResult);
else return eventIndexSearchPagination(searchResult);
else return eventIndexSearchPagination(client, searchResult);
}
export default function eventSearch(term: string, roomId: string = undefined): Promise<ISearchResults> {
export default function eventSearch(
client: MatrixClient,
term: string,
roomId?: string,
abortSignal?: AbortSignal,
): Promise<ISearchResults> {
const eventIndex = EventIndexPeg.get();
if (eventIndex === null) return serverSideSearchProcess(term, roomId);
else return eventIndexSearch(term, roomId);
if (eventIndex === null) {
return serverSideSearchProcess(client, term, roomId, abortSignal);
} else {
return eventIndexSearch(client, term, roomId, abortSignal);
}
}

View file

@ -14,22 +14,19 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import { ICryptoCallbacks } from 'matrix-js-sdk/src/matrix';
import { ISecretStorageKeyInfo } from 'matrix-js-sdk/src/crypto/api';
import { MatrixClient } from 'matrix-js-sdk/src/client';
import { deriveKey } from 'matrix-js-sdk/src/crypto/key_passphrase';
import { decodeRecoveryKey } from 'matrix-js-sdk/src/crypto/recoverykey';
import { encodeBase64 } from "matrix-js-sdk/src/crypto/olmlib";
import { DeviceTrustLevel } from 'matrix-js-sdk/src/crypto/CrossSigning';
import { DeviceVerificationStatus, ICryptoCallbacks, MatrixClient, encodeBase64 } from "matrix-js-sdk/src/matrix";
import { ISecretStorageKeyInfo } from "matrix-js-sdk/src/crypto/api";
import { deriveKey } from "matrix-js-sdk/src/crypto/key_passphrase";
import { decodeRecoveryKey } from "matrix-js-sdk/src/crypto/recoverykey";
import { logger } from "matrix-js-sdk/src/logger";
import { ComponentType } from "react";
import Modal from './Modal';
import { MatrixClientPeg } from './MatrixClientPeg';
import { _t } from './languageHandler';
import { isSecureBackupRequired } from './utils/WellKnownUtils';
import AccessSecretStorageDialog from './components/views/dialogs/security/AccessSecretStorageDialog';
import RestoreKeyBackupDialog from './components/views/dialogs/security/RestoreKeyBackupDialog';
import type CreateSecretStorageDialog from "./async-components/views/dialogs/security/CreateSecretStorageDialog";
import Modal from "./Modal";
import { MatrixClientPeg } from "./MatrixClientPeg";
import { _t } from "./languageHandler";
import { isSecureBackupRequired } from "./utils/WellKnownUtils";
import AccessSecretStorageDialog, { KeyParams } from "./components/views/dialogs/security/AccessSecretStorageDialog";
import RestoreKeyBackupDialog from "./components/views/dialogs/security/RestoreKeyBackupDialog";
import SettingsStore from "./settings/SettingsStore";
import SecurityCustomisations from "./customisations/Security";
import QuestionDialog from "./components/views/dialogs/QuestionDialog";
@ -67,53 +64,48 @@ export function isSecretStorageBeingAccessed(): boolean {
}
export class AccessCancelledError extends Error {
constructor() {
public constructor() {
super("Secret storage access canceled");
}
}
async function confirmToDismiss(): Promise<boolean> {
const [sure] = await Modal.createDialog(QuestionDialog, {
title: _t("Cancel entering passphrase?"),
description: _t("Are you sure you want to cancel entering passphrase?"),
title: _t("encryption|cancel_entering_passphrase_title"),
description: _t("encryption|cancel_entering_passphrase_description"),
danger: false,
button: _t("Go Back"),
cancelButton: _t("Cancel"),
button: _t("action|go_back"),
cancelButton: _t("action|cancel"),
}).finished;
return !sure;
}
type KeyParams = { passphrase: string, recoveryKey: string };
function makeInputToKey(
keyInfo: ISecretStorageKeyInfo,
): (keyParams: KeyParams) => Promise<Uint8Array> {
return async ({ passphrase, recoveryKey }) => {
function makeInputToKey(keyInfo: ISecretStorageKeyInfo): (keyParams: KeyParams) => Promise<Uint8Array> {
return async ({ passphrase, recoveryKey }): Promise<Uint8Array> => {
if (passphrase) {
return deriveKey(
passphrase,
keyInfo.passphrase.salt,
keyInfo.passphrase.iterations,
);
} else {
return deriveKey(passphrase, keyInfo.passphrase.salt, keyInfo.passphrase.iterations);
} else if (recoveryKey) {
return decodeRecoveryKey(recoveryKey);
}
throw new Error("Invalid input, passphrase or recoveryKey need to be provided");
};
}
async function getSecretStorageKey(
{ keys: keyInfos }: { keys: Record<string, ISecretStorageKeyInfo> },
): Promise<[string, Uint8Array]> {
const cli = MatrixClientPeg.get();
async function getSecretStorageKey({
keys: keyInfos,
}: {
keys: Record<string, ISecretStorageKeyInfo>;
}): Promise<[string, Uint8Array]> {
const cli = MatrixClientPeg.safeGet();
let keyId = await cli.getDefaultSecretStorageKeyId();
let keyInfo: ISecretStorageKeyInfo;
let keyInfo!: ISecretStorageKeyInfo;
if (keyId) {
// use the default SSSS key if set
keyInfo = keyInfos[keyId];
if (!keyInfo) {
// if the default key is not available, pretend the default key
// isn't set
keyId = undefined;
keyId = null;
}
}
if (!keyId) {
@ -132,7 +124,7 @@ async function getSecretStorageKey(
}
if (dehydrationCache.key) {
if (await MatrixClientPeg.get().checkSecretStorageKey(dehydrationCache.key, keyInfo)) {
if (await MatrixClientPeg.safeGet().checkSecretStorageKey(dehydrationCache.key, keyInfo)) {
cacheSecretStorageKey(keyId, keyInfo, dehydrationCache.key);
return [keyId, dehydrationCache.key];
}
@ -155,16 +147,16 @@ async function getSecretStorageKey(
/* props= */
{
keyInfo,
checkPrivateKey: async (input: KeyParams) => {
checkPrivateKey: async (input: KeyParams): Promise<boolean> => {
const key = await inputToKey(input);
return MatrixClientPeg.get().checkSecretStorageKey(key, keyInfo);
return MatrixClientPeg.safeGet().checkSecretStorageKey(key, keyInfo);
},
},
/* className= */ null,
/* className= */ undefined,
/* isPriorityModal= */ false,
/* isStaticModal= */ false,
/* options= */ {
onBeforeClose: async (reason) => {
onBeforeClose: async (reason): Promise<boolean> => {
if (reason === "backgroundClick") {
return confirmToDismiss();
}
@ -186,7 +178,7 @@ async function getSecretStorageKey(
export async function getDehydrationKey(
keyInfo: ISecretStorageKeyInfo,
checkFunc: (Uint8Array) => void,
checkFunc: (data: Uint8Array) => void,
): Promise<Uint8Array> {
const keyFromCustomisations = SecurityCustomisations.getSecretStorageKey?.();
if (keyFromCustomisations) {
@ -200,7 +192,7 @@ export async function getDehydrationKey(
/* props= */
{
keyInfo,
checkPrivateKey: async (input) => {
checkPrivateKey: async (input: KeyParams): Promise<boolean> => {
const key = await inputToKey(input);
try {
checkFunc(key);
@ -210,11 +202,11 @@ export async function getDehydrationKey(
}
},
},
/* className= */ null,
/* className= */ undefined,
/* isPriorityModal= */ false,
/* isStaticModal= */ false,
/* options= */ {
onBeforeClose: async (reason) => {
onBeforeClose: async (reason): Promise<boolean> => {
if (reason === "backgroundClick") {
return confirmToDismiss();
}
@ -234,11 +226,7 @@ export async function getDehydrationKey(
return key;
}
function cacheSecretStorageKey(
keyId: string,
keyInfo: ISecretStorageKeyInfo,
key: Uint8Array,
): void {
function cacheSecretStorageKey(keyId: string, keyInfo: ISecretStorageKeyInfo, key: Uint8Array): void {
if (isCachingAllowed()) {
secretStorageKeys[keyId] = key;
secretStorageKeyInfo[keyId] = keyInfo;
@ -250,10 +238,10 @@ async function onSecretRequested(
deviceId: string,
requestId: string,
name: string,
deviceTrust: DeviceTrustLevel,
): Promise<string> {
deviceTrust: DeviceVerificationStatus,
): Promise<string | undefined> {
logger.log("onSecretRequested", userId, deviceId, requestId, name, deviceTrust);
const client = MatrixClientPeg.get();
const client = MatrixClientPeg.safeGet();
if (userId !== client.getUserId()) {
return;
}
@ -267,23 +255,19 @@ async function onSecretRequested(
name === "m.cross_signing.user_signing"
) {
const callbacks = client.getCrossSigningCacheCallbacks();
if (!callbacks.getCrossSigningKeyCache) return;
if (!callbacks?.getCrossSigningKeyCache) return;
const keyId = name.replace("m.cross_signing.", "");
const key = await callbacks.getCrossSigningKeyCache(keyId);
if (!key) {
logger.log(
`${keyId} requested by ${deviceId}, but not found in cache`,
);
logger.log(`${keyId} requested by ${deviceId}, but not found in cache`);
}
return key && encodeBase64(key);
return key ? encodeBase64(key) : undefined;
} else if (name === "m.megolm_backup.v1") {
const key = await client.crypto.getSessionBackupPrivateKey();
const key = await client.crypto?.getSessionBackupPrivateKey();
if (!key) {
logger.log(
`session backup key requested by ${deviceId}, but not found in cache`,
);
logger.log(`session backup key requested by ${deviceId}, but not found in cache`);
}
return key && encodeBase64(key);
return key ? encodeBase64(key) : undefined;
}
logger.warn("onSecretRequested didn't recognise the secret named ", name);
}
@ -296,11 +280,18 @@ export const crossSigningCallbacks: ICryptoCallbacks = {
};
export async function promptForBackupPassphrase(): Promise<Uint8Array> {
let key: Uint8Array;
let key!: Uint8Array;
const { finished } = Modal.createDialog(RestoreKeyBackupDialog, {
showSummary: false, keyCallback: k => key = k,
}, null, /* priority = */ false, /* static = */ true);
const { finished } = Modal.createDialog(
RestoreKeyBackupDialog,
{
showSummary: false,
keyCallback: (k: Uint8Array) => (key = k),
},
undefined,
/* priority = */ false,
/* static = */ true,
);
const success = await finished;
if (!success) throw new Error("Key backup prompt cancelled");
@ -308,6 +299,28 @@ export async function promptForBackupPassphrase(): Promise<Uint8Array> {
return key;
}
/**
* Carry out an operation that may require multiple accesses to secret storage, caching the key.
*
* Use this helper to wrap an operation that may require multiple accesses to secret storage; the user will be prompted
* to enter the 4S key or passphrase on the first access, and the key will be cached for the rest of the operation.
*
* @param func - The operation to be wrapped.
*/
export async function withSecretStorageKeyCache<T>(func: () => Promise<T>): Promise<T> {
secretStorageBeingAccessed = true;
try {
return await func();
} finally {
// Clear secret storage key cache now that work is complete
secretStorageBeingAccessed = false;
if (!isCachingAllowed()) {
secretStorageKeys = {};
secretStorageKeyInfo = {};
}
}
}
/**
* This helper should be used whenever you need to access secret storage. It
* ensures that secret storage (and also cross-signing since they each depend on
@ -329,28 +342,32 @@ export async function promptForBackupPassphrase(): Promise<Uint8Array> {
* bootstrapped. Optional.
* @param {bool} [forceReset] Reset secret storage even if it's already set up
*/
export async function accessSecretStorage(func = async () => { }, forceReset = false) {
const cli = MatrixClientPeg.get();
secretStorageBeingAccessed = true;
export async function accessSecretStorage(func = async (): Promise<void> => {}, forceReset = false): Promise<void> {
await withSecretStorageKeyCache(() => doAccessSecretStorage(func, forceReset));
}
/** Helper for {@link #accessSecretStorage} */
async function doAccessSecretStorage(func: () => Promise<void>, forceReset: boolean): Promise<void> {
try {
const cli = MatrixClientPeg.safeGet();
if (!(await cli.hasSecretStorageKey()) || forceReset) {
// This dialog calls bootstrap itself after guiding the user through
// passphrase creation.
const { finished } = Modal.createDialogAsync(
import(
"./async-components/views/dialogs/security/CreateSecretStorageDialog"
) as unknown as Promise<ComponentType<{}>>,
import("./async-components/views/dialogs/security/CreateSecretStorageDialog") as unknown as Promise<
typeof CreateSecretStorageDialog
>,
{
forceReset,
},
null,
undefined,
/* priority = */ false,
/* static = */ true,
/* options = */ {
onBeforeClose: async (reason) => {
onBeforeClose: async (reason): Promise<boolean> => {
// If Secure Backup is required, you cannot leave the modal.
if (reason === "backgroundClick") {
return !isSecureBackupRequired();
return !isSecureBackupRequired(cli);
}
return true;
},
@ -361,10 +378,15 @@ export async function accessSecretStorage(func = async () => { }, forceReset = f
throw new Error("Secret storage creation canceled");
}
} else {
await cli.bootstrapCrossSigning({
authUploadDeviceSigningKeys: async (makeRequest) => {
const crypto = cli.getCrypto();
if (!crypto) {
throw new Error("End-to-end encryption is disabled - unable to access secret storage.");
}
await crypto.bootstrapCrossSigning({
authUploadDeviceSigningKeys: async (makeRequest): Promise<void> => {
const { finished } = Modal.createDialog(InteractiveAuthDialog, {
title: _t("Setting up keys"),
title: _t("encryption|bootstrap_title"),
matrixClient: cli,
makeRequest,
});
@ -374,7 +396,7 @@ export async function accessSecretStorage(func = async () => { }, forceReset = f
}
},
});
await cli.bootstrapSecretStorage({
await crypto.bootstrapSecretStorage({
getKeyBackupPassphrase: promptForBackupPassphrase,
});
@ -401,20 +423,11 @@ export async function accessSecretStorage(func = async () => { }, forceReset = f
logger.error(e);
// Re-throw so that higher level logic can abort as needed
throw e;
} finally {
// Clear secret storage key cache now that work is complete
secretStorageBeingAccessed = false;
if (!isCachingAllowed()) {
secretStorageKeys = {};
secretStorageKeyInfo = {};
}
}
}
// FIXME: this function name is a bit of a mouthful
export async function tryToUnlockSecretStorageWithDehydrationKey(
client: MatrixClient,
): Promise<void> {
export async function tryToUnlockSecretStorageWithDehydrationKey(client: MatrixClient): Promise<void> {
const key = dehydrationCache.key;
let restoringBackup = false;
if (key && (await client.isSecretStorageReady())) {
@ -437,15 +450,14 @@ export async function tryToUnlockSecretStorageWithDehydrationKey(
if (backupInfo) {
restoringBackup = true;
// don't await, because this can take a long time
client.restoreKeyBackupWithSecretStorage(backupInfo)
.finally(() => {
secretStorageBeingAccessed = false;
nonInteractive = false;
if (!isCachingAllowed()) {
secretStorageKeys = {};
secretStorageKeyInfo = {};
}
});
client.restoreKeyBackupWithSecretStorage(backupInfo).finally(() => {
secretStorageBeingAccessed = false;
nonInteractive = false;
if (!isCachingAllowed()) {
secretStorageKeys = {};
secretStorageKeyInfo = {};
}
});
}
} finally {
dehydrationCache = {};

View file

@ -15,7 +15,7 @@ limitations under the License.
*/
import { clamp } from "lodash";
import { MatrixEvent } from "matrix-js-sdk/src/models/event";
import { MatrixEvent } from "matrix-js-sdk/src/matrix";
import { logger } from "matrix-js-sdk/src/logger";
import { SerializedPart } from "./editor/parts";
@ -27,19 +27,19 @@ interface IHistoryItem {
}
export default class SendHistoryManager {
history: Array<IHistoryItem> = [];
prefix: string;
lastIndex = 0; // used for indexing the storage
currentIndex = 0; // used for indexing the loaded validated history Array
public history: Array<IHistoryItem> = [];
public prefix: string;
public lastIndex = 0; // used for indexing the storage
public currentIndex = 0; // used for indexing the loaded validated history Array
constructor(roomId: string, prefix: string) {
public constructor(roomId: string, prefix: string) {
this.prefix = prefix + roomId;
// TODO: Performance issues?
let index = 0;
let itemJSON;
while (itemJSON = sessionStorage.getItem(`${this.prefix}[${index}]`)) {
while ((itemJSON = sessionStorage.getItem(`${this.prefix}[${index}]`))) {
try {
this.history.push(JSON.parse(itemJSON));
} catch (e) {
@ -53,14 +53,14 @@ export default class SendHistoryManager {
this.currentIndex = this.lastIndex + 1;
}
static createItem(model: EditorModel, replyEvent?: MatrixEvent): IHistoryItem {
public static createItem(model: EditorModel, replyEvent?: MatrixEvent): IHistoryItem {
return {
parts: model.serializeParts(),
replyEventId: replyEvent ? replyEvent.getId() : undefined,
};
}
save(editorModel: EditorModel, replyEvent?: MatrixEvent) {
public save(editorModel: EditorModel, replyEvent?: MatrixEvent): void {
const item = SendHistoryManager.createItem(editorModel, replyEvent);
this.history.push(item);
this.currentIndex = this.history.length;
@ -68,7 +68,7 @@ export default class SendHistoryManager {
sessionStorage.setItem(`${this.prefix}[${this.lastIndex}]`, JSON.stringify(item));
}
getItem(offset: number): IHistoryItem {
public getItem(offset: number): IHistoryItem {
this.currentIndex = clamp(this.currentIndex + offset, 0, this.history.length - 1);
return this.history[this.currentIndex];
}

File diff suppressed because it is too large Load diff

View file

@ -44,15 +44,17 @@ limitations under the License.
* list ops)
*/
import { MatrixClient } from 'matrix-js-sdk/src/matrix';
import { EventType } from 'matrix-js-sdk/src/@types/event';
import { MatrixClient, EventType } from "matrix-js-sdk/src/matrix";
import {
MSC3575Filter,
MSC3575List,
MSC3575_STATE_KEY_LAZY,
MSC3575_STATE_KEY_ME,
MSC3575_WILDCARD,
SlidingSync,
} from 'matrix-js-sdk/src/sliding-sync';
} from "matrix-js-sdk/src/sliding-sync";
import { logger } from "matrix-js-sdk/src/logger";
import { IDeferred, defer } from 'matrix-js-sdk/src/utils';
import { defer, sleep } from "matrix-js-sdk/src/utils";
// how long to long poll for
const SLIDING_SYNC_TIMEOUT_MS = 20 * 1000;
@ -60,19 +62,42 @@ const SLIDING_SYNC_TIMEOUT_MS = 20 * 1000;
// the things to fetch when a user clicks on a room
const DEFAULT_ROOM_SUBSCRIPTION_INFO = {
timeline_limit: 50,
required_state: [
["*", "*"], // all events
],
// missing required_state which will change depending on the kind of room
include_old_rooms: {
timeline_limit: 0,
required_state: [ // state needed to handle space navigation and tombstone chains
required_state: [
// state needed to handle space navigation and tombstone chains
[EventType.RoomCreate, ""],
[EventType.RoomTombstone, ""],
[EventType.SpaceChild, "*"],
[EventType.SpaceParent, "*"],
[EventType.SpaceChild, MSC3575_WILDCARD],
[EventType.SpaceParent, MSC3575_WILDCARD],
[EventType.RoomMember, MSC3575_STATE_KEY_ME],
],
},
};
// lazy load room members so rooms like Matrix HQ don't take forever to load
const UNENCRYPTED_SUBSCRIPTION_NAME = "unencrypted";
const UNENCRYPTED_SUBSCRIPTION = Object.assign(
{
required_state: [
[MSC3575_WILDCARD, MSC3575_WILDCARD], // all events
[EventType.RoomMember, MSC3575_STATE_KEY_ME], // except for m.room.members, get our own membership
[EventType.RoomMember, MSC3575_STATE_KEY_LAZY], // ...and lazy load the rest.
],
},
DEFAULT_ROOM_SUBSCRIPTION_INFO,
);
// we need all the room members in encrypted rooms because we need to know which users to encrypt
// messages for.
const ENCRYPTED_SUBSCRIPTION = Object.assign(
{
required_state: [
[MSC3575_WILDCARD, MSC3575_WILDCARD], // all events
],
},
DEFAULT_ROOM_SUBSCRIPTION_INFO,
);
export type PartialSlidingSyncRequest = {
filters?: MSC3575Filter;
@ -91,16 +116,10 @@ export class SlidingSyncManager {
public static readonly ListSearch = "search_list";
private static readonly internalInstance = new SlidingSyncManager();
public slidingSync: SlidingSync;
private client: MatrixClient;
private listIdToIndex: Record<string, number>;
public slidingSync?: SlidingSync;
private client?: MatrixClient;
private configureDefer: IDeferred<void>;
public constructor() {
this.listIdToIndex = {};
this.configureDefer = defer<void>();
}
private configureDefer = defer<void>();
public static get instance(): SlidingSyncManager {
return SlidingSyncManager.internalInstance;
@ -108,16 +127,20 @@ export class SlidingSyncManager {
public configure(client: MatrixClient, proxyUrl: string): SlidingSync {
this.client = client;
this.listIdToIndex = {};
// by default use the encrypted subscription as that gets everything, which is a safer
// default than potentially missing member events.
this.slidingSync = new SlidingSync(
proxyUrl, [], DEFAULT_ROOM_SUBSCRIPTION_INFO, client, SLIDING_SYNC_TIMEOUT_MS,
proxyUrl,
new Map(),
ENCRYPTED_SUBSCRIPTION,
client,
SLIDING_SYNC_TIMEOUT_MS,
);
this.slidingSync.addCustomSubscription(UNENCRYPTED_SUBSCRIPTION_NAME, UNENCRYPTED_SUBSCRIPTION);
// set the space list
this.slidingSync.setList(this.getOrAllocateListIndex(SlidingSyncManager.ListSpaces), {
this.slidingSync.setList(SlidingSyncManager.ListSpaces, {
ranges: [[0, 20]],
sort: [
"by_name",
],
sort: ["by_name"],
slow_get_all_rooms: true,
timeline_limit: 0,
required_state: [
@ -126,18 +149,18 @@ export class SlidingSyncManager {
[EventType.RoomTombstone, ""], // lets JS SDK hide rooms which are dead
[EventType.RoomEncryption, ""], // lets rooms be configured for E2EE correctly
[EventType.RoomCreate, ""], // for isSpaceRoom checks
[EventType.SpaceChild, "*"], // all space children
[EventType.SpaceParent, "*"], // all space parents
[EventType.RoomMember, this.client.getUserId()!], // lets the client calculate that we are in fact in the room
[EventType.SpaceChild, MSC3575_WILDCARD], // all space children
[EventType.SpaceParent, MSC3575_WILDCARD], // all space parents
[EventType.RoomMember, MSC3575_STATE_KEY_ME], // lets the client calculate that we are in fact in the room
],
include_old_rooms: {
timeline_limit: 0,
required_state: [
[EventType.RoomCreate, ""],
[EventType.RoomTombstone, ""], // lets JS SDK hide rooms which are dead
[EventType.SpaceChild, "*"], // all space children
[EventType.SpaceParent, "*"], // all space parents
[EventType.RoomMember, this.client.getUserId()!], // lets the client calculate that we are in fact in the room
[EventType.SpaceChild, MSC3575_WILDCARD], // all space children
[EventType.SpaceParent, MSC3575_WILDCARD], // all space parents
[EventType.RoomMember, MSC3575_STATE_KEY_ME], // lets the client calculate that we are in fact in the room
],
},
filters: {
@ -148,55 +171,20 @@ export class SlidingSyncManager {
return this.slidingSync;
}
public listIdForIndex(index: number): string | null {
for (const listId in this.listIdToIndex) {
if (this.listIdToIndex[listId] === index) {
return listId;
}
}
return null;
}
/**
* Allocate or retrieve the list index for an arbitrary list ID. For example SlidingSyncManager.ListSpaces
* @param listId A string which represents the list.
* @returns The index to use when registering lists or listening for callbacks.
*/
public getOrAllocateListIndex(listId: string): number {
let index = this.listIdToIndex[listId];
if (index === undefined) {
// assign next highest index
index = -1;
for (const id in this.listIdToIndex) {
const listIndex = this.listIdToIndex[id];
if (listIndex > index) {
index = listIndex;
}
}
index++;
this.listIdToIndex[listId] = index;
}
return index;
}
/**
* Ensure that this list is registered.
* @param listIndex The list index to register
* @param listKey The list key to register
* @param updateArgs The fields to update on the list.
* @returns The complete list request params
*/
public async ensureListRegistered(
listIndex: number, updateArgs: PartialSlidingSyncRequest,
): Promise<MSC3575List> {
logger.debug("ensureListRegistered:::", listIndex, updateArgs);
public async ensureListRegistered(listKey: string, updateArgs: PartialSlidingSyncRequest): Promise<MSC3575List> {
logger.debug("ensureListRegistered:::", listKey, updateArgs);
await this.configureDefer.promise;
let list = this.slidingSync.getList(listIndex);
let list = this.slidingSync!.getListParams(listKey);
if (!list) {
list = {
ranges: [[0, 20]],
sort: [
"by_notification_level", "by_recency",
],
sort: ["by_notification_level", "by_recency"],
timeline_limit: 1, // most recent message display: though this seems to only be needed for favourites?
required_state: [
[EventType.RoomJoinRules, ""], // the public icon on the room list
@ -204,16 +192,16 @@ export class SlidingSyncManager {
[EventType.RoomTombstone, ""], // lets JS SDK hide rooms which are dead
[EventType.RoomEncryption, ""], // lets rooms be configured for E2EE correctly
[EventType.RoomCreate, ""], // for isSpaceRoom checks
[EventType.RoomMember, this.client.getUserId()], // lets the client calculate that we are in fact in the room
[EventType.RoomMember, MSC3575_STATE_KEY_ME], // lets the client calculate that we are in fact in the room
],
include_old_rooms: {
timeline_limit: 0,
required_state: [
[EventType.RoomCreate, ""],
[EventType.RoomTombstone, ""], // lets JS SDK hide rooms which are dead
[EventType.SpaceChild, "*"], // all space children
[EventType.SpaceParent, "*"], // all space parents
[EventType.RoomMember, this.client.getUserId()!], // lets the client calculate that we are in fact in the room
[EventType.SpaceChild, MSC3575_WILDCARD], // all space children
[EventType.SpaceParent, MSC3575_WILDCARD], // all space parents
[EventType.RoomMember, MSC3575_STATE_KEY_ME], // lets the client calculate that we are in fact in the room
],
},
};
@ -231,27 +219,39 @@ export class SlidingSyncManager {
try {
// if we only have range changes then call a different function so we don't nuke the list from before
if (updateArgs.ranges && Object.keys(updateArgs).length === 1) {
await this.slidingSync.setListRanges(listIndex, updateArgs.ranges);
await this.slidingSync!.setListRanges(listKey, updateArgs.ranges);
} else {
await this.slidingSync.setList(listIndex, list);
await this.slidingSync!.setList(listKey, list);
}
} catch (err) {
logger.debug("ensureListRegistered: update failed txn_id=", err);
}
return this.slidingSync.getList(listIndex);
return this.slidingSync!.getListParams(listKey)!;
}
public async setRoomVisible(roomId: string, visible: boolean): Promise<string> {
await this.configureDefer.promise;
const subscriptions = this.slidingSync.getRoomSubscriptions();
const subscriptions = this.slidingSync!.getRoomSubscriptions();
if (visible) {
subscriptions.add(roomId);
} else {
subscriptions.delete(roomId);
}
logger.log("SlidingSync setRoomVisible:", roomId, visible);
const p = this.slidingSync.modifyRoomSubscriptions(subscriptions);
if (this.client.getRoom(roomId)) {
const room = this.client?.getRoom(roomId);
let shouldLazyLoad = !this.client?.isRoomEncrypted(roomId);
if (!room) {
// default to safety: request all state if we can't work it out. This can happen if you
// refresh the app whilst viewing a room: we call setRoomVisible before we know anything
// about the room.
shouldLazyLoad = false;
}
logger.log("SlidingSync setRoomVisible:", roomId, visible, "shouldLazyLoad:", shouldLazyLoad);
if (shouldLazyLoad) {
// lazy load this room
this.slidingSync!.useCustomSubscription(roomId, UNENCRYPTED_SUBSCRIPTION_NAME);
}
const p = this.slidingSync!.modifyRoomSubscriptions(subscriptions);
if (room) {
return roomId; // we have data already for this room, show immediately e.g it's in a list
}
try {
@ -262,4 +262,65 @@ export class SlidingSyncManager {
}
return roomId;
}
/**
* Retrieve all rooms on the user's account. Used for pre-populating the local search cache.
* Retrieval is gradual over time.
* @param batchSize The number of rooms to return in each request.
* @param gapBetweenRequestsMs The number of milliseconds to wait between requests.
*/
public async startSpidering(batchSize: number, gapBetweenRequestsMs: number): Promise<void> {
await sleep(gapBetweenRequestsMs); // wait a bit as this is called on first render so let's let things load
let startIndex = batchSize;
let hasMore = true;
let firstTime = true;
while (hasMore) {
const endIndex = startIndex + batchSize - 1;
try {
const ranges = [
[0, batchSize - 1],
[startIndex, endIndex],
];
if (firstTime) {
await this.slidingSync!.setList(SlidingSyncManager.ListSearch, {
// e.g [0,19] [20,39] then [0,19] [40,59]. We keep [0,20] constantly to ensure
// any changes to the list whilst spidering are caught.
ranges: ranges,
sort: [
"by_recency", // this list isn't shown on the UI so just sorting by timestamp is enough
],
timeline_limit: 0, // we only care about the room details, not messages in the room
required_state: [
[EventType.RoomJoinRules, ""], // the public icon on the room list
[EventType.RoomAvatar, ""], // any room avatar
[EventType.RoomTombstone, ""], // lets JS SDK hide rooms which are dead
[EventType.RoomEncryption, ""], // lets rooms be configured for E2EE correctly
[EventType.RoomCreate, ""], // for isSpaceRoom checks
[EventType.RoomMember, MSC3575_STATE_KEY_ME], // lets the client calculate that we are in fact in the room
],
// we don't include_old_rooms here in an effort to reduce the impact of spidering all rooms
// on the user's account. This means some data in the search dialog results may be inaccurate
// e.g membership of space, but this will be corrected when the user clicks on the room
// as the direct room subscription does include old room iterations.
filters: {
// we get spaces via a different list, so filter them out
not_room_types: ["m.space"],
},
});
} else {
await this.slidingSync!.setListRanges(SlidingSyncManager.ListSearch, ranges);
}
} catch (err) {
// do nothing, as we reject only when we get interrupted but that's fine as the next
// request will include our data
} finally {
// gradually request more over time, even on errors.
await sleep(gapBetweenRequestsMs);
}
const listData = this.slidingSync!.getListData(SlidingSyncManager.ListSearch)!;
hasMore = endIndex + 1 < listData.joinedCount;
startIndex += batchSize;
firstTime = false;
}
}
}

View file

@ -14,12 +14,11 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import classNames from 'classnames';
import { SERVICE_TYPES } from 'matrix-js-sdk/src/service-types';
import classNames from "classnames";
import { SERVICE_TYPES, MatrixClient } from "matrix-js-sdk/src/matrix";
import { logger } from "matrix-js-sdk/src/logger";
import { MatrixClientPeg } from './MatrixClientPeg';
import Modal from './Modal';
import Modal from "./Modal";
import TermsDialog from "./components/views/dialogs/TermsDialog";
export class TermsNotSignedError extends Error {}
@ -34,8 +33,11 @@ export class Service {
* @param {string} baseUrl The Base URL of the service (ie. before '/_matrix')
* @param {string} accessToken The user's access token for the service
*/
constructor(public serviceType: SERVICE_TYPES, public baseUrl: string, public accessToken: string) {
}
public constructor(
public serviceType: SERVICE_TYPES,
public baseUrl: string,
public accessToken: string,
) {}
}
export interface LocalisedPolicy {
@ -53,11 +55,13 @@ export type Policies = {
[policy: string]: Policy;
};
export type ServicePolicyPair = {
policies: Policies;
service: Service;
};
export type TermsInteractionCallback = (
policiesAndServicePairs: {
service: Service;
policies: Policies;
}[],
policiesAndServicePairs: ServicePolicyPair[],
agreedUrls: string[],
extraClassNames?: string,
) => Promise<string[]>;
@ -65,6 +69,7 @@ export type TermsInteractionCallback = (
/**
* Start a flow where the user is presented with terms & conditions for some services
*
* @param client The Matrix Client instance of the logged-in user
* @param {Service[]} services Object with keys 'serviceType', 'baseUrl', 'accessToken'
* @param {function} interactionCallback Function called with:
* * an array of { service: {Service}, policies: {terms response from API} }
@ -74,12 +79,11 @@ export type TermsInteractionCallback = (
* if they cancel.
*/
export async function startTermsFlow(
client: MatrixClient,
services: Service[],
interactionCallback: TermsInteractionCallback = dialogTermsInteractionCallback,
) {
const termsPromises = services.map(
(s) => MatrixClientPeg.get().getTerms(s.serviceType, s.baseUrl),
);
): Promise<void> {
const termsPromises = services.map((s) => client.getTerms(s.serviceType, s.baseUrl));
/*
* a /terms response looks like:
@ -101,10 +105,12 @@ export async function startTermsFlow(
*/
const terms: { policies: Policies }[] = await Promise.all(termsPromises);
const policiesAndServicePairs = terms.map((t, i) => { return { 'service': services[i], 'policies': t.policies }; });
const policiesAndServicePairs = terms.map((t, i) => {
return { service: services[i], policies: t.policies };
});
// fetch the set of agreed policy URLs from account data
const currentAcceptedTerms = await MatrixClientPeg.get().getAccountData('m.accepted_terms');
const currentAcceptedTerms = await client.getAccountData("m.accepted_terms");
let agreedUrlSet: Set<string>;
if (!currentAcceptedTerms || !currentAcceptedTerms.getContent() || !currentAcceptedTerms.getContent().accepted) {
agreedUrlSet = new Set();
@ -118,13 +124,13 @@ export async function startTermsFlow(
// but then they'd assume they can un-check the boxes to un-agree to a policy,
// but that is not a thing the API supports, so probably best to just show
// things they've not agreed to yet.
const unagreedPoliciesAndServicePairs = [];
const unagreedPoliciesAndServicePairs: ServicePolicyPair[] = [];
for (const { service, policies } of policiesAndServicePairs) {
const unagreedPolicies = {};
const unagreedPolicies: Policies = {};
for (const [policyName, policy] of Object.entries(policies)) {
let policyAgreed = false;
for (const lang of Object.keys(policy)) {
if (lang === 'version') continue;
if (lang === "version") continue;
if (agreedUrlSet.has(policy[lang].url)) {
policyAgreed = true;
break;
@ -143,7 +149,7 @@ export async function startTermsFlow(
const newlyAgreedUrls = await interactionCallback(unagreedPoliciesAndServicePairs, [...agreedUrlSet]);
logger.log("User has agreed to URLs", newlyAgreedUrls);
// Merge with previously agreed URLs
newlyAgreedUrls.forEach(url => agreedUrlSet.add(url));
newlyAgreedUrls.forEach((url) => agreedUrlSet.add(url));
} else {
logger.log("User has already agreed to all required policies");
}
@ -151,7 +157,7 @@ export async function startTermsFlow(
// We only ever add to the set of URLs, so if anything has changed then we'd see a different length
if (agreedUrlSet.size !== numAcceptedBeforeAgreement) {
const newAcceptedTerms = { accepted: Array.from(agreedUrlSet) };
await MatrixClientPeg.get().setAccountData('m.accepted_terms', newAcceptedTerms);
await client.setAccountData("m.accepted_terms", newAcceptedTerms);
}
const agreePromises = policiesAndServicePairs.map((policiesAndService) => {
@ -161,7 +167,7 @@ export async function startTermsFlow(
const urlsForService = Array.from(agreedUrlSet).filter((url) => {
for (const policy of Object.values(policiesAndService.policies)) {
for (const lang of Object.keys(policy)) {
if (lang === 'version') continue;
if (lang === "version") continue;
if (policy[lang].url === url) return true;
}
}
@ -170,14 +176,14 @@ export async function startTermsFlow(
if (urlsForService.length === 0) return Promise.resolve();
return MatrixClientPeg.get().agreeToTerms(
return client.agreeToTerms(
policiesAndService.service.serviceType,
policiesAndService.service.baseUrl,
policiesAndService.service.accessToken,
urlsForService,
);
});
return Promise.all(agreePromises);
await Promise.all(agreePromises);
}
export async function dialogTermsInteractionCallback(
@ -190,13 +196,17 @@ export async function dialogTermsInteractionCallback(
): Promise<string[]> {
logger.log("Terms that need agreement", policiesAndServicePairs);
const { finished } = Modal.createDialog<[boolean, string[]]>(TermsDialog, {
policiesAndServicePairs,
agreedUrls,
}, classNames("mx_TermsDialog", extraClassNames));
const { finished } = Modal.createDialog(
TermsDialog,
{
policiesAndServicePairs,
agreedUrls,
},
classNames("mx_TermsDialog", extraClassNames),
);
const [done, _agreedUrls] = await finished;
if (!done) {
if (!done || !_agreedUrls) {
throw new TermsNotSignedError();
}
return _agreedUrls;

File diff suppressed because it is too large Load diff

23
src/Typeguards.ts Normal file
View file

@ -0,0 +1,23 @@
/*
Copyright 2023 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.
*/
export function isNotNull<T>(arg: T): arg is Exclude<T, null> {
return arg !== null;
}
export function isNotUndefined<T>(arg: T): arg is Exclude<T, undefined> {
return arg !== undefined;
}

View file

@ -1,5 +1,5 @@
/*
Copyright 2015 - 2021 The Matrix.org Foundation C.I.C.
Copyright 2015 - 2023 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,25 +14,24 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import { Room } from "matrix-js-sdk/src/models/room";
import { MatrixEvent } from "matrix-js-sdk/src/models/event";
import { EventType } from "matrix-js-sdk/src/@types/event";
import { M_BEACON } from "matrix-js-sdk/src/@types/beacon";
import { M_BEACON, Room, Thread, MatrixEvent, EventType, MatrixClient } from "matrix-js-sdk/src/matrix";
import { logger } from "matrix-js-sdk/src/logger";
import { MatrixClientPeg } from "./MatrixClientPeg";
import shouldHideEvent from './shouldHideEvent';
import shouldHideEvent from "./shouldHideEvent";
import { haveRendererForEvent } from "./events/EventTileFactory";
import SettingsStore from "./settings/SettingsStore";
import { RoomNotifState, getRoomNotifsState } from "./RoomNotifs";
/**
* Returns true if this event arriving in a room should affect the room's
* count of unread messages
*
* @param client The Matrix Client instance of the logged-in user
* @param {Object} ev The event
* @returns {boolean} True if the given event should affect the unread message count
*/
export function eventTriggersUnreadCount(ev: MatrixEvent): boolean {
if (ev.getSender() === MatrixClientPeg.get().credentials.userId) {
export function eventTriggersUnreadCount(client: MatrixClient, ev: MatrixEvent): boolean {
if (ev.getSender() === client.getSafeUserId()) {
return false;
}
@ -49,68 +48,113 @@ export function eventTriggersUnreadCount(ev: MatrixEvent): boolean {
}
if (ev.isRedacted()) return false;
return haveRendererForEvent(ev, false /* hidden messages should never trigger unread counts anyways */);
return haveRendererForEvent(ev, client, false /* hidden messages should never trigger unread counts anyways */);
}
export function doesRoomHaveUnreadMessages(room: Room): boolean {
export function doesRoomHaveUnreadMessages(room: Room, includeThreads: boolean): boolean {
if (SettingsStore.getValue("feature_sliding_sync")) {
// TODO: https://github.com/vector-im/element-web/issues/23207
// Sliding Sync doesn't support unread indicator dots (yet...)
return false;
}
const myUserId = MatrixClientPeg.get().getUserId();
// get the most recent read receipt sent by our account.
// N.B. this is NOT a read marker (RM, aka "read up to marker"),
// despite the name of the method :((
const readUpToId = room.getEventReadUpTo(myUserId);
if (!SettingsStore.getValue("feature_thread")) {
// as we don't send RRs for our own messages, make sure we special case that
// if *we* sent the last message into the room, we consider it not unread!
// Should fix: https://github.com/vector-im/element-web/issues/3263
// https://github.com/vector-im/element-web/issues/2427
// ...and possibly some of the others at
// https://github.com/vector-im/element-web/issues/3363
if (room.timeline.length && room.timeline[room.timeline.length - 1].getSender() === myUserId) {
return false;
}
const toCheck: Array<Room | Thread> = [room];
if (includeThreads) {
toCheck.push(...room.getThreads());
}
// if the read receipt relates to an event is that part of a thread
// we consider that there are no unread messages
// This might be a false negative, but probably the best we can do until
// the read receipts have evolved to cater for threads
const event = room.findEventById(readUpToId);
if (event?.getThread()) {
return false;
}
// this just looks at whatever history we have, which if we've only just started
// up probably won't be very much, so if the last couple of events are ones that
// don't count, we don't know if there are any events that do count between where
// we have and the read receipt. We could fetch more history to try & find out,
// but currently we just guess.
// Loop through messages, starting with the most recent...
for (let i = room.timeline.length - 1; i >= 0; --i) {
const ev = room.timeline[i];
if (ev.getId() == readUpToId) {
// If we've read up to this event, there's nothing more recent
// that counts and we can stop looking because the user's read
// this and everything before.
return false;
} else if (!shouldHideEvent(ev) && eventTriggersUnreadCount(ev)) {
// We've found a message that counts before we hit
// the user's read receipt, so this room is definitely unread.
for (const withTimeline of toCheck) {
if (doesTimelineHaveUnreadMessages(room, withTimeline.timeline)) {
// We found an unread, so the room is unread
return true;
}
}
// If we got here, we didn't find a message that counted but didn't find
// the user's read receipt either, so we guess and say that the room is
// unread on the theory that false positives are better than false
// negatives here.
return true;
// If we got here then no timelines were found with unread messages.
return false;
}
function doesTimelineHaveUnreadMessages(room: Room, timeline: Array<MatrixEvent>): boolean {
// The room is a space, let's ignore it
if (room.isSpaceRoom()) return false;
const myUserId = room.client.getSafeUserId();
const latestImportantEventId = findLatestImportantEvent(room.client, timeline)?.getId();
if (latestImportantEventId) {
return !room.hasUserReadEvent(myUserId, latestImportantEventId);
} else {
// We couldn't find an important event to check - check the unimportant ones.
const earliestUnimportantEventId = timeline.at(0)?.getId();
if (!earliestUnimportantEventId) {
// There are no events in this timeline - it is uninitialised, so we
// consider it read
return false;
} else if (room.hasUserReadEvent(myUserId, earliestUnimportantEventId)) {
// Some of the unimportant events are read, and there are no
// important ones after them, so we've read everything.
return false;
} else {
// We have events. and none of them are read. We must guess that
// the timeline is unread, because there could be older unread
// important events that we don't have loaded.
logger.warn("Falling back to unread room because of no read receipt or counting message found", {
roomId: room.roomId,
earliestUnimportantEventId: earliestUnimportantEventId,
});
return true;
}
}
}
/**
* Returns true if this room has unread threads.
* @param room The room to check
* @returns {boolean} True if the given room has unread threads
*/
export function doesRoomHaveUnreadThreads(room: Room): boolean {
if (getRoomNotifsState(room.client, room.roomId) === RoomNotifState.Mute) {
// No unread for muted rooms, nor their threads
// NB. This logic duplicated in RoomNotifs.determineUnreadState
return false;
}
for (const thread of room.getThreads()) {
if (doesTimelineHaveUnreadMessages(room, thread.timeline)) {
// We found an unread, so the room has an unread thread
return true;
}
}
// If we got here then no threads were found with unread messages.
return false;
}
export function doesRoomOrThreadHaveUnreadMessages(roomOrThread: Room | Thread): boolean {
const room = roomOrThread instanceof Thread ? roomOrThread.room : roomOrThread;
const events = roomOrThread instanceof Thread ? roomOrThread.timeline : room.getLiveTimeline().getEvents();
return doesTimelineHaveUnreadMessages(room, events);
}
/**
* Look backwards through the timeline and find the last event that is
* "important" in the sense of isImportantEvent.
*
* @returns the latest important event, or null if none were found
*/
function findLatestImportantEvent(client: MatrixClient, timeline: Array<MatrixEvent>): MatrixEvent | null {
for (let index = timeline.length - 1; index >= 0; index--) {
const event = timeline[index];
if (isImportantEvent(client, event)) {
return event;
}
}
return null;
}
/**
* Given this event does not have a receipt, is it important enough to make
* this room unread?
*/
function isImportantEvent(client: MatrixClient, event: MatrixEvent): boolean {
return !shouldHideEvent(event) && eventTriggersUnreadCount(client, event);
}

View file

@ -15,8 +15,8 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import dis from './dispatcher/dispatcher';
import Timer from './utils/Timer';
import dis from "./dispatcher/dispatcher";
import Timer from "./utils/Timer";
// important these are larger than the timeouts of timers
// used with UserActivity.timeWhileActive*,
@ -45,12 +45,15 @@ export default class UserActivity {
private lastScreenX = 0;
private lastScreenY = 0;
constructor(private readonly window: Window, private readonly document: Document) {
public constructor(
private readonly window: Window,
private readonly document: Document,
) {
this.activeNowTimeout = new Timer(CURRENTLY_ACTIVE_THRESHOLD_MS);
this.activeRecentlyTimeout = new Timer(RECENTLY_ACTIVE_THRESHOLD_MS);
}
static sharedInstance() {
public static sharedInstance(): UserActivity {
if (window.mxUserActivity === undefined) {
window.mxUserActivity = new UserActivity(window, document);
}
@ -66,7 +69,7 @@ export default class UserActivity {
* later on when the user does become active.
* @param {Timer} timer the timer to use
*/
public timeWhileActiveNow(timer: Timer) {
public timeWhileActiveNow(timer: Timer): void {
this.timeWhile(timer, this.attachedActiveNowTimers);
if (this.userActiveNow()) {
timer.start();
@ -82,37 +85,41 @@ export default class UserActivity {
* later on when the user does become active.
* @param {Timer} timer the timer to use
*/
public timeWhileActiveRecently(timer: Timer) {
public timeWhileActiveRecently(timer: Timer): void {
this.timeWhile(timer, this.attachedActiveRecentlyTimers);
if (this.userActiveRecently()) {
timer.start();
}
}
private timeWhile(timer: Timer, attachedTimers: Timer[]) {
private timeWhile(timer: Timer, attachedTimers: Timer[]): void {
// important this happens first
const index = attachedTimers.indexOf(timer);
if (index === -1) {
attachedTimers.push(timer);
// remove when done or aborted
timer.finished().finally(() => {
const index = attachedTimers.indexOf(timer);
if (index !== -1) { // should never be -1
attachedTimers.splice(index, 1);
}
// as we fork the promise here,
// avoid unhandled rejection warnings
}).catch((err) => {});
timer
.finished()
.finally(() => {
const index = attachedTimers.indexOf(timer);
if (index !== -1) {
// should never be -1
attachedTimers.splice(index, 1);
}
// as we fork the promise here,
// avoid unhandled rejection warnings
})
.catch((err) => {});
}
}
/**
* Start listening to user activity
*/
public start() {
this.document.addEventListener('mousedown', this.onUserActivity);
this.document.addEventListener('mousemove', this.onUserActivity);
this.document.addEventListener('keydown', this.onUserActivity);
public start(): void {
this.document.addEventListener("mousedown", this.onUserActivity);
this.document.addEventListener("mousemove", this.onUserActivity);
this.document.addEventListener("keydown", this.onUserActivity);
this.document.addEventListener("visibilitychange", this.onPageVisibilityChanged);
this.window.addEventListener("blur", this.onWindowBlurred);
this.window.addEventListener("focus", this.onUserActivity);
@ -120,7 +127,7 @@ export default class UserActivity {
// itself being scrolled. Need to use addEventListener's useCapture.
// also this needs to be the wheel event, not scroll, as scroll is
// fired when the view scrolls down for a new message.
this.window.addEventListener('wheel', this.onUserActivity, {
this.window.addEventListener("wheel", this.onUserActivity, {
passive: true,
capture: true,
});
@ -129,11 +136,11 @@ export default class UserActivity {
/**
* Stop tracking user activity
*/
public stop() {
this.document.removeEventListener('mousedown', this.onUserActivity);
this.document.removeEventListener('mousemove', this.onUserActivity);
this.document.removeEventListener('keydown', this.onUserActivity);
this.window.removeEventListener('wheel', this.onUserActivity, {
public stop(): void {
this.document.removeEventListener("mousedown", this.onUserActivity);
this.document.removeEventListener("mousemove", this.onUserActivity);
this.document.removeEventListener("keydown", this.onUserActivity);
this.window.removeEventListener("wheel", this.onUserActivity, {
capture: true,
});
this.document.removeEventListener("visibilitychange", this.onPageVisibilityChanged);
@ -148,7 +155,7 @@ export default class UserActivity {
* user's attention at any given moment.
* @returns {boolean} true if user is currently 'active'
*/
public userActiveNow() {
public userActiveNow(): boolean {
return this.activeNowTimeout.isRunning();
}
@ -160,11 +167,11 @@ export default class UserActivity {
* (or they may have gone to make tea and left the window focused).
* @returns {boolean} true if user has been active recently
*/
public userActiveRecently() {
public userActiveRecently(): boolean {
return this.activeRecentlyTimeout.isRunning();
}
private onPageVisibilityChanged = e => {
private onPageVisibilityChanged = (e: Event): void => {
if (this.document.visibilityState === "hidden") {
this.activeNowTimeout.abort();
this.activeRecentlyTimeout.abort();
@ -173,16 +180,17 @@ export default class UserActivity {
}
};
private onWindowBlurred = () => {
private onWindowBlurred = (): void => {
this.activeNowTimeout.abort();
this.activeRecentlyTimeout.abort();
};
private onUserActivity = (event: MouseEvent) => {
// XXX: exported for tests
public onUserActivity = (event: Event): void => {
// ignore anything if the window isn't focused
if (!this.document.hasFocus()) return;
if (event.screenX && event.type === "mousemove") {
if (event.type === "mousemove" && this.isMouseEvent(event)) {
if (event.screenX === this.lastScreenX && event.screenY === this.lastScreenY) {
// mouse hasn't actually moved
return;
@ -191,10 +199,10 @@ export default class UserActivity {
this.lastScreenY = event.screenY;
}
dis.dispatch({ action: 'user_activity' });
dis.dispatch({ action: "user_activity" });
if (!this.activeNowTimeout.isRunning()) {
this.activeNowTimeout.start();
dis.dispatch({ action: 'user_activity_start' });
dis.dispatch({ action: "user_activity_start" });
UserActivity.runTimersUntilTimeout(this.attachedActiveNowTimers, this.activeNowTimeout);
} else {
@ -210,11 +218,17 @@ export default class UserActivity {
}
};
private static async runTimersUntilTimeout(attachedTimers: Timer[], timeout: Timer) {
private static async runTimersUntilTimeout(attachedTimers: Timer[], timeout: Timer): Promise<void> {
attachedTimers.forEach((t) => t.start());
try {
await timeout.finished();
} catch (_e) { /* aborted */ }
} catch (_e) {
/* aborted */
}
attachedTimers.forEach((t) => t.abort());
}
private isMouseEvent(event: Event): event is MouseEvent {
return event.type.startsWith("mouse");
}
}

View file

@ -20,6 +20,9 @@ enum Views {
// trying to re-animate a matrix client or register as a guest.
LOADING,
// Another tab holds the lock.
CONFIRM_LOCK_THEFT,
// we are showing the welcome view
WELCOME,
@ -48,6 +51,9 @@ enum Views {
// We are logged out (invalid token) but have our local state again. The user
// should log back in to rehydrate the client.
SOFT_LOGOUT,
// Another instance of the application has started up. We just show an error page.
LOCK_STOLEN,
}
export default Views;

View file

@ -14,16 +14,15 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import { Room } from 'matrix-js-sdk/src/models/room';
import { Room, EventType } from "matrix-js-sdk/src/matrix";
import { logger } from "matrix-js-sdk/src/logger";
import { EventType } from 'matrix-js-sdk/src/@types/event';
import { ensureVirtualRoomExists } from './createRoom';
import { ensureVirtualRoomExists } from "./createRoom";
import { MatrixClientPeg } from "./MatrixClientPeg";
import DMRoomMap from "./utils/DMRoomMap";
import LegacyCallHandler from './LegacyCallHandler';
import LegacyCallHandler from "./LegacyCallHandler";
import { VIRTUAL_ROOM_EVENT_TYPE } from "./call-types";
import { findDMForUser } from './utils/dm/findDMForUser';
import { findDMForUser } from "./utils/dm/findDMForUser";
// Functions for mapping virtual users & rooms. Currently the only lookup
// is sip virtual: there could be others in the future.
@ -38,7 +37,7 @@ export default class VoipUserMapper {
return window.mxVoipUserMapper;
}
private async userToVirtualUser(userId: string): Promise<string> {
private async userToVirtualUser(userId: string): Promise<string | null> {
const results = await LegacyCallHandler.instance.sipVirtualLookup(userId);
if (results.length === 0 || !results[0].fields.lookup_success) return null;
return results[0].userid;
@ -58,12 +57,13 @@ export default class VoipUserMapper {
const virtualUser = await this.getVirtualUserForRoom(roomId);
if (!virtualUser) return null;
const virtualRoomId = await ensureVirtualRoomExists(MatrixClientPeg.get(), virtualUser, roomId);
MatrixClientPeg.get().setRoomAccountData(virtualRoomId, VIRTUAL_ROOM_EVENT_TYPE, {
const cli = MatrixClientPeg.safeGet();
const virtualRoomId = await ensureVirtualRoomExists(cli, virtualUser, roomId);
cli.setRoomAccountData(virtualRoomId!, VIRTUAL_ROOM_EVENT_TYPE, {
native_room: roomId,
});
this.virtualToNativeRoomIdCache.set(virtualRoomId, roomId);
this.virtualToNativeRoomIdCache.set(virtualRoomId!, roomId);
return virtualRoomId;
}
@ -72,14 +72,14 @@ export default class VoipUserMapper {
* Gets the ID of the virtual room for a room, or null if the room has no
* virtual room
*/
public async getVirtualRoomForRoom(roomId: string): Promise<Room | null> {
public async getVirtualRoomForRoom(roomId: string): Promise<Room | undefined> {
const virtualUser = await this.getVirtualUserForRoom(roomId);
if (!virtualUser) return null;
if (!virtualUser) return undefined;
return findDMForUser(MatrixClientPeg.get(), virtualUser);
return findDMForUser(MatrixClientPeg.safeGet(), virtualUser);
}
public nativeRoomForVirtualRoom(roomId: string): string {
public nativeRoomForVirtualRoom(roomId: string): string | null {
const cachedNativeRoomId = this.virtualToNativeRoomIdCache.get(roomId);
if (cachedNativeRoomId) {
logger.log(
@ -88,13 +88,14 @@ export default class VoipUserMapper {
return cachedNativeRoomId;
}
const virtualRoom = MatrixClientPeg.get().getRoom(roomId);
const cli = MatrixClientPeg.safeGet();
const virtualRoom = cli.getRoom(roomId);
if (!virtualRoom) return null;
const virtualRoomEvent = virtualRoom.getAccountData(VIRTUAL_ROOM_EVENT_TYPE);
if (!virtualRoomEvent || !virtualRoomEvent.getContent()) return null;
const nativeRoomID = virtualRoomEvent.getContent()['native_room'];
const nativeRoom = MatrixClientPeg.get().getRoom(nativeRoomID);
if (!nativeRoom || nativeRoom.getMyMembership() !== 'join') return null;
const nativeRoomID = virtualRoomEvent.getContent()["native_room"];
const nativeRoom = cli.getRoom(nativeRoomID);
if (!nativeRoom || nativeRoom.getMyMembership() !== "join") return null;
return nativeRoomID;
}
@ -112,7 +113,7 @@ export default class VoipUserMapper {
if (!roomCreateEvent || !roomCreateEvent.getContent()) return false;
// we only look at this for rooms we created (so inviters can't just cause rooms
// to be invisible)
if (roomCreateEvent.getSender() !== MatrixClientPeg.get().getUserId()) return false;
if (roomCreateEvent.getSender() !== MatrixClientPeg.safeGet().getUserId()) return false;
const claimedNativeRoomId = roomCreateEvent.getContent()[VIRTUAL_ROOM_EVENT_TYPE];
return Boolean(claimedNativeRoomId);
}
@ -121,31 +122,36 @@ export default class VoipUserMapper {
if (!LegacyCallHandler.instance.getSupportsVirtualRooms()) return;
const inviterId = invitedRoom.getDMInviter();
if (!inviterId) {
logger.error("Could not find DM inviter for room id: " + invitedRoom.roomId);
}
logger.log(`Checking virtual-ness of room ID ${invitedRoom.roomId}, invited by ${inviterId}`);
const result = await LegacyCallHandler.instance.sipNativeLookup(inviterId);
const result = await LegacyCallHandler.instance.sipNativeLookup(inviterId!);
if (result.length === 0) {
return;
}
if (result[0].fields.is_virtual) {
const cli = MatrixClientPeg.safeGet();
const nativeUser = result[0].userid;
const nativeRoom = findDMForUser(MatrixClientPeg.get(), nativeUser);
const nativeRoom = findDMForUser(cli, nativeUser);
if (nativeRoom) {
// It's a virtual room with a matching native room, so set the room account data. This
// will make sure we know where how to map calls and also allow us know not to display
// it in the future.
MatrixClientPeg.get().setRoomAccountData(invitedRoom.roomId, VIRTUAL_ROOM_EVENT_TYPE, {
cli.setRoomAccountData(invitedRoom.roomId, VIRTUAL_ROOM_EVENT_TYPE, {
native_room: nativeRoom.roomId,
});
// also auto-join the virtual room if we have a matching native room
// (possibly we should only join if we've also joined the native room, then we'd also have
// to make sure we joined virtual rooms on joining a native one)
MatrixClientPeg.get().joinRoom(invitedRoom.roomId);
}
cli.joinRoom(invitedRoom.roomId);
// also put this room in the virtual room ID cache so isVirtualRoom return the right answer
// in however long it takes for the echo of setAccountData to come down the sync
this.virtualToNativeRoomIdCache.set(invitedRoom.roomId, nativeRoom.roomId);
// also put this room in the virtual room ID cache so isVirtualRoom return the right answer
// in however long it takes for the echo of setAccountData to come down the sync
this.virtualToNativeRoomIdCache.set(invitedRoom.roomId, nativeRoom.roomId);
}
}
}
}

View file

@ -14,18 +14,16 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import { Room } from "matrix-js-sdk/src/models/room";
import { RoomMember } from "matrix-js-sdk/src/models/room-member";
import { Room, RoomMember } from "matrix-js-sdk/src/matrix";
import { MatrixClientPeg } from "./MatrixClientPeg";
import { _t } from './languageHandler';
import { _t } from "./languageHandler";
export function usersTypingApartFromMeAndIgnored(room: Room): RoomMember[] {
return usersTyping(room, [MatrixClientPeg.get().getUserId()].concat(MatrixClientPeg.get().getIgnoredUsers()));
return usersTyping(room, [room.client.getSafeUserId()].concat(room.client.getIgnoredUsers()));
}
export function usersTypingApartFromMe(room: Room): RoomMember[] {
return usersTyping(room, [MatrixClientPeg.get().getUserId()]);
return usersTyping(room, [room.client.getSafeUserId()]);
}
/**
@ -36,12 +34,10 @@ export function usersTypingApartFromMe(room: Room): RoomMember[] {
* @returns {RoomMember[]} list of user objects who are typing.
*/
export function usersTyping(room: Room, exclude: string[] = []): RoomMember[] {
const whoIsTyping = [];
const whoIsTyping: RoomMember[] = [];
const memberKeys = Object.keys(room.currentState.members);
for (let i = 0; i < memberKeys.length; ++i) {
const userId = memberKeys[i];
for (const userId of memberKeys) {
if (room.currentState.members[userId].typing) {
if (exclude.indexOf(userId) === -1) {
whoIsTyping.push(room.currentState.members[userId]);
@ -59,20 +55,20 @@ export function whoIsTypingString(whoIsTyping: RoomMember[], limit: number): str
}
if (whoIsTyping.length === 0) {
return '';
return "";
} else if (whoIsTyping.length === 1) {
return _t('%(displayName)s is typing …', { displayName: whoIsTyping[0].name });
return _t("timeline|typing_indicator|one_user", { displayName: whoIsTyping[0].name });
}
const names = whoIsTyping.map(m => m.name);
const names = whoIsTyping.map((m) => m.name);
if (othersCount >= 1) {
return _t('%(names)s and %(count)s others are typing …', {
names: names.slice(0, limit - 1).join(', '),
return _t("timeline|typing_indicator|more_users", {
names: names.slice(0, limit - 1).join(", "),
count: othersCount,
});
} else {
const lastPerson = names.pop();
return _t('%(names)s and %(lastPerson)s are typing …', { names: names.join(', '), lastPerson: lastPerson });
return _t("timeline|typing_indicator|two_users", { names: names.join(", "), lastPerson: lastPerson });
}
}

46
src/WorkerManager.ts Normal file
View file

@ -0,0 +1,46 @@
/*
Copyright 2022 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import { defer, IDeferred } from "matrix-js-sdk/src/utils";
import { WorkerPayload } from "./workers/worker";
export class WorkerManager<Request extends {}, Response> {
private readonly worker: Worker;
private seq = 0;
private pendingDeferredMap = new Map<number, IDeferred<Response>>();
public constructor(worker: Worker) {
this.worker = worker;
this.worker.onmessage = this.onMessage;
}
private onMessage = (ev: MessageEvent<Response & WorkerPayload>): void => {
const deferred = this.pendingDeferredMap.get(ev.data.seq);
if (deferred) {
this.pendingDeferredMap.delete(ev.data.seq);
deferred.resolve(ev.data);
}
};
public call(request: Request): Promise<Response> {
const seq = this.seq++;
const deferred = defer<Response>();
this.pendingDeferredMap.set(seq, deferred);
this.worker.postMessage({ seq, ...request });
return deferred.promise;
}
}

View file

@ -25,6 +25,7 @@ import {
IKeyboardShortcuts,
KeyBindingAction,
KEYBOARD_SHORTCUTS,
KeyboardShortcutSetting,
MAC_ONLY_SHORTCUTS,
} from "./KeyboardShortcuts";
@ -34,7 +35,7 @@ import {
* have to be manually mirrored in KeyBindingDefaults.
*/
const getUIOnlyShortcuts = (): IKeyboardShortcuts => {
const ctrlEnterToSend = SettingsStore.getValue('MessageComposerInput.ctrlEnterToSend');
const ctrlEnterToSend = SettingsStore.getValue("MessageComposerInput.ctrlEnterToSend");
const keyboardShortcuts: IKeyboardShortcuts = {
[KeyBindingAction.SendMessage]: {
@ -42,37 +43,37 @@ const getUIOnlyShortcuts = (): IKeyboardShortcuts => {
key: Key.ENTER,
ctrlOrCmdKey: ctrlEnterToSend,
},
displayName: _td("Send message"),
displayName: _td("composer|send_button_title"),
},
[KeyBindingAction.NewLine]: {
default: {
key: Key.ENTER,
shiftKey: !ctrlEnterToSend,
},
displayName: _td("New line"),
displayName: _td("keyboard|composer_new_line"),
},
[KeyBindingAction.CompleteAutocomplete]: {
default: {
key: Key.ENTER,
},
displayName: _td("Complete"),
displayName: _td("action|complete"),
},
[KeyBindingAction.ForceCompleteAutocomplete]: {
default: {
key: Key.TAB,
},
displayName: _td("Force complete"),
displayName: _td("keyboard|autocomplete_force"),
},
[KeyBindingAction.SearchInRoom]: {
default: {
ctrlOrCmdKey: true,
key: Key.F,
},
displayName: _td("Search (must be enabled)"),
displayName: _td("keyboard|search"),
},
};
if (PlatformPeg.get().overrideBrowserShortcuts()) {
if (PlatformPeg.get()?.overrideBrowserShortcuts()) {
// XXX: This keyboard shortcut isn't manually added to
// KeyBindingDefaults as it can't be easily handled by the
// KeyBindingManager
@ -81,7 +82,7 @@ const getUIOnlyShortcuts = (): IKeyboardShortcuts => {
ctrlOrCmdKey: true,
key: DIGITS,
},
displayName: _td("Switch to space by number"),
displayName: _td("keyboard|switch_to_space"),
};
}
@ -92,28 +93,30 @@ const getUIOnlyShortcuts = (): IKeyboardShortcuts => {
* This function gets keyboard shortcuts that can be consumed by the KeyBindingDefaults.
*/
export const getKeyboardShortcuts = (): IKeyboardShortcuts => {
const overrideBrowserShortcuts = PlatformPeg.get().overrideBrowserShortcuts();
const overrideBrowserShortcuts = PlatformPeg.get()?.overrideBrowserShortcuts();
return Object.keys(KEYBOARD_SHORTCUTS).filter((k: KeyBindingAction) => {
if (KEYBOARD_SHORTCUTS[k]?.controller?.settingDisabled) return false;
if (MAC_ONLY_SHORTCUTS.includes(k) && !IS_MAC) return false;
if (DESKTOP_SHORTCUTS.includes(k) && !overrideBrowserShortcuts) return false;
return (Object.keys(KEYBOARD_SHORTCUTS) as KeyBindingAction[])
.filter((k) => {
if (KEYBOARD_SHORTCUTS[k]?.controller?.settingDisabled) return false;
if (MAC_ONLY_SHORTCUTS.includes(k) && !IS_MAC) return false;
if (DESKTOP_SHORTCUTS.includes(k) && !overrideBrowserShortcuts) return false;
return true;
}).reduce((o, key) => {
o[key] = KEYBOARD_SHORTCUTS[key];
return o;
}, {} as IKeyboardShortcuts);
return true;
})
.reduce((o, key) => {
o[key as KeyBindingAction] = KEYBOARD_SHORTCUTS[key as KeyBindingAction];
return o;
}, {} as IKeyboardShortcuts);
};
/**
* Gets keyboard shortcuts that should be presented to the user in the UI.
*/
export const getKeyboardShortcutsForUI = (): IKeyboardShortcuts => {
const entries = [
...Object.entries(getUIOnlyShortcuts()),
...Object.entries(getKeyboardShortcuts()),
];
const entries = [...Object.entries(getUIOnlyShortcuts()), ...Object.entries(getKeyboardShortcuts())] as [
KeyBindingAction,
KeyboardShortcutSetting,
][];
return entries.reduce((acc, [key, value]) => {
acc[key] = value;
@ -121,11 +124,11 @@ export const getKeyboardShortcutsForUI = (): IKeyboardShortcuts => {
}, {} as IKeyboardShortcuts);
};
export const getKeyboardShortcutValue = (name: string): KeyCombo => {
export const getKeyboardShortcutValue = (name: KeyBindingAction): KeyCombo | undefined => {
return getKeyboardShortcutsForUI()[name]?.default;
};
export const getKeyboardShortcutDisplayName = (name: string): string | null => {
export const getKeyboardShortcutDisplayName = (name: KeyBindingAction): string | undefined => {
const keyboardShortcutDisplayName = getKeyboardShortcutsForUI()[name]?.displayName;
return keyboardShortcutDisplayName && _t(keyboardShortcutDisplayName);
};

View file

@ -16,110 +16,110 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import { _td } from "../languageHandler";
import { _td, TranslationKey } from "../languageHandler";
import { IS_MAC, Key } from "../Keyboard";
import { IBaseSetting } from "../settings/Settings";
import { KeyCombo } from "../KeyBindingsManager";
export enum KeyBindingAction {
/** Send a message */
SendMessage = 'KeyBinding.sendMessageInComposer',
SendMessage = "KeyBinding.sendMessageInComposer",
/** Go backwards through the send history and use the message in composer view */
SelectPrevSendHistory = 'KeyBinding.previousMessageInComposerHistory',
SelectPrevSendHistory = "KeyBinding.previousMessageInComposerHistory",
/** Go forwards through the send history */
SelectNextSendHistory = 'KeyBinding.nextMessageInComposerHistory',
SelectNextSendHistory = "KeyBinding.nextMessageInComposerHistory",
/** Start editing the user's last sent message */
EditPrevMessage = 'KeyBinding.editPreviousMessage',
EditPrevMessage = "KeyBinding.editPreviousMessage",
/** Start editing the user's next sent message */
EditNextMessage = 'KeyBinding.editNextMessage',
EditNextMessage = "KeyBinding.editNextMessage",
/** Cancel editing a message or cancel replying to a message */
CancelReplyOrEdit = 'KeyBinding.cancelReplyInComposer',
CancelReplyOrEdit = "KeyBinding.cancelReplyInComposer",
/** Show the sticker picker */
ShowStickerPicker = 'KeyBinding.showStickerPicker',
ShowStickerPicker = "KeyBinding.showStickerPicker",
/** Set bold format the current selection */
FormatBold = 'KeyBinding.toggleBoldInComposer',
FormatBold = "KeyBinding.toggleBoldInComposer",
/** Set italics format the current selection */
FormatItalics = 'KeyBinding.toggleItalicsInComposer',
FormatItalics = "KeyBinding.toggleItalicsInComposer",
/** Insert link for current selection */
FormatLink = 'KeyBinding.FormatLink',
FormatLink = "KeyBinding.FormatLink",
/** Set code format for current selection */
FormatCode = 'KeyBinding.FormatCode',
FormatCode = "KeyBinding.FormatCode",
/** Format the current selection as quote */
FormatQuote = 'KeyBinding.toggleQuoteInComposer',
FormatQuote = "KeyBinding.toggleQuoteInComposer",
/** Undo the last editing */
EditUndo = 'KeyBinding.editUndoInComposer',
EditUndo = "KeyBinding.editUndoInComposer",
/** Redo editing */
EditRedo = 'KeyBinding.editRedoInComposer',
EditRedo = "KeyBinding.editRedoInComposer",
/** Insert new line */
NewLine = 'KeyBinding.newLineInComposer',
NewLine = "KeyBinding.newLineInComposer",
/** Move the cursor to the start of the message */
MoveCursorToStart = 'KeyBinding.jumpToStartInComposer',
MoveCursorToStart = "KeyBinding.jumpToStartInComposer",
/** Move the cursor to the end of the message */
MoveCursorToEnd = 'KeyBinding.jumpToEndInComposer',
MoveCursorToEnd = "KeyBinding.jumpToEndInComposer",
/** Accepts chosen autocomplete selection */
CompleteAutocomplete = 'KeyBinding.completeAutocomplete',
CompleteAutocomplete = "KeyBinding.completeAutocomplete",
/** Accepts chosen autocomplete selection or,
* if the autocompletion window is not shown, open the window and select the first selection */
ForceCompleteAutocomplete = 'KeyBinding.forceCompleteAutocomplete',
ForceCompleteAutocomplete = "KeyBinding.forceCompleteAutocomplete",
/** Move to the previous autocomplete selection */
PrevSelectionInAutocomplete = 'KeyBinding.previousOptionInAutoComplete',
PrevSelectionInAutocomplete = "KeyBinding.previousOptionInAutoComplete",
/** Move to the next autocomplete selection */
NextSelectionInAutocomplete = 'KeyBinding.nextOptionInAutoComplete',
NextSelectionInAutocomplete = "KeyBinding.nextOptionInAutoComplete",
/** Close the autocompletion window */
CancelAutocomplete = 'KeyBinding.cancelAutoComplete',
CancelAutocomplete = "KeyBinding.cancelAutoComplete",
/** Clear room list filter field */
ClearRoomFilter = 'KeyBinding.clearRoomFilter',
ClearRoomFilter = "KeyBinding.clearRoomFilter",
/** Navigate up/down in the room list */
PrevRoom = 'KeyBinding.downerRoom',
PrevRoom = "KeyBinding.downerRoom",
/** Navigate down in the room list */
NextRoom = 'KeyBinding.upperRoom',
NextRoom = "KeyBinding.upperRoom",
/** Select room from the room list */
SelectRoomInRoomList = 'KeyBinding.selectRoomInRoomList',
SelectRoomInRoomList = "KeyBinding.selectRoomInRoomList",
/** Collapse room list section */
CollapseRoomListSection = 'KeyBinding.collapseSectionInRoomList',
CollapseRoomListSection = "KeyBinding.collapseSectionInRoomList",
/** Expand room list section, if already expanded, jump to first room in the selection */
ExpandRoomListSection = 'KeyBinding.expandSectionInRoomList',
ExpandRoomListSection = "KeyBinding.expandSectionInRoomList",
/** Scroll up in the timeline */
ScrollUp = 'KeyBinding.scrollUpInTimeline',
ScrollUp = "KeyBinding.scrollUpInTimeline",
/** Scroll down in the timeline */
ScrollDown = 'KeyBinding.scrollDownInTimeline',
ScrollDown = "KeyBinding.scrollDownInTimeline",
/** Dismiss read marker and jump to bottom */
DismissReadMarker = 'KeyBinding.dismissReadMarkerAndJumpToBottom',
DismissReadMarker = "KeyBinding.dismissReadMarkerAndJumpToBottom",
/** Jump to oldest unread message */
JumpToOldestUnread = 'KeyBinding.jumpToOldestUnreadMessage',
JumpToOldestUnread = "KeyBinding.jumpToOldestUnreadMessage",
/** Upload a file */
UploadFile = 'KeyBinding.uploadFileToRoom',
UploadFile = "KeyBinding.uploadFileToRoom",
/** Focus search message in a room (must be enabled) */
SearchInRoom = 'KeyBinding.searchInRoom',
SearchInRoom = "KeyBinding.searchInRoom",
/** Jump to the first (downloaded) message in the room */
JumpToFirstMessage = 'KeyBinding.jumpToFirstMessageInTimeline',
JumpToFirstMessage = "KeyBinding.jumpToFirstMessageInTimeline",
/** Jump to the latest message in the room */
JumpToLatestMessage = 'KeyBinding.jumpToLastMessageInTimeline',
JumpToLatestMessage = "KeyBinding.jumpToLastMessageInTimeline",
/** Jump to room search (search for a room) */
FilterRooms = 'KeyBinding.filterRooms',
FilterRooms = "KeyBinding.filterRooms",
/** Toggle the space panel */
ToggleSpacePanel = 'KeyBinding.toggleSpacePanel',
ToggleSpacePanel = "KeyBinding.toggleSpacePanel",
/** Toggle the room side panel */
ToggleRoomSidePanel = 'KeyBinding.toggleRightPanel',
ToggleRoomSidePanel = "KeyBinding.toggleRightPanel",
/** Toggle the user menu */
ToggleUserMenu = 'KeyBinding.toggleTopLeftMenu',
ToggleUserMenu = "KeyBinding.toggleTopLeftMenu",
/** Toggle the short cut help dialog */
ShowKeyboardSettings = 'KeyBinding.showKeyBindingsSettings',
ShowKeyboardSettings = "KeyBinding.showKeyBindingsSettings",
/** Got to the Element home screen */
GoToHome = 'KeyBinding.goToHomeView',
GoToHome = "KeyBinding.goToHomeView",
/** Select prev room */
SelectPrevRoom = 'KeyBinding.previousRoom',
SelectPrevRoom = "KeyBinding.previousRoom",
/** Select next room */
SelectNextRoom = 'KeyBinding.nextRoom',
SelectNextRoom = "KeyBinding.nextRoom",
/** Select prev room with unread messages */
SelectPrevUnreadRoom = 'KeyBinding.previousUnreadRoom',
SelectPrevUnreadRoom = "KeyBinding.previousUnreadRoom",
/** Select next room with unread messages */
SelectNextUnreadRoom = 'KeyBinding.nextUnreadRoom',
SelectNextUnreadRoom = "KeyBinding.nextUnreadRoom",
/** Switches to a space by number */
SwitchToSpaceByNumber = "KeyBinding.switchToSpaceByNumber",
@ -151,20 +151,20 @@ export enum KeyBindingAction {
Comma = "KeyBinding.comma",
/** Toggle visibility of hidden events */
ToggleHiddenEventVisibility = 'KeyBinding.toggleHiddenEventVisibility',
ToggleHiddenEventVisibility = "KeyBinding.toggleHiddenEventVisibility",
}
type KeyboardShortcutSetting = IBaseSetting<KeyCombo>;
export type IKeyboardShortcuts = {
// TODO: We should figure out what to do with the keyboard shortcuts that are not handled by KeybindingManager
[k in (KeyBindingAction)]?: KeyboardShortcutSetting;
export type KeyboardShortcutSetting = Omit<IBaseSetting<KeyCombo>, "supportedLevels" | "displayName"> & {
displayName?: TranslationKey;
};
// TODO: We should figure out what to do with the keyboard shortcuts that are not handled by KeybindingManager
export type IKeyboardShortcuts = Partial<Record<KeyBindingAction, KeyboardShortcutSetting>>;
export interface ICategory {
categoryLabel?: string;
categoryLabel?: TranslationKey;
// TODO: We should figure out what to do with the keyboard shortcuts that are not handled by KeybindingManager
settingNames: (KeyBindingAction)[];
settingNames: KeyBindingAction[];
}
export enum CategoryName {
@ -181,18 +181,18 @@ export enum CategoryName {
// Meta-key representing the digits [0-9] often found at the top of standard keyboard layouts
export const DIGITS = "digits";
export const ALTERNATE_KEY_NAME: Record<string, string> = {
[Key.PAGE_UP]: _td("Page Up"),
[Key.PAGE_DOWN]: _td("Page Down"),
[Key.ESCAPE]: _td("Esc"),
[Key.ENTER]: _td("Enter"),
[Key.SPACE]: _td("Space"),
[Key.HOME]: _td("Home"),
[Key.END]: _td("End"),
[Key.ALT]: _td("Alt"),
[Key.CONTROL]: _td("Ctrl"),
[Key.SHIFT]: _td("Shift"),
[DIGITS]: _td("[number]"),
export const ALTERNATE_KEY_NAME: Record<string, TranslationKey> = {
[Key.PAGE_UP]: _td("keyboard|page_up"),
[Key.PAGE_DOWN]: _td("keyboard|page_down"),
[Key.ESCAPE]: _td("keyboard|escape"),
[Key.ENTER]: _td("keyboard|enter"),
[Key.SPACE]: _td("keyboard|space"),
[Key.HOME]: _td("keyboard|home"),
[Key.END]: _td("keyboard|end"),
[Key.ALT]: _td("keyboard|alt"),
[Key.CONTROL]: _td("keyboard|control"),
[Key.SHIFT]: _td("keyboard|shift"),
[DIGITS]: _td("keyboard|number"),
};
export const KEY_ICON: Record<string, string> = {
[Key.ARROW_UP]: "↑",
@ -207,7 +207,7 @@ if (IS_MAC) {
export const CATEGORIES: Record<CategoryName, ICategory> = {
[CategoryName.COMPOSER]: {
categoryLabel: _td("Composer"),
categoryLabel: _td("settings|preferences|composer_heading"),
settingNames: [
KeyBindingAction.SendMessage,
KeyBindingAction.NewLine,
@ -227,14 +227,13 @@ export const CATEGORIES: Record<CategoryName, ICategory> = {
KeyBindingAction.SelectPrevSendHistory,
KeyBindingAction.ShowStickerPicker,
],
}, [CategoryName.CALLS]: {
categoryLabel: _td("Calls"),
settingNames: [
KeyBindingAction.ToggleMicInCall,
KeyBindingAction.ToggleWebcamInCall,
],
}, [CategoryName.ROOM]: {
categoryLabel: _td("Room"),
},
[CategoryName.CALLS]: {
categoryLabel: _td("keyboard|category_calls"),
settingNames: [KeyBindingAction.ToggleMicInCall, KeyBindingAction.ToggleWebcamInCall],
},
[CategoryName.ROOM]: {
categoryLabel: _td("common|room"),
settingNames: [
KeyBindingAction.SearchInRoom,
KeyBindingAction.UploadFile,
@ -245,8 +244,9 @@ export const CATEGORIES: Record<CategoryName, ICategory> = {
KeyBindingAction.JumpToFirstMessage,
KeyBindingAction.JumpToLatestMessage,
],
}, [CategoryName.ROOM_LIST]: {
categoryLabel: _td("Room List"),
},
[CategoryName.ROOM_LIST]: {
categoryLabel: _td("keyboard|category_room_list"),
settingNames: [
KeyBindingAction.SelectRoomInRoomList,
KeyBindingAction.ClearRoomFilter,
@ -255,8 +255,9 @@ export const CATEGORIES: Record<CategoryName, ICategory> = {
KeyBindingAction.NextRoom,
KeyBindingAction.PrevRoom,
],
}, [CategoryName.ACCESSIBILITY]: {
categoryLabel: _td("Accessibility"),
},
[CategoryName.ACCESSIBILITY]: {
categoryLabel: _td("common|accessibility"),
settingNames: [
KeyBindingAction.Escape,
KeyBindingAction.Enter,
@ -271,8 +272,9 @@ export const CATEGORIES: Record<CategoryName, ICategory> = {
KeyBindingAction.ArrowDown,
KeyBindingAction.Comma,
],
}, [CategoryName.NAVIGATION]: {
categoryLabel: _td("Navigation"),
},
[CategoryName.NAVIGATION]: {
categoryLabel: _td("keyboard|category_navigation"),
settingNames: [
KeyBindingAction.ToggleUserMenu,
KeyBindingAction.ToggleRoomSidePanel,
@ -289,8 +291,9 @@ export const CATEGORIES: Record<CategoryName, ICategory> = {
KeyBindingAction.PreviousVisitedRoomOrSpace,
KeyBindingAction.NextVisitedRoomOrSpace,
],
}, [CategoryName.AUTOCOMPLETE]: {
categoryLabel: _td("Autocomplete"),
},
[CategoryName.AUTOCOMPLETE]: {
categoryLabel: _td("keyboard|category_autocomplete"),
settingNames: [
KeyBindingAction.CancelAutocomplete,
KeyBindingAction.NextSelectionInAutocomplete,
@ -298,11 +301,10 @@ export const CATEGORIES: Record<CategoryName, ICategory> = {
KeyBindingAction.CompleteAutocomplete,
KeyBindingAction.ForceCompleteAutocomplete,
],
}, [CategoryName.LABS]: {
categoryLabel: _td("Labs"),
settingNames: [
KeyBindingAction.ToggleHiddenEventVisibility,
],
},
[CategoryName.LABS]: {
categoryLabel: _td("common|labs"),
settingNames: [KeyBindingAction.ToggleHiddenEventVisibility],
},
};
@ -313,9 +315,7 @@ export const DESKTOP_SHORTCUTS = [
KeyBindingAction.NextVisitedRoomOrSpace,
];
export const MAC_ONLY_SHORTCUTS = [
KeyBindingAction.OpenUserSettings,
];
export const MAC_ONLY_SHORTCUTS = [KeyBindingAction.OpenUserSettings];
// This is very intentionally modelled after SETTINGS as it will make it easier
// to implement customizable keyboard shortcuts
@ -327,14 +327,14 @@ export const KEYBOARD_SHORTCUTS: IKeyboardShortcuts = {
ctrlOrCmdKey: true,
key: Key.B,
},
displayName: _td("Toggle Bold"),
displayName: _td("keyboard|composer_toggle_bold"),
},
[KeyBindingAction.FormatItalics]: {
default: {
ctrlOrCmdKey: true,
key: Key.I,
},
displayName: _td("Toggle Italics"),
displayName: _td("keyboard|composer_toggle_italics"),
},
[KeyBindingAction.FormatQuote]: {
default: {
@ -342,14 +342,14 @@ export const KEYBOARD_SHORTCUTS: IKeyboardShortcuts = {
shiftKey: true,
key: Key.GREATER_THAN,
},
displayName: _td("Toggle Quote"),
displayName: _td("keyboard|composer_toggle_quote"),
},
[KeyBindingAction.FormatCode]: {
default: {
ctrlOrCmdKey: true,
key: Key.E,
},
displayName: _td("Toggle Code Block"),
displayName: _td("keyboard|composer_toggle_code_block"),
},
[KeyBindingAction.FormatLink]: {
default: {
@ -357,39 +357,39 @@ export const KEYBOARD_SHORTCUTS: IKeyboardShortcuts = {
shiftKey: true,
key: Key.L,
},
displayName: _td("Toggle Link"),
displayName: _td("keyboard|composer_toggle_link"),
},
[KeyBindingAction.CancelReplyOrEdit]: {
default: {
key: Key.ESCAPE,
},
displayName: _td("Cancel replying to a message"),
displayName: _td("keyboard|cancel_reply"),
},
[KeyBindingAction.EditNextMessage]: {
default: {
key: Key.ARROW_DOWN,
},
displayName: _td("Navigate to next message to edit"),
displayName: _td("keyboard|navigate_next_message_edit"),
},
[KeyBindingAction.EditPrevMessage]: {
default: {
key: Key.ARROW_UP,
},
displayName: _td("Navigate to previous message to edit"),
displayName: _td("keyboard|navigate_prev_message_edit"),
},
[KeyBindingAction.MoveCursorToStart]: {
default: {
ctrlOrCmdKey: true,
key: Key.HOME,
},
displayName: _td("Jump to start of the composer"),
displayName: _td("keyboard|composer_jump_start"),
},
[KeyBindingAction.MoveCursorToEnd]: {
default: {
ctrlOrCmdKey: true,
key: Key.END,
},
displayName: _td("Jump to end of the composer"),
displayName: _td("keyboard|composer_jump_end"),
},
[KeyBindingAction.SelectNextSendHistory]: {
default: {
@ -397,7 +397,7 @@ export const KEYBOARD_SHORTCUTS: IKeyboardShortcuts = {
ctrlKey: true,
key: Key.ARROW_DOWN,
},
displayName: _td("Navigate to next message in composer history"),
displayName: _td("keyboard|composer_navigate_next_history"),
},
[KeyBindingAction.SelectPrevSendHistory]: {
default: {
@ -405,41 +405,41 @@ export const KEYBOARD_SHORTCUTS: IKeyboardShortcuts = {
ctrlKey: true,
key: Key.ARROW_UP,
},
displayName: _td("Navigate to previous message in composer history"),
displayName: _td("keyboard|composer_navigate_prev_history"),
},
[KeyBindingAction.ShowStickerPicker]: {
default: {
ctrlOrCmdKey: true,
key: Key.SEMICOLON,
},
displayName: _td("Send a sticker"),
displayName: _td("keyboard|send_sticker"),
},
[KeyBindingAction.ToggleMicInCall]: {
default: {
ctrlOrCmdKey: true,
key: Key.D,
},
displayName: _td("Toggle microphone mute"),
displayName: _td("keyboard|toggle_microphone_mute"),
},
[KeyBindingAction.ToggleWebcamInCall]: {
default: {
ctrlOrCmdKey: true,
key: Key.E,
},
displayName: _td("Toggle webcam on/off"),
displayName: _td("keyboard|toggle_webcam_mute"),
},
[KeyBindingAction.DismissReadMarker]: {
default: {
key: Key.ESCAPE,
},
displayName: _td("Dismiss read marker and jump to bottom"),
displayName: _td("keyboard|dismiss_read_marker_and_jump_bottom"),
},
[KeyBindingAction.JumpToOldestUnread]: {
default: {
shiftKey: true,
key: Key.PAGE_UP,
},
displayName: _td("Jump to oldest unread message"),
displayName: _td("keyboard|jump_to_read_marker"),
},
[KeyBindingAction.UploadFile]: {
default: {
@ -447,77 +447,77 @@ export const KEYBOARD_SHORTCUTS: IKeyboardShortcuts = {
shiftKey: true,
key: Key.U,
},
displayName: _td("Upload a file"),
displayName: _td("keyboard|upload_file"),
},
[KeyBindingAction.ScrollUp]: {
default: {
key: Key.PAGE_UP,
},
displayName: _td("Scroll up in the timeline"),
displayName: _td("keyboard|scroll_up_timeline"),
},
[KeyBindingAction.ScrollDown]: {
default: {
key: Key.PAGE_DOWN,
},
displayName: _td("Scroll down in the timeline"),
displayName: _td("keyboard|scroll_down_timeline"),
},
[KeyBindingAction.FilterRooms]: {
default: {
ctrlOrCmdKey: true,
key: Key.K,
},
displayName: _td("Jump to room search"),
displayName: _td("keyboard|jump_room_search"),
},
[KeyBindingAction.SelectRoomInRoomList]: {
default: {
key: Key.ENTER,
},
displayName: _td("Select room from the room list"),
displayName: _td("keyboard|room_list_select_room"),
},
[KeyBindingAction.CollapseRoomListSection]: {
default: {
key: Key.ARROW_LEFT,
},
displayName: _td("Collapse room list section"),
displayName: _td("keyboard|room_list_collapse_section"),
},
[KeyBindingAction.ExpandRoomListSection]: {
default: {
key: Key.ARROW_RIGHT,
},
displayName: _td("Expand room list section"),
displayName: _td("keyboard|room_list_expand_section"),
},
[KeyBindingAction.NextRoom]: {
default: {
key: Key.ARROW_DOWN,
},
displayName: _td("Navigate down in the room list"),
displayName: _td("keyboard|room_list_navigate_down"),
},
[KeyBindingAction.PrevRoom]: {
default: {
key: Key.ARROW_UP,
},
displayName: _td("Navigate up in the room list"),
displayName: _td("keyboard|room_list_navigate_up"),
},
[KeyBindingAction.ToggleUserMenu]: {
default: {
ctrlOrCmdKey: true,
key: Key.BACKTICK,
},
displayName: _td("Toggle the top left menu"),
displayName: _td("keyboard|toggle_top_left_menu"),
},
[KeyBindingAction.ToggleRoomSidePanel]: {
default: {
ctrlOrCmdKey: true,
key: Key.PERIOD,
},
displayName: _td("Toggle right panel"),
displayName: _td("keyboard|toggle_right_panel"),
},
[KeyBindingAction.ShowKeyboardSettings]: {
default: {
ctrlOrCmdKey: true,
key: Key.SLASH,
},
displayName: _td("Open this settings tab"),
displayName: _td("keyboard|keyboard_shortcuts_tab"),
},
[KeyBindingAction.GoToHome]: {
default: {
@ -526,7 +526,7 @@ export const KEYBOARD_SHORTCUTS: IKeyboardShortcuts = {
shiftKey: IS_MAC,
key: Key.H,
},
displayName: _td("Go to Home View"),
displayName: _td("keyboard|go_home_view"),
},
[KeyBindingAction.SelectNextUnreadRoom]: {
default: {
@ -534,7 +534,7 @@ export const KEYBOARD_SHORTCUTS: IKeyboardShortcuts = {
altKey: true,
key: Key.ARROW_DOWN,
},
displayName: _td("Next unread room or DM"),
displayName: _td("keyboard|next_unread_room"),
},
[KeyBindingAction.SelectPrevUnreadRoom]: {
default: {
@ -542,39 +542,39 @@ export const KEYBOARD_SHORTCUTS: IKeyboardShortcuts = {
altKey: true,
key: Key.ARROW_UP,
},
displayName: _td("Previous unread room or DM"),
displayName: _td("keyboard|prev_unread_room"),
},
[KeyBindingAction.SelectNextRoom]: {
default: {
altKey: true,
key: Key.ARROW_DOWN,
},
displayName: _td("Next room or DM"),
displayName: _td("keyboard|next_room"),
},
[KeyBindingAction.SelectPrevRoom]: {
default: {
altKey: true,
key: Key.ARROW_UP,
},
displayName: _td("Previous room or DM"),
displayName: _td("keyboard|prev_room"),
},
[KeyBindingAction.CancelAutocomplete]: {
default: {
key: Key.ESCAPE,
},
displayName: _td("Cancel autocomplete"),
displayName: _td("keyboard|autocomplete_cancel"),
},
[KeyBindingAction.NextSelectionInAutocomplete]: {
default: {
key: Key.ARROW_DOWN,
},
displayName: _td("Next autocomplete suggestion"),
displayName: _td("keyboard|autocomplete_navigate_next"),
},
[KeyBindingAction.PrevSelectionInAutocomplete]: {
default: {
key: Key.ARROW_UP,
},
displayName: _td("Previous autocomplete suggestion"),
displayName: _td("keyboard|autocomplete_navigate_prev"),
},
[KeyBindingAction.ToggleSpacePanel]: {
default: {
@ -582,7 +582,7 @@ export const KEYBOARD_SHORTCUTS: IKeyboardShortcuts = {
shiftKey: true,
key: Key.D,
},
displayName: _td("Toggle space panel"),
displayName: _td("keyboard|toggle_space_panel"),
},
[KeyBindingAction.ToggleHiddenEventVisibility]: {
default: {
@ -590,28 +590,28 @@ export const KEYBOARD_SHORTCUTS: IKeyboardShortcuts = {
shiftKey: true,
key: Key.H,
},
displayName: _td("Toggle hidden event visibility"),
displayName: _td("keyboard|toggle_hidden_events"),
},
[KeyBindingAction.JumpToFirstMessage]: {
default: {
key: Key.HOME,
ctrlKey: true,
},
displayName: _td("Jump to first message"),
displayName: _td("keyboard|jump_first_message"),
},
[KeyBindingAction.JumpToLatestMessage]: {
default: {
key: Key.END,
ctrlKey: true,
},
displayName: _td("Jump to last message"),
displayName: _td("keyboard|jump_last_message"),
},
[KeyBindingAction.EditUndo]: {
default: {
key: Key.Z,
ctrlOrCmdKey: true,
},
displayName: _td("Undo edit"),
displayName: _td("keyboard|composer_undo"),
},
[KeyBindingAction.EditRedo]: {
default: {
@ -619,7 +619,7 @@ export const KEYBOARD_SHORTCUTS: IKeyboardShortcuts = {
ctrlOrCmdKey: true,
shiftKey: IS_MAC,
},
displayName: _td("Redo edit"),
displayName: _td("keyboard|composer_redo"),
},
[KeyBindingAction.PreviousVisitedRoomOrSpace]: {
default: {
@ -627,7 +627,7 @@ export const KEYBOARD_SHORTCUTS: IKeyboardShortcuts = {
altKey: !IS_MAC,
key: IS_MAC ? Key.SQUARE_BRACKET_LEFT : Key.ARROW_LEFT,
},
displayName: _td("Previous recently visited room or space"),
displayName: _td("keyboard|navigate_prev_history"),
},
[KeyBindingAction.NextVisitedRoomOrSpace]: {
default: {
@ -635,33 +635,33 @@ export const KEYBOARD_SHORTCUTS: IKeyboardShortcuts = {
altKey: !IS_MAC,
key: IS_MAC ? Key.SQUARE_BRACKET_RIGHT : Key.ARROW_RIGHT,
},
displayName: _td("Next recently visited room or space"),
displayName: _td("keyboard|navigate_next_history"),
},
[KeyBindingAction.SwitchToSpaceByNumber]: {
default: {
ctrlOrCmdKey: true,
key: DIGITS,
},
displayName: _td("Switch to space by number"),
displayName: _td("keyboard|switch_to_space"),
},
[KeyBindingAction.OpenUserSettings]: {
default: {
metaKey: true,
key: Key.COMMA,
},
displayName: _td("Open user settings"),
displayName: _td("keyboard|open_user_settings"),
},
[KeyBindingAction.Escape]: {
default: {
key: Key.ESCAPE,
},
displayName: _td("Close dialog or context menu"),
displayName: _td("keyboard|close_dialog_menu"),
},
[KeyBindingAction.Enter]: {
default: {
key: Key.ENTER,
},
displayName: _td("Activate selected button"),
displayName: _td("keyboard|activate_button"),
},
[KeyBindingAction.Space]: {
default: {

View file

@ -25,6 +25,7 @@ import React, {
Reducer,
Dispatch,
RefObject,
ReactNode,
} from "react";
import { getKeyBindingsManager } from "../KeyBindingsManager";
@ -56,18 +57,17 @@ export function checkInputableElement(el: HTMLElement): boolean {
}
export interface IState {
activeRef: Ref;
activeRef?: Ref;
refs: Ref[];
}
interface IContext {
export interface IContext {
state: IState;
dispatch: Dispatch<IAction>;
}
export const RovingTabIndexContext = createContext<IContext>({
state: {
activeRef: null,
refs: [], // list of refs in DOM order
},
dispatch: () => {},
@ -78,16 +78,40 @@ export enum Type {
Register = "REGISTER",
Unregister = "UNREGISTER",
SetFocus = "SET_FOCUS",
Update = "UPDATE",
}
interface IAction {
type: Type;
export interface IAction {
type: Exclude<Type, Type.Update>;
payload: {
ref: Ref;
};
}
export const reducer = (state: IState, action: IAction) => {
interface UpdateAction {
type: Type.Update;
payload?: undefined;
}
type Action = IAction | UpdateAction;
const refSorter = (a: Ref, b: Ref): number => {
if (a === b) {
return 0;
}
const position = a.current!.compareDocumentPosition(b.current!);
if (position & Node.DOCUMENT_POSITION_FOLLOWING || position & Node.DOCUMENT_POSITION_CONTAINED_BY) {
return -1;
} else if (position & Node.DOCUMENT_POSITION_PRECEDING || position & Node.DOCUMENT_POSITION_CONTAINS) {
return 1;
} else {
return 0;
}
};
export const reducer: Reducer<IState, Action> = (state: IState, action: Action) => {
switch (action.type) {
case Type.Register: {
if (!state.activeRef) {
@ -97,27 +121,13 @@ export const reducer = (state: IState, action: IAction) => {
// Sadly due to the potential of DOM elements swapping order we can't do anything fancy like a binary insert
state.refs.push(action.payload.ref);
state.refs.sort((a, b) => {
if (a === b) {
return 0;
}
const position = a.current.compareDocumentPosition(b.current);
if (position & Node.DOCUMENT_POSITION_FOLLOWING || position & Node.DOCUMENT_POSITION_CONTAINED_BY) {
return -1;
} else if (position & Node.DOCUMENT_POSITION_PRECEDING || position & Node.DOCUMENT_POSITION_CONTAINS) {
return 1;
} else {
return 0;
}
});
state.refs.sort(refSorter);
return { ...state };
}
case Type.Unregister: {
const oldIndex = state.refs.findIndex(r => r === action.payload.ref);
const oldIndex = state.refs.findIndex((r) => r === action.payload.ref);
if (oldIndex === -1) {
return state; // already removed, this should not happen
@ -129,8 +139,8 @@ export const reducer = (state: IState, action: IAction) => {
if (oldIndex >= state.refs.length) {
state.activeRef = findSiblingElement(state.refs, state.refs.length - 1, true);
} else {
state.activeRef = findSiblingElement(state.refs, oldIndex)
|| findSiblingElement(state.refs, oldIndex, true);
state.activeRef =
findSiblingElement(state.refs, oldIndex) || findSiblingElement(state.refs, oldIndex, true);
}
if (document.activeElement === document.body) {
// if the focus got reverted to the body then the user was likely focused on the unmounted element
@ -150,38 +160,51 @@ export const reducer = (state: IState, action: IAction) => {
return { ...state };
}
case Type.Update: {
state.refs.sort(refSorter);
return { ...state };
}
default:
return state;
}
};
interface IProps {
handleLoop?: boolean;
handleHomeEnd?: boolean;
handleUpDown?: boolean;
handleLeftRight?: boolean;
children(renderProps: {
onKeyDownHandler(ev: React.KeyboardEvent);
});
onKeyDown?(ev: React.KeyboardEvent, state: IState);
handleInputFields?: boolean;
scrollIntoView?: boolean | ScrollIntoViewOptions;
children(renderProps: { onKeyDownHandler(ev: React.KeyboardEvent): void; onDragEndHandler(): void }): ReactNode;
onKeyDown?(ev: React.KeyboardEvent, state: IState, dispatch: Dispatch<IAction>): void;
}
export const findSiblingElement = (
refs: RefObject<HTMLElement>[],
startIndex: number,
backwards = false,
): RefObject<HTMLElement> => {
loop = false,
): RefObject<HTMLElement> | undefined => {
if (backwards) {
for (let i = startIndex; i < refs.length && i >= 0; i--) {
if (refs[i].current?.offsetParent !== null) {
return refs[i];
}
}
if (loop) {
return findSiblingElement(refs.slice(startIndex + 1), refs.length - 1, true, false);
}
} else {
for (let i = startIndex; i < refs.length && i >= 0; i++) {
if (refs[i].current?.offsetParent !== null) {
return refs[i];
}
}
if (loop) {
return findSiblingElement(refs.slice(0, startIndex), 0, false, false);
}
}
};
@ -190,105 +213,136 @@ export const RovingTabIndexProvider: React.FC<IProps> = ({
handleHomeEnd,
handleUpDown,
handleLeftRight,
handleLoop,
handleInputFields,
scrollIntoView,
onKeyDown,
}) => {
const [state, dispatch] = useReducer<Reducer<IState, IAction>>(reducer, {
activeRef: null,
const [state, dispatch] = useReducer<Reducer<IState, Action>>(reducer, {
refs: [],
});
const context = useMemo<IContext>(() => ({ state, dispatch }), [state]);
const onKeyDownHandler = useCallback((ev: React.KeyboardEvent) => {
if (onKeyDown) {
onKeyDown(ev, context.state);
if (ev.defaultPrevented) {
return;
const onKeyDownHandler = useCallback(
(ev: React.KeyboardEvent) => {
if (onKeyDown) {
onKeyDown(ev, context.state, context.dispatch);
if (ev.defaultPrevented) {
return;
}
}
}
let handled = false;
const action = getKeyBindingsManager().getAccessibilityAction(ev);
let focusRef: RefObject<HTMLElement>;
// Don't interfere with input default keydown behaviour
// but allow people to move focus from it with Tab.
if (checkInputableElement(ev.target as HTMLElement)) {
switch (action) {
case KeyBindingAction.Tab:
handled = true;
if (context.state.refs.length > 0) {
const idx = context.state.refs.indexOf(context.state.activeRef);
focusRef = findSiblingElement(context.state.refs, idx + (ev.shiftKey ? -1 : 1), ev.shiftKey);
}
break;
}
} else {
// check if we actually have any items
switch (action) {
case KeyBindingAction.Home:
if (handleHomeEnd) {
handled = true;
// move focus to first (visible) item
focusRef = findSiblingElement(context.state.refs, 0);
}
break;
case KeyBindingAction.End:
if (handleHomeEnd) {
handled = true;
// move focus to last (visible) item
focusRef = findSiblingElement(context.state.refs, context.state.refs.length - 1, true);
}
break;
case KeyBindingAction.ArrowDown:
case KeyBindingAction.ArrowRight:
if ((action === KeyBindingAction.ArrowDown && handleUpDown) ||
(action === KeyBindingAction.ArrowRight && handleLeftRight)
) {
let handled = false;
const action = getKeyBindingsManager().getAccessibilityAction(ev);
let focusRef: RefObject<HTMLElement> | undefined;
// Don't interfere with input default keydown behaviour
// but allow people to move focus from it with Tab.
if (!handleInputFields && checkInputableElement(ev.target as HTMLElement)) {
switch (action) {
case KeyBindingAction.Tab:
handled = true;
if (context.state.refs.length > 0) {
const idx = context.state.refs.indexOf(context.state.activeRef);
focusRef = findSiblingElement(context.state.refs, idx + 1);
const idx = context.state.refs.indexOf(context.state.activeRef!);
focusRef = findSiblingElement(
context.state.refs,
idx + (ev.shiftKey ? -1 : 1),
ev.shiftKey,
);
}
}
break;
break;
}
} else {
// check if we actually have any items
switch (action) {
case KeyBindingAction.Home:
if (handleHomeEnd) {
handled = true;
// move focus to first (visible) item
focusRef = findSiblingElement(context.state.refs, 0);
}
break;
case KeyBindingAction.ArrowUp:
case KeyBindingAction.ArrowLeft:
if ((action === KeyBindingAction.ArrowUp && handleUpDown) ||
(action === KeyBindingAction.ArrowLeft && handleLeftRight)
) {
handled = true;
if (context.state.refs.length > 0) {
const idx = context.state.refs.indexOf(context.state.activeRef);
focusRef = findSiblingElement(context.state.refs, idx - 1, true);
case KeyBindingAction.End:
if (handleHomeEnd) {
handled = true;
// move focus to last (visible) item
focusRef = findSiblingElement(context.state.refs, context.state.refs.length - 1, true);
}
}
break;
break;
case KeyBindingAction.ArrowDown:
case KeyBindingAction.ArrowRight:
if (
(action === KeyBindingAction.ArrowDown && handleUpDown) ||
(action === KeyBindingAction.ArrowRight && handleLeftRight)
) {
handled = true;
if (context.state.refs.length > 0) {
const idx = context.state.refs.indexOf(context.state.activeRef!);
focusRef = findSiblingElement(context.state.refs, idx + 1, false, handleLoop);
}
}
break;
case KeyBindingAction.ArrowUp:
case KeyBindingAction.ArrowLeft:
if (
(action === KeyBindingAction.ArrowUp && handleUpDown) ||
(action === KeyBindingAction.ArrowLeft && handleLeftRight)
) {
handled = true;
if (context.state.refs.length > 0) {
const idx = context.state.refs.indexOf(context.state.activeRef!);
focusRef = findSiblingElement(context.state.refs, idx - 1, true, handleLoop);
}
}
break;
}
}
}
if (handled) {
ev.preventDefault();
ev.stopPropagation();
}
if (handled) {
ev.preventDefault();
ev.stopPropagation();
}
if (focusRef) {
focusRef.current?.focus();
// programmatic focus doesn't fire the onFocus handler, so we must do the do ourselves
dispatch({
type: Type.SetFocus,
payload: {
ref: focusRef,
},
});
}
}, [context, onKeyDown, handleHomeEnd, handleUpDown, handleLeftRight]);
if (focusRef) {
focusRef.current?.focus();
// programmatic focus doesn't fire the onFocus handler, so we must do the do ourselves
dispatch({
type: Type.SetFocus,
payload: {
ref: focusRef,
},
});
if (scrollIntoView) {
focusRef.current?.scrollIntoView(scrollIntoView);
}
}
},
[
context,
onKeyDown,
handleHomeEnd,
handleUpDown,
handleLeftRight,
handleLoop,
handleInputFields,
scrollIntoView,
],
);
return <RovingTabIndexContext.Provider value={context}>
{ children({ onKeyDownHandler }) }
</RovingTabIndexContext.Provider>;
const onDragEndHandler = useCallback(() => {
dispatch({
type: Type.Update,
});
}, []);
return (
<RovingTabIndexContext.Provider value={context}>
{children({ onKeyDownHandler, onDragEndHandler })}
</RovingTabIndexContext.Provider>
);
};
// Hook to register a roving tab index

View file

@ -14,20 +14,19 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import React from "react";
import React, { forwardRef } from "react";
import { RovingTabIndexProvider } from "./RovingTabIndex";
import { getKeyBindingsManager } from "../KeyBindingsManager";
import { KeyBindingAction } from "./KeyboardShortcuts";
interface IProps extends Omit<React.HTMLProps<HTMLDivElement>, "onKeyDown"> {
}
interface IProps extends Omit<React.HTMLProps<HTMLDivElement>, "onKeyDown"> {}
// This component implements the Toolbar design pattern from the WAI-ARIA Authoring Practices guidelines.
// https://www.w3.org/TR/wai-aria-practices-1.1/#toolbar
// All buttons passed in children must use RovingTabIndex to set `onFocus`, `isActive`, `ref`
const Toolbar: React.FC<IProps> = ({ children, ...props }) => {
const onKeyDown = (ev: React.KeyboardEvent) => {
const Toolbar = forwardRef<HTMLDivElement, IProps>(({ children, ...props }, ref) => {
const onKeyDown = (ev: React.KeyboardEvent): void => {
const target = ev.target as HTMLElement;
// Don't interfere with input default keydown behaviour
if (target.tagName === "INPUT") return;
@ -39,7 +38,7 @@ const Toolbar: React.FC<IProps> = ({ children, ...props }) => {
switch (action) {
case KeyBindingAction.ArrowUp:
case KeyBindingAction.ArrowDown:
if (target.hasAttribute('aria-haspopup')) {
if (target.hasAttribute("aria-haspopup")) {
target.click();
}
break;
@ -54,11 +53,16 @@ const Toolbar: React.FC<IProps> = ({ children, ...props }) => {
}
};
return <RovingTabIndexProvider handleHomeEnd handleLeftRight onKeyDown={onKeyDown}>
{ ({ onKeyDownHandler }) => <div {...props} onKeyDown={onKeyDownHandler} role="toolbar">
{ children }
</div> }
</RovingTabIndexProvider>;
};
// We handle both up/down and left/right as is allowed in the above WAI ARIA best practices
return (
<RovingTabIndexProvider handleHomeEnd handleLeftRight handleUpDown onKeyDown={onKeyDown}>
{({ onKeyDownHandler }) => (
<div {...props} onKeyDown={onKeyDownHandler} role="toolbar" ref={ref}>
{children}
</div>
)}
</RovingTabIndexProvider>
);
});
export default Toolbar;

View file

@ -16,36 +16,33 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import React from "react";
import React, { ComponentProps, forwardRef, Ref } from "react";
import AccessibleButton from "../../components/views/elements/AccessibleButton";
interface IProps extends React.ComponentProps<typeof AccessibleButton> {
type Props<T extends keyof JSX.IntrinsicElements> = ComponentProps<typeof AccessibleButton<T>> & {
label?: string;
// whether or not the context menu is currently open
// whether the context menu is currently open
isExpanded: boolean;
}
};
// Semantic component for representing the AccessibleButton which launches a <ContextMenu />
export const ContextMenuButton: React.FC<IProps> = ({
label,
isExpanded,
children,
onClick,
onContextMenu,
...props
}) => {
export const ContextMenuButton = forwardRef(function <T extends keyof JSX.IntrinsicElements>(
{ label, isExpanded, children, onClick, onContextMenu, ...props }: Props<T>,
ref: Ref<HTMLElement>,
) {
return (
<AccessibleButton
{...props}
onClick={onClick}
onContextMenu={onContextMenu || onClick}
onContextMenu={onContextMenu ?? onClick ?? undefined}
title={label}
aria-label={label}
aria-haspopup={true}
aria-expanded={isExpanded}
ref={ref}
>
{ children }
{children}
</AccessibleButton>
);
};
});

View file

@ -16,33 +16,31 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import React from "react";
import React, { ComponentProps, forwardRef, Ref } from "react";
import AccessibleTooltipButton from "../../components/views/elements/AccessibleTooltipButton";
interface IProps extends React.ComponentProps<typeof AccessibleTooltipButton> {
// whether or not the context menu is currently open
type Props<T extends keyof JSX.IntrinsicElements> = ComponentProps<typeof AccessibleTooltipButton<T>> & {
// whether the context menu is currently open
isExpanded: boolean;
}
};
// Semantic component for representing the AccessibleButton which launches a <ContextMenu />
export const ContextMenuTooltipButton: React.FC<IProps> = ({
isExpanded,
children,
onClick,
onContextMenu,
...props
}) => {
export const ContextMenuTooltipButton = forwardRef(function <T extends keyof JSX.IntrinsicElements>(
{ isExpanded, children, onClick, onContextMenu, ...props }: Props<T>,
ref: Ref<HTMLElement>,
) {
return (
<AccessibleTooltipButton
{...props}
onClick={onClick}
onContextMenu={onContextMenu || onClick}
onContextMenu={onContextMenu ?? onClick ?? undefined}
aria-haspopup={true}
aria-expanded={isExpanded}
forceHide={isExpanded}
ref={ref}
>
{ children }
{children}
</AccessibleTooltipButton>
);
};
});

View file

@ -24,7 +24,9 @@ interface IProps extends React.HTMLAttributes<HTMLDivElement> {
// Semantic component for representing a role=group for grouping menu radios/checkboxes
export const MenuGroup: React.FC<IProps> = ({ children, label, ...props }) => {
return <div {...props} role="group" aria-label={label}>
{ children }
</div>;
return (
<div {...props} role="group" aria-label={label}>
{children}
</div>
);
};

View file

@ -30,14 +30,16 @@ export const MenuItem: React.FC<IProps> = ({ children, label, tooltip, ...props
const ariaLabel = props["aria-label"] || label;
if (tooltip) {
return <RovingAccessibleTooltipButton {...props} role="menuitem" aria-label={ariaLabel} title={tooltip}>
{ children }
</RovingAccessibleTooltipButton>;
return (
<RovingAccessibleTooltipButton {...props} role="menuitem" aria-label={ariaLabel} title={tooltip}>
{children}
</RovingAccessibleTooltipButton>
);
}
return (
<RovingAccessibleButton {...props} role="menuitem" aria-label={ariaLabel}>
{ children }
{children}
</RovingAccessibleButton>
);
};

View file

@ -36,7 +36,7 @@ export const MenuItemCheckbox: React.FC<IProps> = ({ children, label, active, di
disabled={disabled}
aria-label={label}
>
{ children }
{children}
</RovingAccessibleButton>
);
};

View file

@ -36,7 +36,7 @@ export const MenuItemRadio: React.FC<IProps> = ({ children, label, active, disab
disabled={disabled}
aria-label={label}
>
{ children }
{children}
</RovingAccessibleButton>
);
};

View file

@ -25,7 +25,7 @@ import { getKeyBindingsManager } from "../../KeyBindingsManager";
interface IProps extends React.ComponentProps<typeof StyledCheckbox> {
label?: string;
onChange(); // we handle keyup/down ourselves so lose the ChangeEvent
onChange(): void; // we handle keyup/down ourselves so lose the ChangeEvent
onClose(): void; // gets called after onChange on KeyBindingAction.ActivateSelectedButton
}
@ -33,7 +33,7 @@ interface IProps extends React.ComponentProps<typeof StyledCheckbox> {
export const StyledMenuItemCheckbox: React.FC<IProps> = ({ children, label, onChange, onClose, ...props }) => {
const [onFocus, isActive, ref] = useRovingTabIndex<HTMLInputElement>();
const onKeyDown = (e: React.KeyboardEvent) => {
const onKeyDown = (e: React.KeyboardEvent): void => {
let handled = true;
const action = getKeyBindingsManager().getAccessibilityAction(e);
@ -55,7 +55,7 @@ export const StyledMenuItemCheckbox: React.FC<IProps> = ({ children, label, onCh
e.preventDefault();
}
};
const onKeyUp = (e: React.KeyboardEvent) => {
const onKeyUp = (e: React.KeyboardEvent): void => {
const action = getKeyBindingsManager().getAccessibilityAction(e);
switch (action) {
case KeyBindingAction.Space:
@ -79,7 +79,7 @@ export const StyledMenuItemCheckbox: React.FC<IProps> = ({ children, label, onCh
inputRef={ref}
tabIndex={isActive ? 0 : -1}
>
{ children }
{children}
</StyledCheckbox>
);
};

View file

@ -25,7 +25,7 @@ import { getKeyBindingsManager } from "../../KeyBindingsManager";
interface IProps extends React.ComponentProps<typeof StyledRadioButton> {
label?: string;
onChange(); // we handle keyup/down ourselves so lose the ChangeEvent
onChange(): void; // we handle keyup/down ourselves so lose the ChangeEvent
onClose(): void; // gets called after onChange on KeyBindingAction.Enter
}
@ -33,7 +33,7 @@ interface IProps extends React.ComponentProps<typeof StyledRadioButton> {
export const StyledMenuItemRadio: React.FC<IProps> = ({ children, label, onChange, onClose, ...props }) => {
const [onFocus, isActive, ref] = useRovingTabIndex<HTMLInputElement>();
const onKeyDown = (e: React.KeyboardEvent) => {
const onKeyDown = (e: React.KeyboardEvent): void => {
let handled = true;
const action = getKeyBindingsManager().getAccessibilityAction(e);
@ -55,7 +55,7 @@ export const StyledMenuItemRadio: React.FC<IProps> = ({ children, label, onChang
e.preventDefault();
}
};
const onKeyUp = (e: React.KeyboardEvent) => {
const onKeyUp = (e: React.KeyboardEvent): void => {
const action = getKeyBindingsManager().getAccessibilityAction(e);
switch (action) {
case KeyBindingAction.Enter:
@ -79,7 +79,7 @@ export const StyledMenuItemRadio: React.FC<IProps> = ({ children, label, onChang
inputRef={ref}
tabIndex={isActive ? 0 : -1}
>
{ children }
{children}
</StyledRadioButton>
);
};

View file

@ -14,27 +14,42 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import React from "react";
import React, { ComponentProps } from "react";
import AccessibleButton from "../../components/views/elements/AccessibleButton";
import { useRovingTabIndex } from "../RovingTabIndex";
import { Ref } from "./types";
interface IProps extends Omit<React.ComponentProps<typeof AccessibleButton>, "inputRef" | "tabIndex"> {
type Props<T extends keyof JSX.IntrinsicElements> = Omit<
ComponentProps<typeof AccessibleButton<T>>,
"inputRef" | "tabIndex"
> & {
inputRef?: Ref;
}
// Wrapper to allow use of useRovingTabIndex for simple AccessibleButtons outside of React Functional Components.
export const RovingAccessibleButton: React.FC<IProps> = ({ inputRef, onFocus, ...props }) => {
const [onFocusInternal, isActive, ref] = useRovingTabIndex(inputRef);
return <AccessibleButton
{...props}
onFocus={event => {
onFocusInternal();
onFocus?.(event);
}}
inputRef={ref}
tabIndex={isActive ? 0 : -1}
/>;
focusOnMouseOver?: boolean;
};
// Wrapper to allow use of useRovingTabIndex for simple AccessibleButtons outside of React Functional Components.
export const RovingAccessibleButton = <T extends keyof JSX.IntrinsicElements>({
inputRef,
onFocus,
onMouseOver,
focusOnMouseOver,
...props
}: Props<T>): JSX.Element => {
const [onFocusInternal, isActive, ref] = useRovingTabIndex(inputRef);
return (
<AccessibleButton
{...props}
onFocus={(event: React.FocusEvent) => {
onFocusInternal();
onFocus?.(event);
}}
onMouseOver={(event: React.MouseEvent) => {
if (focusOnMouseOver) onFocusInternal();
onMouseOver?.(event);
}}
ref={ref}
tabIndex={isActive ? 0 : -1}
/>
);
};

View file

@ -14,28 +14,35 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import React from "react";
import React, { ComponentProps } from "react";
import AccessibleTooltipButton from "../../components/views/elements/AccessibleTooltipButton";
import { useRovingTabIndex } from "../RovingTabIndex";
import { Ref } from "./types";
type ATBProps = React.ComponentProps<typeof AccessibleTooltipButton>;
interface IProps extends Omit<ATBProps, "inputRef" | "tabIndex"> {
type Props<T extends keyof JSX.IntrinsicElements> = Omit<
ComponentProps<typeof AccessibleTooltipButton<T>>,
"tabIndex"
> & {
inputRef?: Ref;
}
// Wrapper to allow use of useRovingTabIndex for simple AccessibleTooltipButtons outside of React Functional Components.
export const RovingAccessibleTooltipButton: React.FC<IProps> = ({ inputRef, onFocus, ...props }) => {
const [onFocusInternal, isActive, ref] = useRovingTabIndex(inputRef);
return <AccessibleTooltipButton
{...props}
onFocus={event => {
onFocusInternal();
onFocus?.(event);
}}
inputRef={ref}
tabIndex={isActive ? 0 : -1}
/>;
};
// Wrapper to allow use of useRovingTabIndex for simple AccessibleTooltipButtons outside of React Functional Components.
export const RovingAccessibleTooltipButton = <T extends keyof JSX.IntrinsicElements>({
inputRef,
onFocus,
...props
}: Props<T>): JSX.Element => {
const [onFocusInternal, isActive, ref] = useRovingTabIndex(inputRef);
return (
<AccessibleTooltipButton
{...props}
onFocus={(event: React.FocusEvent) => {
onFocusInternal();
onFocus?.(event);
}}
ref={ref}
tabIndex={isActive ? 0 : -1}
/>
);
};

View file

@ -14,18 +14,14 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import React from "react";
import React, { ReactElement } from "react";
import { useRovingTabIndex } from "../RovingTabIndex";
import { FocusHandler, Ref } from "./types";
interface IProps {
inputRef?: Ref;
children(renderProps: {
onFocus: FocusHandler;
isActive: boolean;
ref: Ref;
});
children(renderProps: { onFocus: FocusHandler; isActive: boolean; ref: Ref }): ReactElement<any, any>;
}
// Wrapper to allow use of useRovingTabIndex outside of React Functional Components.

View file

@ -14,11 +14,17 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import { ClientEvent, MatrixClient } from "matrix-js-sdk/src/client";
import { MatrixEvent, MatrixEventEvent } from "matrix-js-sdk/src/models/event";
import { Room, RoomEvent } from "matrix-js-sdk/src/models/room";
import { IRoomTimelineData } from "matrix-js-sdk/src/models/event-timeline-set";
import { RoomState, RoomStateEvent } from "matrix-js-sdk/src/models/room-state";
import {
ClientEvent,
MatrixClient,
MatrixEvent,
MatrixEventEvent,
Room,
RoomEvent,
IRoomTimelineData,
RoomState,
RoomStateEvent,
} from "matrix-js-sdk/src/matrix";
import dis from "../dispatcher/dispatcher";
import { ActionPayload } from "../dispatcher/payloads";
@ -34,7 +40,7 @@ import { ActionPayload } from "../dispatcher/payloads";
*/
function createSyncAction(matrixClient: MatrixClient, state: string, prevState: string): ActionPayload {
return {
action: 'MatrixActions.sync',
action: "MatrixActions.sync",
state,
prevState,
matrixClient,
@ -48,6 +54,7 @@ function createSyncAction(matrixClient: MatrixClient, state: string, prevState:
* @property {MatrixEvent} event the MatrixEvent that triggered the dispatch.
* @property {string} event_type the type of the MatrixEvent, e.g. "m.direct".
* @property {Object} event_content the content of the MatrixEvent.
* @property {MatrixEvent} previousEvent the previous account data event of the same type, if present
*/
/**
@ -56,14 +63,20 @@ function createSyncAction(matrixClient: MatrixClient, state: string, prevState:
*
* @param {MatrixClient} matrixClient the matrix client.
* @param {MatrixEvent} accountDataEvent the account data event.
* @param {MatrixEvent | undefined} previousAccountDataEvent the previous account data event of the same type, if present
* @returns {AccountDataAction} an action of type MatrixActions.accountData.
*/
function createAccountDataAction(matrixClient: MatrixClient, accountDataEvent: MatrixEvent): ActionPayload {
function createAccountDataAction(
matrixClient: MatrixClient,
accountDataEvent: MatrixEvent,
previousAccountDataEvent?: MatrixEvent,
): ActionPayload {
return {
action: 'MatrixActions.accountData',
action: "MatrixActions.accountData",
event: accountDataEvent,
event_type: accountDataEvent.getType(),
event_content: accountDataEvent.getContent(),
previousEvent: previousAccountDataEvent,
};
}
@ -92,7 +105,7 @@ function createRoomAccountDataAction(
room: Room,
): ActionPayload {
return {
action: 'MatrixActions.Room.accountData',
action: "MatrixActions.Room.accountData",
event: accountDataEvent,
event_type: accountDataEvent.getType(),
event_content: accountDataEvent.getContent(),
@ -116,7 +129,7 @@ function createRoomAccountDataAction(
* @returns {RoomAction} an action of type `MatrixActions.Room`.
*/
function createRoomAction(matrixClient: MatrixClient, room: Room): ActionPayload {
return { action: 'MatrixActions.Room', room };
return { action: "MatrixActions.Room", room };
}
/**
@ -137,7 +150,7 @@ function createRoomAction(matrixClient: MatrixClient, room: Room): ActionPayload
* @returns {RoomTagsAction} an action of type `MatrixActions.Room.tags`.
*/
function createRoomTagsAction(matrixClient: MatrixClient, roomTagsEvent: MatrixEvent, room: Room): ActionPayload {
return { action: 'MatrixActions.Room.tags', room };
return { action: "MatrixActions.Room.tags", room };
}
/**
@ -151,7 +164,7 @@ function createRoomTagsAction(matrixClient: MatrixClient, roomTagsEvent: MatrixE
*/
function createRoomReceiptAction(matrixClient: MatrixClient, event: MatrixEvent, room: Room): ActionPayload {
return {
action: 'MatrixActions.Room.receipt',
action: "MatrixActions.Room.receipt",
event,
room,
matrixClient,
@ -169,7 +182,7 @@ function createRoomReceiptAction(matrixClient: MatrixClient, event: MatrixEvent,
* @property {Room} room the Room whose tags changed.
*/
export interface IRoomTimelineActionPayload extends Pick<ActionPayload, "action"> {
action: 'MatrixActions.Room.timeline';
action: "MatrixActions.Room.timeline";
event: MatrixEvent;
room: Room | null;
isLiveEvent?: boolean;
@ -185,7 +198,7 @@ export interface IRoomTimelineActionPayload extends Pick<ActionPayload, "action"
* @property {MatrixEvent | null} lastStateEvent the previous value for this (event-type, state-key) tuple in room state
*/
export interface IRoomStateEventsActionPayload extends Pick<ActionPayload, "action"> {
action: 'MatrixActions.RoomState.events';
action: "MatrixActions.RoomState.events";
event: MatrixEvent;
state: RoomState;
lastStateEvent: MatrixEvent | null;
@ -218,10 +231,10 @@ function createRoomTimelineAction(
data: IRoomTimelineData,
): IRoomTimelineActionPayload {
return {
action: 'MatrixActions.Room.timeline',
action: "MatrixActions.Room.timeline",
event: timelineEvent,
isLiveEvent: data.liveEvent,
isLiveUnfilteredRoomTimelineEvent: room && data.timeline.getTimelineSet() === room.getUnfilteredTimelineSet(),
isLiveUnfilteredRoomTimelineEvent: data.timeline.getTimelineSet() === room?.getUnfilteredTimelineSet(),
room,
};
}
@ -244,7 +257,7 @@ function createRoomStateEventsAction(
lastStateEvent: MatrixEvent | null,
): IRoomStateEventsActionPayload {
return {
action: 'MatrixActions.RoomState.events',
action: "MatrixActions.RoomState.events",
event,
state,
lastStateEvent,
@ -277,7 +290,7 @@ function createSelfMembershipAction(
membership: string,
oldMembership: string,
): ActionPayload {
return { action: 'MatrixActions.Room.myMembership', room, membership, oldMembership };
return { action: "MatrixActions.Room.myMembership", room, membership, oldMembership };
}
/**
@ -297,7 +310,7 @@ function createSelfMembershipAction(
* @returns {EventDecryptedAction} an action of type `MatrixActions.Event.decrypted`.
*/
function createEventDecryptedAction(matrixClient: MatrixClient, event: MatrixEvent): ActionPayload {
return { action: 'MatrixActions.Event.decrypted', event };
return { action: "MatrixActions.Event.decrypted", event };
}
type Listener = () => void;

View file

@ -15,19 +15,18 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import { MatrixClient } from "matrix-js-sdk/src/client";
import { Room } from "matrix-js-sdk/src/models/room";
import { MatrixClient, Room } from "matrix-js-sdk/src/matrix";
import { logger } from "matrix-js-sdk/src/logger";
import { asyncAction } from './actionCreators';
import Modal from '../Modal';
import * as Rooms from '../Rooms';
import { _t } from '../languageHandler';
import { asyncAction } from "./actionCreators";
import Modal from "../Modal";
import * as Rooms from "../Rooms";
import { _t } from "../languageHandler";
import { AsyncActionPayload } from "../dispatcher/payloads";
import RoomListStore from "../stores/room-list/RoomListStore";
import { SortAlgorithm } from "../stores/room-list/algorithms/models";
import { DefaultTagID } from "../stores/room-list/models";
import ErrorDialog from '../components/views/dialogs/ErrorDialog';
import { DefaultTagID, TagID } from "../stores/room-list/models";
import ErrorDialog from "../components/views/dialogs/ErrorDialog";
export default class RoomListActions {
/**
@ -47,11 +46,13 @@ export default class RoomListActions {
* @see asyncAction
*/
public static tagRoom(
matrixClient: MatrixClient, room: Room,
oldTag: string, newTag: string,
oldIndex: number | null, newIndex: number | null,
matrixClient: MatrixClient,
room: Room,
oldTag: TagID | null,
newTag: TagID | null,
newIndex: number,
): AsyncActionPayload {
let metaData = null;
let metaData: Parameters<MatrixClient["setRoomTag"]>[2] | undefined;
// Is the tag ordered manually?
const store = RoomListStore.instance;
@ -60,93 +61,81 @@ export default class RoomListActions {
newList.sort((a, b) => a.tags[newTag].order - b.tags[newTag].order);
// If the room was moved "down" (increasing index) in the same list we
// need to use the orders of the tiles with indices shifted by +1
const offset = (
newTag === oldTag && oldIndex < newIndex
) ? 1 : 0;
const indexBefore = newIndex - 1;
const indexAfter = newIndex;
const indexBefore = offset + newIndex - 1;
const indexAfter = offset + newIndex;
const prevOrder = indexBefore <= 0 ?
0 : newList[indexBefore].tags[newTag].order;
const nextOrder = indexAfter >= newList.length ?
1 : newList[indexAfter].tags[newTag].order;
const prevOrder = indexBefore <= 0 ? 0 : newList[indexBefore].tags[newTag].order;
const nextOrder = indexAfter >= newList.length ? 1 : newList[indexAfter].tags[newTag].order;
metaData = {
order: (prevOrder + nextOrder) / 2.0,
};
}
return asyncAction('RoomListActions.tagRoom', () => {
const promises = [];
const roomId = room.roomId;
return asyncAction(
"RoomListActions.tagRoom",
() => {
const promises: Promise<any>[] = [];
const roomId = room.roomId;
// Evil hack to get DMs behaving
if ((oldTag === undefined && newTag === DefaultTagID.DM) ||
(oldTag === DefaultTagID.DM && newTag === undefined)
) {
return Rooms.guessAndSetDMRoom(
room, newTag === DefaultTagID.DM,
).catch((err) => {
logger.error("Failed to set DM tag " + err);
Modal.createDialog(ErrorDialog, {
title: _t('Failed to set direct message tag'),
description: ((err && err.message) ? err.message : _t('Operation failed')),
// Evil hack to get DMs behaving
if (
(oldTag === undefined && newTag === DefaultTagID.DM) ||
(oldTag === DefaultTagID.DM && newTag === undefined)
) {
return Rooms.guessAndSetDMRoom(room, newTag === DefaultTagID.DM).catch((err) => {
logger.error("Failed to set DM tag " + err);
Modal.createDialog(ErrorDialog, {
title: _t("room_list|failed_set_dm_tag"),
description: err && err.message ? err.message : _t("invite|failed_generic"),
});
});
});
}
}
const hasChangedSubLists = oldTag !== newTag;
const hasChangedSubLists = oldTag !== newTag;
// More evilness: We will still be dealing with moving to favourites/low prio,
// but we avoid ever doing a request with TAG_DM.
//
// if we moved lists, remove the old tag
if (oldTag && oldTag !== DefaultTagID.DM &&
hasChangedSubLists
) {
const promiseToDelete = matrixClient.deleteRoomTag(
roomId, oldTag,
).catch(function(err) {
logger.error("Failed to remove tag " + oldTag + " from room: " + err);
Modal.createDialog(ErrorDialog, {
title: _t('Failed to remove tag %(tagName)s from room', { tagName: oldTag }),
description: ((err && err.message) ? err.message : _t('Operation failed')),
});
});
promises.push(promiseToDelete);
}
// if we moved lists or the ordering changed, add the new tag
if (newTag && newTag !== DefaultTagID.DM &&
(hasChangedSubLists || metaData)
) {
// metaData is the body of the PUT to set the tag, so it must
// at least be an empty object.
metaData = metaData || {};
const promiseToAdd = matrixClient.setRoomTag(roomId, newTag, metaData).catch(function(err) {
logger.error("Failed to add tag " + newTag + " to room: " + err);
Modal.createDialog(ErrorDialog, {
title: _t('Failed to add tag %(tagName)s to room', { tagName: newTag }),
description: ((err && err.message) ? err.message : _t('Operation failed')),
// More evilness: We will still be dealing with moving to favourites/low prio,
// but we avoid ever doing a request with TAG_DM.
//
// if we moved lists, remove the old tag
if (oldTag && oldTag !== DefaultTagID.DM && hasChangedSubLists) {
const promiseToDelete = matrixClient.deleteRoomTag(roomId, oldTag).catch(function (err) {
logger.error("Failed to remove tag " + oldTag + " from room: " + err);
Modal.createDialog(ErrorDialog, {
title: _t("room_list|failed_remove_tag", { tagName: oldTag }),
description: err && err.message ? err.message : _t("invite|failed_generic"),
});
});
throw err;
});
promises.push(promiseToDelete);
}
promises.push(promiseToAdd);
}
// if we moved lists or the ordering changed, add the new tag
if (newTag && newTag !== DefaultTagID.DM && (hasChangedSubLists || metaData)) {
const promiseToAdd = matrixClient.setRoomTag(roomId, newTag, metaData).catch(function (err) {
logger.error("Failed to add tag " + newTag + " to room: " + err);
Modal.createDialog(ErrorDialog, {
title: _t("room_list|failed_add_tag", { tagName: newTag }),
description: err && err.message ? err.message : _t("invite|failed_generic"),
});
return Promise.all(promises);
}, () => {
// For an optimistic update
return {
room, oldTag, newTag, metaData,
};
});
throw err;
});
promises.push(promiseToAdd);
}
return Promise.all(promises);
},
() => {
// For an optimistic update
return {
room,
oldTag,
newTag,
metaData,
};
},
);
}
}

View file

@ -15,7 +15,7 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import { AsyncActionPayload } from "../dispatcher/payloads";
import { AsyncActionFn, AsyncActionPayload } from "../dispatcher/payloads";
/**
* Create an action thunk that will dispatch actions indicating the current
@ -45,16 +45,18 @@ import { AsyncActionPayload } from "../dispatcher/payloads";
* `fn`.
*/
export function asyncAction(id: string, fn: () => Promise<any>, pendingFn: () => any | null): AsyncActionPayload {
const helper = (dispatch) => {
const helper: AsyncActionFn = (dispatch) => {
dispatch({
action: id + '.pending',
request: typeof pendingFn === 'function' ? pendingFn() : undefined,
});
fn().then((result) => {
dispatch({ action: id + '.success', result });
}).catch((err) => {
dispatch({ action: id + '.failure', err });
action: id + ".pending",
request: typeof pendingFn === "function" ? pendingFn() : undefined,
});
fn()
.then((result) => {
dispatch({ action: id + ".success", result });
})
.catch((err) => {
dispatch({ action: id + ".failure", err });
});
};
return new AsyncActionPayload(helper);
}

View file

@ -19,12 +19,11 @@ import { Action } from "../../dispatcher/actions";
import defaultDispatcher from "../../dispatcher/dispatcher";
/**
* Redirect to the correct device manager section
* Based on the labs setting
* Open user device manager settings
*/
export const viewUserDeviceSettings = (isNewDeviceManagerEnabled: boolean) => {
export const viewUserDeviceSettings = (): void => {
defaultDispatcher.dispatch({
action: Action.ViewUserSettings,
initialTabId: isNewDeviceManagerEnabled ? UserTab.SessionManager : UserTab.Security,
initialTabId: UserTab.SessionManager,
});
};

View file

@ -14,20 +14,20 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import React from 'react';
import React from "react";
import BaseDialog from "../../../../components/views/dialogs/BaseDialog";
import Spinner from "../../../../components/views/elements/Spinner";
import DialogButtons from "../../../../components/views/elements/DialogButtons";
import dis from "../../../../dispatcher/dispatcher";
import { _t } from '../../../../languageHandler';
import { _t } from "../../../../languageHandler";
import SettingsStore from "../../../../settings/SettingsStore";
import EventIndexPeg from "../../../../indexing/EventIndexPeg";
import { Action } from "../../../../dispatcher/actions";
import { SettingLevel } from "../../../../settings/SettingLevel";
interface IProps {
onFinished: (success: boolean) => void;
onFinished: (success?: boolean) => void;
}
interface IState {
@ -38,7 +38,7 @@ interface IState {
* Allows the user to disable the Event Index.
*/
export default class DisableEventIndexDialog extends React.Component<IProps, IState> {
constructor(props: IProps) {
public constructor(props: IProps) {
super(props);
this.state = {
disabling: false,
@ -50,7 +50,7 @@ export default class DisableEventIndexDialog extends React.Component<IProps, ISt
disabling: true,
});
await SettingsStore.setValue('enableEventIndexing', null, SettingLevel.DEVICE, false);
await SettingsStore.setValue("enableEventIndexing", null, SettingLevel.DEVICE, false);
await EventIndexPeg.deleteEventIndex();
this.props.onFinished(true);
dis.fire(Action.ViewUserSettings);
@ -58,11 +58,11 @@ export default class DisableEventIndexDialog extends React.Component<IProps, ISt
public render(): React.ReactNode {
return (
<BaseDialog onFinished={this.props.onFinished} title={_t("Are you sure?")}>
{ _t("If disabled, messages from encrypted rooms won't appear in search results.") }
{ this.state.disabling ? <Spinner /> : <div /> }
<BaseDialog onFinished={this.props.onFinished} title={_t("common|are_you_sure")}>
{_t("settings|security|message_search_disable_warning")}
{this.state.disabling ? <Spinner /> : <div />}
<DialogButtons
primaryButton={_t('Disable')}
primaryButton={_t("action|disable")}
onPrimaryButtonClick={this.onDisable}
primaryButtonClass="danger"
cancelButtonClass="warning"

View file

@ -14,28 +14,31 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import React from 'react';
import React, { ChangeEvent } from "react";
import { Room } from "matrix-js-sdk/src/matrix";
import { _t } from '../../../../languageHandler';
import SdkConfig from '../../../../SdkConfig';
import { _t } from "../../../../languageHandler";
import SdkConfig from "../../../../SdkConfig";
import SettingsStore from "../../../../settings/SettingsStore";
import Modal from '../../../../Modal';
import Modal from "../../../../Modal";
import { formatBytes, formatCountLong } from "../../../../utils/FormattingUtils";
import EventIndexPeg from "../../../../indexing/EventIndexPeg";
import { SettingLevel } from "../../../../settings/SettingLevel";
import Field from '../../../../components/views/elements/Field';
import Field from "../../../../components/views/elements/Field";
import BaseDialog from "../../../../components/views/dialogs/BaseDialog";
import DialogButtons from "../../../../components/views/elements/DialogButtons";
import { IDialogProps } from "../../../../components/views/dialogs/IDialogProps";
import { IIndexStats } from "../../../../indexing/BaseEventIndexManager";
interface IProps extends IDialogProps {}
interface IProps {
onFinished(): void;
}
interface IState {
eventIndexSize: number;
eventCount: number;
crawlingRoomsCount: number;
roomCount: number;
currentRoom: string;
currentRoom: string | null;
crawlerSleepTime: number;
}
@ -43,7 +46,7 @@ interface IState {
* Allows the user to introspect the event index state and disable it.
*/
export default class ManageEventIndexDialog extends React.Component<IProps, IState> {
constructor(props) {
public constructor(props: IProps) {
super(props);
this.state = {
@ -52,14 +55,14 @@ export default class ManageEventIndexDialog extends React.Component<IProps, ISta
crawlingRoomsCount: 0,
roomCount: 0,
currentRoom: null,
crawlerSleepTime:
SettingsStore.getValueAt(SettingLevel.DEVICE, 'crawlerSleepTime'),
crawlerSleepTime: SettingsStore.getValueAt(SettingLevel.DEVICE, "crawlerSleepTime"),
};
}
updateCurrentRoom = async (room) => {
public updateCurrentRoom = async (room: Room): Promise<void> => {
const eventIndex = EventIndexPeg.get();
let stats;
if (!eventIndex) return;
let stats: IIndexStats | undefined;
try {
stats = await eventIndex.getStats();
@ -69,7 +72,7 @@ export default class ManageEventIndexDialog extends React.Component<IProps, ISta
return;
}
let currentRoom = null;
let currentRoom: string | null = null;
if (room) currentRoom = room.name;
const roomStats = eventIndex.crawlingRooms();
@ -77,15 +80,15 @@ export default class ManageEventIndexDialog extends React.Component<IProps, ISta
const roomCount = roomStats.totalRooms.size;
this.setState({
eventIndexSize: stats.size,
eventCount: stats.eventCount,
eventIndexSize: stats?.size ?? 0,
eventCount: stats?.eventCount ?? 0,
crawlingRoomsCount: crawlingRoomsCount,
roomCount: roomCount,
currentRoom: currentRoom,
});
};
componentWillUnmount(): void {
public componentWillUnmount(): void {
const eventIndex = EventIndexPeg.get();
if (eventIndex !== null) {
@ -93,12 +96,12 @@ export default class ManageEventIndexDialog extends React.Component<IProps, ISta
}
}
async componentDidMount(): Promise<void> {
public async componentDidMount(): Promise<void> {
let eventIndexSize = 0;
let crawlingRoomsCount = 0;
let roomCount = 0;
let eventCount = 0;
let currentRoom = null;
let currentRoom: string | null = null;
const eventIndex = EventIndexPeg.get();
@ -107,8 +110,10 @@ export default class ManageEventIndexDialog extends React.Component<IProps, ISta
try {
const stats = await eventIndex.getStats();
eventIndexSize = stats.size;
eventCount = stats.eventCount;
if (stats) {
eventIndexSize = stats.size;
eventCount = stats.eventCount;
}
} catch {
// This call may fail if sporadically, not a huge issue as we
// will try later again in the updateCurrentRoom call and
@ -132,65 +137,68 @@ export default class ManageEventIndexDialog extends React.Component<IProps, ISta
});
}
private onDisable = async () => {
private onDisable = async (): Promise<void> => {
const DisableEventIndexDialog = (await import("./DisableEventIndexDialog")).default;
Modal.createDialog(DisableEventIndexDialog, null, null, /* priority = */ false, /* static = */ true);
Modal.createDialog(DisableEventIndexDialog, undefined, undefined, /* priority = */ false, /* static = */ true);
};
private onCrawlerSleepTimeChange = (e) => {
this.setState({ crawlerSleepTime: e.target.value });
private onCrawlerSleepTimeChange = (e: ChangeEvent<HTMLInputElement>): void => {
this.setState({ crawlerSleepTime: parseInt(e.target.value, 10) });
SettingsStore.setValue("crawlerSleepTime", null, SettingLevel.DEVICE, e.target.value);
};
render() {
public render(): React.ReactNode {
const brand = SdkConfig.get().brand;
let crawlerState;
if (this.state.currentRoom === null) {
crawlerState = _t("Not currently indexing messages for any room.");
crawlerState = _t("settings|security|message_search_indexing_idle");
} else {
crawlerState = (
_t("Currently indexing: %(currentRoom)s", { currentRoom: this.state.currentRoom })
);
crawlerState = _t("settings|security|message_search_indexing", { currentRoom: this.state.currentRoom });
}
const doneRooms = Math.max(0, (this.state.roomCount - this.state.crawlingRoomsCount));
const doneRooms = Math.max(0, this.state.roomCount - this.state.crawlingRoomsCount);
const eventIndexingSettings = (
<div>
{ _t(
"%(brand)s is securely caching encrypted messages locally for them " +
"to appear in search results:",
{ brand },
) }
<div className='mx_SettingsTab_subsectionText'>
{ crawlerState }<br />
{ _t("Space used:") } { formatBytes(this.state.eventIndexSize, 0) }<br />
{ _t("Indexed messages:") } { formatCountLong(this.state.eventCount) }<br />
{ _t("Indexed rooms:") } { _t("%(doneRooms)s out of %(totalRooms)s", {
{_t("settings|security|message_search_intro", {
brand,
})}
<div className="mx_SettingsTab_subsectionText">
{crawlerState}
<br />
{_t("settings|security|message_search_space_used")} {formatBytes(this.state.eventIndexSize, 0)}
<br />
{_t("settings|security|message_search_indexed_messages")} {formatCountLong(this.state.eventCount)}
<br />
{_t("settings|security|message_search_indexed_rooms")}{" "}
{_t("settings|security|message_search_room_progress", {
doneRooms: formatCountLong(doneRooms),
totalRooms: formatCountLong(this.state.roomCount),
}) } <br />
})}{" "}
<br />
<Field
label={_t('Message downloading sleep time(ms)')}
type='number'
label={_t("settings|security|message_search_sleep_time")}
type="number"
value={this.state.crawlerSleepTime.toString()}
onChange={this.onCrawlerSleepTimeChange} />
onChange={this.onCrawlerSleepTimeChange}
/>
</div>
</div>
);
return (
<BaseDialog className='mx_ManageEventIndexDialog'
<BaseDialog
className="mx_ManageEventIndexDialog"
onFinished={this.props.onFinished}
title={_t("Message search")}
title={_t("settings|security|message_search_section")}
>
{ eventIndexingSettings }
{eventIndexingSettings}
<DialogButtons
primaryButton={_t("Done")}
primaryButton={_t("action|done")}
onPrimaryButtonClick={this.props.onFinished}
primaryButtonClass="primary"
cancelButton={_t("Disable")}
cancelButton={_t("action|disable")}
onCancel={this.onDisable}
cancelButtonClass="danger"
/>

View file

@ -15,129 +15,95 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import React, { createRef } from 'react';
import FileSaver from 'file-saver';
import { IPreparedKeyBackupVersion } from "matrix-js-sdk/src/crypto/backup";
import React from "react";
import { logger } from "matrix-js-sdk/src/logger";
import { MatrixClientPeg } from '../../../../MatrixClientPeg';
import { _t, _td } from '../../../../languageHandler';
import { accessSecretStorage } from '../../../../SecurityManager';
import AccessibleButton from "../../../../components/views/elements/AccessibleButton";
import { copyNode } from "../../../../utils/strings";
import PassphraseField from "../../../../components/views/auth/PassphraseField";
import { IDialogProps } from "../../../../components/views/dialogs/IDialogProps";
import Field from "../../../../components/views/elements/Field";
import { MatrixClientPeg } from "../../../../MatrixClientPeg";
import { _t } from "../../../../languageHandler";
import { accessSecretStorage, withSecretStorageKeyCache } from "../../../../SecurityManager";
import Spinner from "../../../../components/views/elements/Spinner";
import BaseDialog from "../../../../components/views/dialogs/BaseDialog";
import DialogButtons from "../../../../components/views/elements/DialogButtons";
import { IValidationResult } from "../../../../components/views/elements/Validation";
enum Phase {
Passphrase = "passphrase",
PassphraseConfirm = "passphrase_confirm",
ShowKey = "show_key",
KeepItSafe = "keep_it_safe",
BackingUp = "backing_up",
Done = "done",
OptOutConfirm = "opt_out_confirm",
}
const PASSWORD_MIN_SCORE = 4; // So secure, many characters, much complex, wow, etc, etc.
interface IProps extends IDialogProps {}
interface IProps {
onFinished(done?: boolean): void;
}
interface IState {
secureSecretStorage: boolean;
phase: Phase;
passPhrase: string;
passPhraseValid: boolean;
passPhraseConfirm: string;
copied: boolean;
downloaded: boolean;
error?: string;
error?: boolean;
}
/*
* Walks the user through the process of creating an e2e key backup
* on the server.
/**
* Walks the user through the process of setting up e2e key backups to a new backup, and storing the decryption key in
* SSSS.
*
* Uses {@link accessSecretStorage}, which means that if 4S is not already configured, it will be bootstrapped (which
* involves displaying an {@link CreateSecretStorageDialog} so the user can enter a passphrase and/or download the 4S
* key).
*/
export default class CreateKeyBackupDialog extends React.PureComponent<IProps, IState> {
private keyBackupInfo: Pick<IPreparedKeyBackupVersion, "recovery_key" | "algorithm" | "auth_data">;
private recoveryKeyNode = createRef<HTMLElement>();
private passphraseField = createRef<Field>();
constructor(props: IProps) {
public constructor(props: IProps) {
super(props);
this.state = {
secureSecretStorage: null,
phase: Phase.Passphrase,
passPhrase: '',
phase: Phase.BackingUp,
passPhrase: "",
passPhraseValid: false,
passPhraseConfirm: '',
passPhraseConfirm: "",
copied: false,
downloaded: false,
};
}
public async componentDidMount(): Promise<void> {
const cli = MatrixClientPeg.get();
const secureSecretStorage = await cli.doesServerSupportUnstableFeature("org.matrix.e2e_cross_signing");
this.setState({ secureSecretStorage });
// If we're using secret storage, skip ahead to the backing up step, as
// `accessSecretStorage` will handle passphrases as needed.
if (secureSecretStorage) {
this.setState({ phase: Phase.BackingUp });
this.createBackup();
}
public componentDidMount(): void {
this.createBackup();
}
private onCopyClick = (): void => {
const successful = copyNode(this.recoveryKeyNode.current);
if (successful) {
this.setState({
copied: true,
phase: Phase.KeepItSafe,
});
}
};
private onDownloadClick = (): void => {
const blob = new Blob([this.keyBackupInfo.recovery_key], {
type: 'text/plain;charset=us-ascii',
});
FileSaver.saveAs(blob, 'security-key.txt');
this.setState({
downloaded: true,
phase: Phase.KeepItSafe,
});
};
private createBackup = async (): Promise<void> => {
const { secureSecretStorage } = this.state;
this.setState({
phase: Phase.BackingUp,
error: null,
error: undefined,
});
let info;
const cli = MatrixClientPeg.safeGet();
try {
if (secureSecretStorage) {
await accessSecretStorage(async () => {
info = await MatrixClientPeg.get().prepareKeyBackupVersion(
null /* random key */,
{ secureSecretStorage: true },
);
info = await MatrixClientPeg.get().createKeyBackupVersion(info);
// Check if 4S already set up
const secretStorageAlreadySetup = await cli.hasSecretStorageKey();
if (!secretStorageAlreadySetup) {
// bootstrap secret storage; that will also create a backup version
await accessSecretStorage(async (): Promise<void> => {
// do nothing, all is now set up correctly
});
} else {
info = await MatrixClientPeg.get().createKeyBackupVersion(
this.keyBackupInfo,
);
await withSecretStorageKeyCache(async () => {
const crypto = cli.getCrypto();
if (!crypto) {
throw new Error("End-to-end encryption is disabled - unable to create backup.");
}
// Before we reset the backup, let's make sure we can access secret storage, to
// reduce the chance of us getting into a broken state where we have an outdated
// secret in secret storage.
// `SecretStorage.get` will ask the user to enter their passphrase/key if necessary;
// it will then be cached for the actual backup reset operation.
await cli.secretStorage.get("m.megolm_backup.v1");
// We now know we can store the new backup key in secret storage, so it is safe to
// go ahead with the reset.
await crypto.resetKeyBackup();
});
}
await MatrixClientPeg.get().scheduleAllGroupSessionsForBackup();
this.setState({
phase: Phase.Done,
});
@ -147,11 +113,8 @@ export default class CreateKeyBackupDialog extends React.PureComponent<IProps, I
// delete the version, disable backup, or do nothing? If we just
// disable without deleting, we'll enable on next app reload since
// it is trusted.
if (info) {
MatrixClientPeg.get().deleteKeyBackupVersion(info.version);
}
this.setState({
error: e,
error: true,
});
}
};
@ -164,337 +127,67 @@ export default class CreateKeyBackupDialog extends React.PureComponent<IProps, I
this.props.onFinished(true);
};
private onSetUpClick = (): void => {
this.setState({ phase: Phase.Passphrase });
};
private onSkipPassPhraseClick = async (): Promise<void> => {
this.keyBackupInfo = await MatrixClientPeg.get().prepareKeyBackupVersion();
this.setState({
copied: false,
downloaded: false,
phase: Phase.ShowKey,
});
};
private onPassPhraseNextClick = async (e: React.FormEvent): Promise<void> => {
e.preventDefault();
if (!this.passphraseField.current) return; // unmounting
await this.passphraseField.current.validate({ allowEmpty: false });
if (!this.passphraseField.current.state.valid) {
this.passphraseField.current.focus();
this.passphraseField.current.validate({ allowEmpty: false, focused: true });
return;
}
this.setState({ phase: Phase.PassphraseConfirm });
};
private onPassPhraseConfirmNextClick = async (e: React.FormEvent): Promise<void> => {
e.preventDefault();
if (this.state.passPhrase !== this.state.passPhraseConfirm) return;
this.keyBackupInfo = await MatrixClientPeg.get().prepareKeyBackupVersion(this.state.passPhrase);
this.setState({
copied: false,
downloaded: false,
phase: Phase.ShowKey,
});
};
private onSetAgainClick = (): void => {
this.setState({
passPhrase: '',
passPhraseValid: false,
passPhraseConfirm: '',
phase: Phase.Passphrase,
});
};
private onKeepItSafeBackClick = (): void => {
this.setState({
phase: Phase.ShowKey,
});
};
private onPassPhraseValidate = (result: IValidationResult): void => {
this.setState({
passPhraseValid: result.valid,
});
};
private onPassPhraseChange = (e: React.ChangeEvent<HTMLInputElement>): void => {
this.setState({
passPhrase: e.target.value,
});
};
private onPassPhraseConfirmChange = (e: React.ChangeEvent<HTMLInputElement>): void => {
this.setState({
passPhraseConfirm: e.target.value,
});
};
private renderPhasePassPhrase(): JSX.Element {
return <form onSubmit={this.onPassPhraseNextClick}>
<p>{ _t(
"<b>Warning</b>: You should only set up key backup from a trusted computer.", {},
{ b: sub => <b>{ sub }</b> },
) }</p>
<p>{ _t(
"We'll store an encrypted copy of your keys on our server. " +
"Secure your backup with a Security Phrase.",
) }</p>
<p>{ _t("For maximum security, this should be different from your account password.") }</p>
<div className="mx_CreateKeyBackupDialog_primaryContainer">
<div className="mx_CreateKeyBackupDialog_passPhraseContainer">
<PassphraseField
className="mx_CreateKeyBackupDialog_passPhraseInput"
onChange={this.onPassPhraseChange}
minScore={PASSWORD_MIN_SCORE}
value={this.state.passPhrase}
onValidate={this.onPassPhraseValidate}
fieldRef={this.passphraseField}
autoFocus={true}
label={_td("Enter a Security Phrase")}
labelEnterPassword={_td("Enter a Security Phrase")}
labelStrongPassword={_td("Great! This Security Phrase looks strong enough.")}
labelAllowedButUnsafe={_td("Great! This Security Phrase looks strong enough.")}
/>
</div>
</div>
<DialogButtons
primaryButton={_t('Next')}
onPrimaryButtonClick={this.onPassPhraseNextClick}
hasCancel={false}
disabled={!this.state.passPhraseValid}
/>
<details>
<summary>{ _t("Advanced") }</summary>
<AccessibleButton kind='primary' onClick={this.onSkipPassPhraseClick}>
{ _t("Set up with a Security Key") }
</AccessibleButton>
</details>
</form>;
}
private renderPhasePassPhraseConfirm(): JSX.Element {
let matchText;
let changeText;
if (this.state.passPhraseConfirm === this.state.passPhrase) {
matchText = _t("That matches!");
changeText = _t("Use a different passphrase?");
} else if (!this.state.passPhrase.startsWith(this.state.passPhraseConfirm)) {
// only tell them they're wrong if they've actually gone wrong.
// Security conscious readers will note that if you left element-web unattended
// on this screen, this would make it easy for a malicious person to guess
// your passphrase one letter at a time, but they could get this faster by
// just opening the browser's developer tools and reading it.
// Note that not having typed anything at all will not hit this clause and
// fall through so empty box === no hint.
matchText = _t("That doesn't match.");
changeText = _t("Go back to set it again.");
}
let passPhraseMatch = null;
if (matchText) {
passPhraseMatch = <div className="mx_CreateKeyBackupDialog_passPhraseMatch">
<div>{ matchText }</div>
<AccessibleButton kind="link" onClick={this.onSetAgainClick}>
{ changeText }
</AccessibleButton>
</div>;
}
return <form onSubmit={this.onPassPhraseConfirmNextClick}>
<p>{ _t(
"Enter your Security Phrase a second time to confirm it.",
) }</p>
<div className="mx_CreateKeyBackupDialog_primaryContainer">
<div className="mx_CreateKeyBackupDialog_passPhraseContainer">
<div>
<input type="password"
onChange={this.onPassPhraseConfirmChange}
value={this.state.passPhraseConfirm}
className="mx_CreateKeyBackupDialog_passPhraseInput"
placeholder={_t("Repeat your Security Phrase...")}
autoFocus={true}
/>
</div>
{ passPhraseMatch }
</div>
</div>
<DialogButtons
primaryButton={_t('Next')}
onPrimaryButtonClick={this.onPassPhraseConfirmNextClick}
hasCancel={false}
disabled={this.state.passPhrase !== this.state.passPhraseConfirm}
/>
</form>;
}
private renderPhaseShowKey(): JSX.Element {
return <div>
<p>{ _t(
"Your Security Key is a safety net - you can use it to restore " +
"access to your encrypted messages if you forget your Security Phrase.",
) }</p>
<p>{ _t(
"Keep a copy of it somewhere secure, like a password manager or even a safe.",
) }</p>
<div className="mx_CreateKeyBackupDialog_primaryContainer">
<div className="mx_CreateKeyBackupDialog_recoveryKeyHeader">
{ _t("Your Security Key") }
</div>
<div className="mx_CreateKeyBackupDialog_recoveryKeyContainer">
<div className="mx_CreateKeyBackupDialog_recoveryKey">
<code ref={this.recoveryKeyNode}>{ this.keyBackupInfo.recovery_key }</code>
</div>
<div className="mx_CreateKeyBackupDialog_recoveryKeyButtons">
<button className="mx_Dialog_primary" onClick={this.onCopyClick}>
{ _t("Copy") }
</button>
<button className="mx_Dialog_primary" onClick={this.onDownloadClick}>
{ _t("Download") }
</button>
</div>
</div>
</div>
</div>;
}
private renderPhaseKeepItSafe(): JSX.Element {
let introText;
if (this.state.copied) {
introText = _t(
"Your Security Key has been <b>copied to your clipboard</b>, paste it to:",
{}, { b: s => <b>{ s }</b> },
);
} else if (this.state.downloaded) {
introText = _t(
"Your Security Key is in your <b>Downloads</b> folder.",
{}, { b: s => <b>{ s }</b> },
);
}
return <div>
{ introText }
<ul>
<li>{ _t("<b>Print it</b> and store it somewhere safe", {}, { b: s => <b>{ s }</b> }) }</li>
<li>{ _t("<b>Save it</b> on a USB key or backup drive", {}, { b: s => <b>{ s }</b> }) }</li>
<li>{ _t("<b>Copy it</b> to your personal cloud storage", {}, { b: s => <b>{ s }</b> }) }</li>
</ul>
<DialogButtons primaryButton={_t("Continue")}
onPrimaryButtonClick={this.createBackup}
hasCancel={false}>
<button onClick={this.onKeepItSafeBackClick}>{ _t("Back") }</button>
</DialogButtons>
</div>;
}
private renderBusyPhase(): JSX.Element {
return <div>
<Spinner />
</div>;
return (
<div>
<Spinner />
</div>
);
}
private renderPhaseDone(): JSX.Element {
return <div>
<p>{ _t(
"Your keys are being backed up (the first backup could take a few minutes).",
) }</p>
<DialogButtons primaryButton={_t('OK')}
onPrimaryButtonClick={this.onDone}
hasCancel={false}
/>
</div>;
}
private renderPhaseOptOutConfirm(): JSX.Element {
return <div>
{ _t(
"Without setting up Secure Message Recovery, you won't be able to restore your " +
"encrypted message history if you log out or use another session.",
) }
<DialogButtons primaryButton={_t('Set up Secure Message Recovery')}
onPrimaryButtonClick={this.onSetUpClick}
hasCancel={false}
>
<button onClick={this.onCancel}>I understand, continue without</button>
</DialogButtons>
</div>;
return (
<div>
<p>{_t("settings|key_backup|backup_in_progress")}</p>
<DialogButtons primaryButton={_t("action|ok")} onPrimaryButtonClick={this.onDone} hasCancel={false} />
</div>
);
}
private titleForPhase(phase: Phase): string {
switch (phase) {
case Phase.Passphrase:
return _t('Secure your backup with a Security Phrase');
case Phase.PassphraseConfirm:
return _t('Confirm your Security Phrase');
case Phase.OptOutConfirm:
return _t('Warning!');
case Phase.ShowKey:
case Phase.KeepItSafe:
return _t('Make a copy of your Security Key');
case Phase.BackingUp:
return _t('Starting backup...');
return _t("settings|key_backup|backup_starting");
case Phase.Done:
return _t('Success!');
return _t("settings|key_backup|backup_success");
default:
return _t("Create key backup");
return _t("settings|key_backup|create_title");
}
}
public render(): JSX.Element {
public render(): React.ReactNode {
let content;
if (this.state.error) {
content = <div>
<p>{ _t("Unable to create key backup") }</p>
<DialogButtons
primaryButton={_t('Retry')}
onPrimaryButtonClick={this.createBackup}
hasCancel={true}
onCancel={this.onCancel}
/>
</div>;
content = (
<div>
<p>{_t("settings|key_backup|cannot_create_backup")}</p>
<DialogButtons
primaryButton={_t("action|retry")}
onPrimaryButtonClick={this.createBackup}
hasCancel={true}
onCancel={this.onCancel}
/>
</div>
);
} else {
switch (this.state.phase) {
case Phase.Passphrase:
content = this.renderPhasePassPhrase();
break;
case Phase.PassphraseConfirm:
content = this.renderPhasePassPhraseConfirm();
break;
case Phase.ShowKey:
content = this.renderPhaseShowKey();
break;
case Phase.KeepItSafe:
content = this.renderPhaseKeepItSafe();
break;
case Phase.BackingUp:
content = this.renderBusyPhase();
break;
case Phase.Done:
content = this.renderPhaseDone();
break;
case Phase.OptOutConfirm:
content = this.renderPhaseOptOutConfirm();
break;
}
}
return (
<BaseDialog className='mx_CreateKeyBackupDialog'
<BaseDialog
className="mx_CreateKeyBackupDialog"
onFinished={this.props.onFinished}
title={this.titleForPhase(this.state.phase)}
hasCancel={[Phase.Passphrase, Phase.Done].includes(this.state.phase)}
hasCancel={[Phase.Done].includes(this.state.phase)}
>
<div>
{ content }
</div>
<div>{content}</div>
</BaseDialog>
);
}

View file

@ -15,30 +15,32 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import FileSaver from 'file-saver';
import React from 'react';
import { MatrixClient } from 'matrix-js-sdk/src/client';
import FileSaver from "file-saver";
import React, { ChangeEvent } from "react";
import { MatrixClient } from "matrix-js-sdk/src/matrix";
import { logger } from "matrix-js-sdk/src/logger";
import { _t } from '../../../../languageHandler';
import * as MegolmExportEncryption from '../../../../utils/MegolmExportEncryption';
import { IDialogProps } from "../../../../components/views/dialogs/IDialogProps";
import { _t, _td } from "../../../../languageHandler";
import * as MegolmExportEncryption from "../../../../utils/MegolmExportEncryption";
import BaseDialog from "../../../../components/views/dialogs/BaseDialog";
import Field from "../../../../components/views/elements/Field";
import { KeysStartingWith } from "../../../../@types/common";
import PassphraseField from "../../../../components/views/auth/PassphraseField";
import PassphraseConfirmField from "../../../../components/views/auth/PassphraseConfirmField";
import Field from "../../../../components/views/elements/Field";
enum Phase {
Edit = "edit",
Exporting = "exporting",
}
interface IProps extends IDialogProps {
interface IProps {
matrixClient: MatrixClient;
onFinished(doExport?: boolean): void;
}
interface IState {
phase: Phase;
errStr: string;
errStr: string | null;
passphrase1: string;
passphrase2: string;
}
@ -46,9 +48,12 @@ interface IState {
type AnyPassphrase = KeysStartingWith<IState, "passphrase">;
export default class ExportE2eKeysDialog extends React.Component<IProps, IState> {
private fieldPassword: Field | null = null;
private fieldPasswordConfirm: Field | null = null;
private unmounted = false;
constructor(props: IProps) {
public constructor(props: IProps) {
super(props);
this.state = {
@ -63,49 +68,70 @@ export default class ExportE2eKeysDialog extends React.Component<IProps, IState>
this.unmounted = true;
}
private onPassphraseFormSubmit = (ev: React.FormEvent): boolean => {
private async verifyFieldsBeforeSubmit(): Promise<boolean> {
const fieldsInDisplayOrder = [this.fieldPassword, this.fieldPasswordConfirm];
const invalidFields: Field[] = [];
for (const field of fieldsInDisplayOrder) {
if (!field) continue;
const valid = await field.validate({ allowEmpty: false });
if (!valid) {
invalidFields.push(field);
}
}
if (invalidFields.length === 0) {
return true;
}
// Focus on the first invalid field, then re-validate,
// which will result in the error tooltip being displayed for that field.
invalidFields[0].focus();
invalidFields[0].validate({ allowEmpty: false, focused: true });
return false;
}
private onPassphraseFormSubmit = async (ev: React.FormEvent): Promise<void> => {
ev.preventDefault();
const passphrase = this.state.passphrase1;
if (passphrase !== this.state.passphrase2) {
this.setState({ errStr: _t('Passphrases must match') });
return false;
}
if (!passphrase) {
this.setState({ errStr: _t('Passphrase must not be empty') });
return false;
}
if (!(await this.verifyFieldsBeforeSubmit())) return;
if (this.unmounted) return;
const passphrase = this.state.passphrase1;
this.startExport(passphrase);
return false;
};
private startExport(passphrase: string): void {
// extra Promise.resolve() to turn synchronous exceptions into
// asynchronous ones.
Promise.resolve().then(() => {
return this.props.matrixClient.exportRoomKeys();
}).then((k) => {
return MegolmExportEncryption.encryptMegolmKeyFile(
JSON.stringify(k), passphrase,
);
}).then((f) => {
const blob = new Blob([f], {
type: 'text/plain;charset=us-ascii',
Promise.resolve()
.then(() => {
return this.props.matrixClient.getCrypto()!.exportRoomKeysAsJson();
})
.then((k) => {
return MegolmExportEncryption.encryptMegolmKeyFile(k, passphrase);
})
.then((f) => {
const blob = new Blob([f], {
type: "text/plain;charset=us-ascii",
});
FileSaver.saveAs(blob, "element-keys.txt");
this.props.onFinished(true);
})
.catch((e) => {
logger.error("Error exporting e2e keys:", e);
if (this.unmounted) {
return;
}
const msg = e.friendlyText || _t("error|unknown");
this.setState({
errStr: msg,
phase: Phase.Edit,
});
});
FileSaver.saveAs(blob, 'element-keys.txt');
this.props.onFinished(true);
}).catch((e) => {
logger.error("Error exporting e2e keys:", e);
if (this.unmounted) {
return;
}
const msg = e.friendlyText || _t('Unknown error');
this.setState({
errStr: msg,
phase: Phase.Edit,
});
});
this.setState({
errStr: null,
@ -119,77 +145,74 @@ export default class ExportE2eKeysDialog extends React.Component<IProps, IState>
return false;
};
private onPassphraseChange = (ev: React.ChangeEvent<HTMLInputElement>, phrase: AnyPassphrase) => {
private onPassphraseChange = (ev: React.ChangeEvent<HTMLInputElement>, phrase: AnyPassphrase): void => {
this.setState({
[phrase]: ev.target.value,
} as Pick<IState, AnyPassphrase>);
};
public render(): JSX.Element {
const disableForm = (this.state.phase === Phase.Exporting);
public render(): React.ReactNode {
const disableForm = this.state.phase === Phase.Exporting;
return (
<BaseDialog className='mx_exportE2eKeysDialog'
<BaseDialog
className="mx_exportE2eKeysDialog"
onFinished={this.props.onFinished}
title={_t("Export room keys")}
title={_t("settings|key_export_import|export_title")}
>
<form onSubmit={this.onPassphraseFormSubmit}>
<div className="mx_Dialog_content">
<p>
{ _t(
'This process allows you to export the keys for messages ' +
'you have received in encrypted rooms to a local file. You ' +
'will then be able to import the file into another Matrix ' +
'client in the future, so that client will also be able to ' +
'decrypt these messages.',
) }
</p>
<p>
{ _t(
'The exported file will allow anyone who can read it to decrypt ' +
'any encrypted messages that you can see, so you should be ' +
'careful to keep it secure. To help with this, you should enter ' +
'a passphrase below, which will be used to encrypt the exported ' +
'data. It will only be possible to import the data by using the ' +
'same passphrase.',
) }
</p>
<div className='error'>
{ this.state.errStr }
</div>
<div className='mx_E2eKeysDialog_inputTable'>
<div className='mx_E2eKeysDialog_inputRow'>
<Field
label={_t("Enter passphrase")}
<p>{_t("settings|key_export_import|export_description_1")}</p>
<p>{_t("settings|key_export_import|export_description_2")}</p>
<div className="error">{this.state.errStr}</div>
<div className="mx_E2eKeysDialog_inputTable">
<div className="mx_E2eKeysDialog_inputRow">
<PassphraseField
minScore={3}
label={_td("settings|key_export_import|enter_passphrase")}
labelEnterPassword={_td("settings|key_export_import|enter_passphrase")}
labelStrongPassword={_td("settings|key_export_import|phrase_strong_enough")}
labelAllowedButUnsafe={_td("settings|key_export_import|phrase_strong_enough")}
value={this.state.passphrase1}
onChange={e => this.onPassphraseChange(e, "passphrase1")}
onChange={(e: ChangeEvent<HTMLInputElement>) =>
this.onPassphraseChange(e, "passphrase1")
}
autoFocus={true}
size={64}
type="password"
disabled={disableForm}
autoComplete="new-password"
fieldRef={(field) => (this.fieldPassword = field)}
/>
</div>
<div className='mx_E2eKeysDialog_inputRow'>
<Field
label={_t("Confirm passphrase")}
<div className="mx_E2eKeysDialog_inputRow">
<PassphraseConfirmField
password={this.state.passphrase1}
label={_td("settings|key_export_import|confirm_passphrase")}
labelRequired={_td("settings|key_export_import|phrase_cannot_be_empty")}
labelInvalid={_td("settings|key_export_import|phrase_must_match")}
value={this.state.passphrase2}
onChange={e => this.onPassphraseChange(e, "passphrase2")}
onChange={(e: ChangeEvent<HTMLInputElement>) =>
this.onPassphraseChange(e, "passphrase2")
}
size={64}
type="password"
disabled={disableForm}
autoComplete="new-password"
fieldRef={(field) => (this.fieldPasswordConfirm = field)}
/>
</div>
</div>
</div>
<div className='mx_Dialog_buttons'>
<div className="mx_Dialog_buttons">
<input
className='mx_Dialog_primary'
type='submit'
value={_t('Export')}
className="mx_Dialog_primary"
type="submit"
value={_t("action|export")}
disabled={disableForm}
/>
<button onClick={this.onCancelClick} disabled={disableForm}>
{ _t("Cancel") }
{_t("action|cancel")}
</button>
</div>
</form>

View file

@ -15,13 +15,12 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import React, { createRef } from 'react';
import { MatrixClient } from 'matrix-js-sdk/src/client';
import React, { createRef } from "react";
import { MatrixClient } from "matrix-js-sdk/src/matrix";
import { logger } from "matrix-js-sdk/src/logger";
import * as MegolmExportEncryption from '../../../../utils/MegolmExportEncryption';
import { _t } from '../../../../languageHandler';
import { IDialogProps } from "../../../../components/views/dialogs/IDialogProps";
import * as MegolmExportEncryption from "../../../../utils/MegolmExportEncryption";
import { _t } from "../../../../languageHandler";
import BaseDialog from "../../../../components/views/dialogs/BaseDialog";
import Field from "../../../../components/views/elements/Field";
@ -29,7 +28,11 @@ function readFileAsArrayBuffer(file: File): Promise<ArrayBuffer> {
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onload = (e) => {
resolve(e.target.result as ArrayBuffer);
if (e.target?.result) {
resolve(e.target.result as ArrayBuffer);
} else {
reject(new Error("Failed to read file due to unknown error"));
}
};
reader.onerror = reject;
@ -42,14 +45,15 @@ enum Phase {
Importing = "importing",
}
interface IProps extends IDialogProps {
interface IProps {
matrixClient: MatrixClient;
onFinished(imported?: boolean): void;
}
interface IState {
enableSubmit: boolean;
phase: Phase;
errStr: string;
errStr: string | null;
passphrase: string;
}
@ -57,7 +61,7 @@ export default class ImportE2eKeysDialog extends React.Component<IProps, IState>
private unmounted = false;
private file = createRef<HTMLInputElement>();
constructor(props: IProps) {
public constructor(props: IProps) {
super(props);
this.state = {
@ -73,50 +77,54 @@ export default class ImportE2eKeysDialog extends React.Component<IProps, IState>
}
private onFormChange = (): void => {
const files = this.file.current.files || [];
const files = this.file.current?.files;
this.setState({
enableSubmit: (this.state.passphrase !== "" && files.length > 0),
enableSubmit: this.state.passphrase !== "" && !!files?.length,
});
};
private onPassphraseChange = (ev: React.ChangeEvent<HTMLInputElement>): void => {
this.setState({ passphrase: ev.target.value });
this.onFormChange(); // update general form state too
this.setState({ passphrase: ev.target.value }, this.onFormChange); // update general form state too
};
private onFormSubmit = (ev: React.FormEvent): boolean => {
ev.preventDefault();
// noinspection JSIgnoredPromiseFromCall
this.startImport(this.file.current.files[0], this.state.passphrase);
const file = this.file.current?.files?.[0];
if (file) {
this.startImport(file, this.state.passphrase);
}
return false;
};
private startImport(file: File, passphrase: string) {
private startImport(file: File, passphrase: string): Promise<void> {
this.setState({
errStr: null,
phase: Phase.Importing,
});
return readFileAsArrayBuffer(file).then((arrayBuffer) => {
return MegolmExportEncryption.decryptMegolmKeyFile(
arrayBuffer, passphrase,
);
}).then((keys) => {
return this.props.matrixClient.importRoomKeys(JSON.parse(keys));
}).then(() => {
// TODO: it would probably be nice to give some feedback about what we've imported here.
this.props.onFinished(true);
}).catch((e) => {
logger.error("Error importing e2e keys:", e);
if (this.unmounted) {
return;
}
const msg = e.friendlyText || _t('Unknown error');
this.setState({
errStr: msg,
phase: Phase.Edit,
return readFileAsArrayBuffer(file)
.then((arrayBuffer) => {
return MegolmExportEncryption.decryptMegolmKeyFile(arrayBuffer, passphrase);
})
.then((keys) => {
return this.props.matrixClient.getCrypto()!.importRoomKeysAsJson(keys);
})
.then(() => {
// TODO: it would probably be nice to give some feedback about what we've imported here.
this.props.onFinished(true);
})
.catch((e) => {
logger.error("Error importing e2e keys:", e);
if (this.unmounted) {
return;
}
const msg = e.friendlyText || _t("error|unknown");
this.setState({
errStr: msg,
phase: Phase.Edit,
});
});
});
}
private onCancelClick = (ev: React.MouseEvent): boolean => {
@ -125,53 +133,41 @@ export default class ImportE2eKeysDialog extends React.Component<IProps, IState>
return false;
};
public render(): JSX.Element {
const disableForm = (this.state.phase !== Phase.Edit);
public render(): React.ReactNode {
const disableForm = this.state.phase !== Phase.Edit;
return (
<BaseDialog className='mx_importE2eKeysDialog'
<BaseDialog
className="mx_importE2eKeysDialog"
onFinished={this.props.onFinished}
title={_t("Import room keys")}
title={_t("settings|key_export_import|import_title")}
>
<form onSubmit={this.onFormSubmit}>
<div className="mx_Dialog_content">
<p>
{ _t(
'This process allows you to import encryption keys ' +
'that you had previously exported from another Matrix ' +
'client. You will then be able to decrypt any ' +
'messages that the other client could decrypt.',
) }
</p>
<p>
{ _t(
'The export file will be protected with a passphrase. ' +
'You should enter the passphrase here, to decrypt the file.',
) }
</p>
<div className='error'>
{ this.state.errStr }
</div>
<div className='mx_E2eKeysDialog_inputTable'>
<div className='mx_E2eKeysDialog_inputRow'>
<div className='mx_E2eKeysDialog_inputLabel'>
<label htmlFor='importFile'>
{ _t("File to import") }
<p>{_t("settings|key_export_import|import_description_1")}</p>
<p>{_t("settings|key_export_import|import_description_2")}</p>
<div className="error">{this.state.errStr}</div>
<div className="mx_E2eKeysDialog_inputTable">
<div className="mx_E2eKeysDialog_inputRow">
<div className="mx_E2eKeysDialog_inputLabel">
<label htmlFor="importFile">
{_t("settings|key_export_import|file_to_import")}
</label>
</div>
<div className='mx_E2eKeysDialog_inputCell'>
<div className="mx_E2eKeysDialog_inputCell">
<input
ref={this.file}
id='importFile'
type='file'
id="importFile"
type="file"
autoFocus={true}
onChange={this.onFormChange}
disabled={disableForm} />
disabled={disableForm}
/>
</div>
</div>
<div className='mx_E2eKeysDialog_inputRow'>
<div className="mx_E2eKeysDialog_inputRow">
<Field
label={_t("Enter passphrase")}
label={_t("settings|key_export_import|enter_passphrase")}
value={this.state.passphrase}
onChange={this.onPassphraseChange}
size={64}
@ -181,15 +177,15 @@ export default class ImportE2eKeysDialog extends React.Component<IProps, IState>
</div>
</div>
</div>
<div className='mx_Dialog_buttons'>
<div className="mx_Dialog_buttons">
<input
className='mx_Dialog_primary'
type='submit'
value={_t('Import')}
className="mx_Dialog_primary"
type="submit"
value={_t("action|import")}
disabled={!this.state.enableSubmit || disableForm}
/>
<button onClick={this.onCancelClick} disabled={disableForm}>
{ _t("Cancel") }
{_t("action|cancel")}
</button>
</div>
</form>

View file

@ -18,18 +18,18 @@ limitations under the License.
import React from "react";
import { IKeyBackupInfo } from "matrix-js-sdk/src/crypto/keybackup";
import { MatrixClientPeg } from '../../../../MatrixClientPeg';
import { MatrixClientPeg } from "../../../../MatrixClientPeg";
import dis from "../../../../dispatcher/dispatcher";
import { _t } from "../../../../languageHandler";
import Modal from "../../../../Modal";
import RestoreKeyBackupDialog from "../../../../components/views/dialogs/security/RestoreKeyBackupDialog";
import { Action } from "../../../../dispatcher/actions";
import { IDialogProps } from "../../../../components/views/dialogs/IDialogProps";
import DialogButtons from "../../../../components/views/elements/DialogButtons";
import BaseDialog from "../../../../components/views/dialogs/BaseDialog";
interface IProps extends IDialogProps {
interface IProps {
newVersionInfo: IKeyBackupInfo;
onFinished(): void;
}
export default class NewRecoveryMethodDialog extends React.PureComponent<IProps> {
@ -43,61 +43,63 @@ export default class NewRecoveryMethodDialog extends React.PureComponent<IProps>
};
private onSetupClick = async (): Promise<void> => {
Modal.createDialog(RestoreKeyBackupDialog, {
onFinished: this.props.onFinished,
}, null, /* priority = */ false, /* static = */ true);
Modal.createDialog(
RestoreKeyBackupDialog,
{
onFinished: this.props.onFinished,
},
undefined,
/* priority = */ false,
/* static = */ true,
);
};
public render(): JSX.Element {
const title = <span className="mx_KeyBackupFailedDialog_title">
{ _t("New Recovery Method") }
</span>;
public render(): React.ReactNode {
const title = (
<span className="mx_KeyBackupFailedDialog_title">
{_t("encryption|new_recovery_method_detected|title")}
</span>
);
const newMethodDetected = <p>{ _t(
"A new Security Phrase and key for Secure Messages have been detected.",
) }</p>;
const newMethodDetected = <p>{_t("encryption|new_recovery_method_detected|description_1")}</p>;
const hackWarning = <p className="warning">{ _t(
"If you didn't set the new recovery method, an " +
"attacker may be trying to access your account. " +
"Change your account password and set a new recovery " +
"method immediately in Settings.",
) }</p>;
const hackWarning = (
<strong className="warning">{_t("encryption|new_recovery_method_detected|warning")}</strong>
);
let content;
if (MatrixClientPeg.get().getKeyBackupEnabled()) {
content = <div>
{ newMethodDetected }
<p>{ _t(
"This session is encrypting history using the new recovery method.",
) }</p>
{ hackWarning }
<DialogButtons
primaryButton={_t("OK")}
onPrimaryButtonClick={this.onOkClick}
cancelButton={_t("Go to Settings")}
onCancel={this.onGoToSettingsClick}
/>
</div>;
let content: JSX.Element | undefined;
if (MatrixClientPeg.safeGet().getKeyBackupEnabled()) {
content = (
<div>
{newMethodDetected}
<p>{_t("encryption|new_recovery_method_detected|description_2")}</p>
{hackWarning}
<DialogButtons
primaryButton={_t("action|ok")}
onPrimaryButtonClick={this.onOkClick}
cancelButton={_t("common|go_to_settings")}
onCancel={this.onGoToSettingsClick}
/>
</div>
);
} else {
content = <div>
{ newMethodDetected }
{ hackWarning }
<DialogButtons
primaryButton={_t("Set up Secure Messages")}
onPrimaryButtonClick={this.onSetupClick}
cancelButton={_t("Go to Settings")}
onCancel={this.onGoToSettingsClick}
/>
</div>;
content = (
<div>
{newMethodDetected}
{hackWarning}
<DialogButtons
primaryButton={_t("common|setup_secure_messages")}
onPrimaryButtonClick={this.onSetupClick}
cancelButton={_t("common|go_to_settings")}
onCancel={this.onGoToSettingsClick}
/>
</div>
);
}
return (
<BaseDialog className="mx_KeyBackupFailedDialog"
onFinished={this.props.onFinished}
title={title}
>
{ content }
<BaseDialog className="mx_KeyBackupFailedDialog" onFinished={this.props.onFinished} title={title}>
{content}
</BaseDialog>
);
}

View file

@ -15,17 +15,18 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import React, { ComponentType } from "react";
import React from "react";
import dis from "../../../../dispatcher/dispatcher";
import { _t } from "../../../../languageHandler";
import Modal from "../../../../Modal";
import Modal, { ComponentType } from "../../../../Modal";
import { Action } from "../../../../dispatcher/actions";
import { IDialogProps } from "../../../../components/views/dialogs/IDialogProps";
import BaseDialog from "../../../../components/views/dialogs/BaseDialog";
import DialogButtons from "../../../../components/views/elements/DialogButtons";
interface IProps extends IDialogProps {}
interface IProps {
onFinished(): void;
}
export default class RecoveryMethodRemovedDialog extends React.PureComponent<IProps> {
private onGoToSettingsClick = (): void => {
@ -36,41 +37,29 @@ export default class RecoveryMethodRemovedDialog extends React.PureComponent<IPr
private onSetupClick = (): void => {
this.props.onFinished();
Modal.createDialogAsync(
import("./CreateKeyBackupDialog") as unknown as Promise<ComponentType<{}>>,
null, null, /* priority = */ false, /* static = */ true,
import("./CreateKeyBackupDialog") as unknown as Promise<ComponentType>,
undefined,
undefined,
/* priority = */ false,
/* static = */ true,
);
};
public render(): JSX.Element {
const title = <span className="mx_KeyBackupFailedDialog_title">
{ _t("Recovery Method Removed") }
</span>;
public render(): React.ReactNode {
const title = (
<span className="mx_KeyBackupFailedDialog_title">{_t("encryption|recovery_method_removed|title")}</span>
);
return (
<BaseDialog className="mx_KeyBackupFailedDialog"
onFinished={this.props.onFinished}
title={title}
>
<BaseDialog className="mx_KeyBackupFailedDialog" onFinished={this.props.onFinished} title={title}>
<div>
<p>{ _t(
"This session has detected that your Security Phrase and key " +
"for Secure Messages have been removed.",
) }</p>
<p>{ _t(
"If you did this accidentally, you can setup Secure Messages on " +
"this session which will re-encrypt this session's message " +
"history with a new recovery method.",
) }</p>
<p className="warning">{ _t(
"If you didn't remove the recovery method, an " +
"attacker may be trying to access your account. " +
"Change your account password and set a new recovery " +
"method immediately in Settings.",
) }</p>
<p>{_t("encryption|recovery_method_removed|description_1")}</p>
<p>{_t("encryption|recovery_method_removed|description_2")}</p>
<strong className="warning">{_t("encryption|recovery_method_removed|warning")}</strong>
<DialogButtons
primaryButton={_t("Set up Secure Messages")}
primaryButton={_t("common|setup_secure_messages")}
onPrimaryButtonClick={this.onSetupClick}
cancelButton={_t("Go to Settings")}
cancelButton={_t("common|go_to_settings")}
onCancel={this.onGoToSettingsClick}
/>
</div>

View file

@ -14,14 +14,19 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import { DEFAULT_WAVEFORM, Playback } from "./Playback";
import { Playback } from "./Playback";
import { PlaybackManager } from "./PlaybackManager";
import { DEFAULT_WAVEFORM } from "./consts";
/**
* A managed playback is a Playback instance that is guided by a PlaybackManager.
*/
export class ManagedPlayback extends Playback {
public constructor(private manager: PlaybackManager, buf: ArrayBuffer, seedWaveform = DEFAULT_WAVEFORM) {
public constructor(
private manager: PlaybackManager,
buf: ArrayBuffer,
seedWaveform = DEFAULT_WAVEFORM,
) {
super(buf, seedWaveform);
}
@ -30,7 +35,7 @@ export class ManagedPlayback extends Playback {
return super.play();
}
public destroy() {
public destroy(): void {
this.manager.destroyPlaybackInstance(this);
super.destroy();
}

View file

@ -17,13 +17,19 @@ limitations under the License.
import EventEmitter from "events";
import { SimpleObservable } from "matrix-widget-api";
import { logger } from "matrix-js-sdk/src/logger";
import { defer } from "matrix-js-sdk/src/utils";
// @ts-ignore - `.ts` is needed here to make TS happy
import { Request, Response } from "../workers/playback.worker.ts";
import { UPDATE_EVENT } from "../stores/AsyncStore";
import { arrayFastResample, arrayRescale, arraySeed, arraySmoothingResample } from "../utils/arrays";
import { arrayFastResample } from "../utils/arrays";
import { IDestroyable } from "../utils/IDestroyable";
import { PlaybackClock } from "./PlaybackClock";
import { createAudioContext, decodeOgg } from "./compat";
import { clamp } from "../utils/numbers";
import { WorkerManager } from "../WorkerManager";
import { DEFAULT_WAVEFORM, PLAYBACK_WAVEFORM_SAMPLES } from "./consts";
import playbackWorkerFactory from "../workers/playbackWorkerFactory";
export enum PlaybackState {
Decoding = "decoding",
@ -32,20 +38,17 @@ export enum PlaybackState {
Playing = "playing", // active progress through timeline
}
export const PLAYBACK_WAVEFORM_SAMPLES = 39;
const THUMBNAIL_WAVEFORM_SAMPLES = 100; // arbitrary: [30,120]
export const DEFAULT_WAVEFORM = arraySeed(0, PLAYBACK_WAVEFORM_SAMPLES);
function makePlaybackWaveform(input: number[]): number[] {
// First, convert negative amplitudes to positive so we don't detect zero as "noisy".
const noiseWaveform = input.map(v => Math.abs(v));
// Then, we'll resample the waveform using a smoothing approach so we can keep the same rough shape.
// We also rescale the waveform to be 0-1 so we end up with a clamped waveform to rely upon.
return arrayRescale(arraySmoothingResample(noiseWaveform, PLAYBACK_WAVEFORM_SAMPLES), 0, 1);
export interface PlaybackInterface {
readonly currentState: PlaybackState;
readonly liveData: SimpleObservable<number[]>;
readonly timeSeconds: number;
readonly durationSeconds: number;
skipTo(timeSeconds: number): Promise<void>;
}
export class Playback extends EventEmitter implements IDestroyable {
export class Playback extends EventEmitter implements IDestroyable, PlaybackInterface {
/**
* Stable waveform for representing a thumbnail of the media. Values are
* guaranteed to be between zero and one, inclusive.
@ -53,14 +56,15 @@ export class Playback extends EventEmitter implements IDestroyable {
public readonly thumbnailWaveform: number[];
private readonly context: AudioContext;
private source: AudioBufferSourceNode | MediaElementAudioSourceNode;
private source?: AudioBufferSourceNode | MediaElementAudioSourceNode;
private state = PlaybackState.Decoding;
private audioBuf: AudioBuffer;
private element: HTMLAudioElement;
private audioBuf?: AudioBuffer;
private element?: HTMLAudioElement;
private resampledWaveform: number[];
private waveformObservable = new SimpleObservable<number[]>();
private readonly clock: PlaybackClock;
private readonly fileSize: number;
private readonly worker = new WorkerManager<Request, Response>(playbackWorkerFactory());
/**
* Creates a new playback instance from a buffer.
@ -68,7 +72,10 @@ export class Playback extends EventEmitter implements IDestroyable {
* @param {number[]} seedWaveform Optional seed waveform to present until the proper waveform
* can be calculated. Contains values between zero and one, inclusive.
*/
constructor(private buf: ArrayBuffer, seedWaveform = DEFAULT_WAVEFORM) {
public constructor(
private buf: ArrayBuffer,
seedWaveform = DEFAULT_WAVEFORM,
) {
super();
// Capture the file size early as reading the buffer will result in a 0-length buffer left behind
this.fileSize = this.buf.byteLength;
@ -103,6 +110,18 @@ export class Playback extends EventEmitter implements IDestroyable {
return this.clock;
}
public get liveData(): SimpleObservable<number[]> {
return this.clock.liveData;
}
public get timeSeconds(): number {
return this.clock.timeSeconds;
}
public get durationSeconds(): number {
return this.clock.durationSeconds;
}
public get currentState(): PlaybackState {
return this.state;
}
@ -118,7 +137,7 @@ export class Playback extends EventEmitter implements IDestroyable {
return true; // we don't ever care if the event had listeners, so just return "yes"
}
public destroy() {
public destroy(): void {
// Dev note: It's critical that we call stop() during cleanup to ensure that downstream callers
// are aware of the final clock position before the user triggered an unload.
// noinspection JSIgnoredPromiseFromCall - not concerned about being called async here
@ -132,7 +151,7 @@ export class Playback extends EventEmitter implements IDestroyable {
}
}
public async prepare() {
public async prepare(): Promise<void> {
// don't attempt to decode the media again
// AudioContext.decodeAudioData detaches the array buffer `this.buf`
// meaning it cannot be re-read
@ -147,61 +166,72 @@ export class Playback extends EventEmitter implements IDestroyable {
// Overall, the point of this is to avoid memory-related issues due to storing a massive
// audio buffer in memory, as that can balloon to far greater than the input buffer's
// byte length.
if (this.buf.byteLength > 5 * 1024 * 1024) { // 5mb
if (this.buf.byteLength > 5 * 1024 * 1024) {
// 5mb
logger.log("Audio file too large: processing through <audio /> element");
this.element = document.createElement("AUDIO") as HTMLAudioElement;
const prom = new Promise((resolve, reject) => {
this.element.onloadeddata = () => resolve(null);
this.element.onerror = (e) => reject(e);
});
const deferred = defer<unknown>();
this.element.onloadeddata = deferred.resolve;
this.element.onerror = deferred.reject;
this.element.src = URL.createObjectURL(new Blob([this.buf]));
await prom; // make sure the audio element is ready for us
await deferred.promise; // make sure the audio element is ready for us
} else {
// Safari compat: promise API not supported on this function
this.audioBuf = await new Promise((resolve, reject) => {
this.context.decodeAudioData(this.buf, b => resolve(b), async e => {
try {
// This error handler is largely for Safari as well, which doesn't support Opus/Ogg
// very well.
logger.error("Error decoding recording: ", e);
logger.warn("Trying to re-encode to WAV instead...");
this.context.decodeAudioData(
this.buf,
(b) => resolve(b),
async (e): Promise<void> => {
try {
// This error handler is largely for Safari as well, which doesn't support Opus/Ogg
// very well.
logger.error("Error decoding recording: ", e);
logger.warn("Trying to re-encode to WAV instead...");
const wav = await decodeOgg(this.buf);
const wav = await decodeOgg(this.buf);
// noinspection ES6MissingAwait - not needed when using callbacks
this.context.decodeAudioData(wav, b => resolve(b), e => {
logger.error("Still failed to decode recording: ", e);
// noinspection ES6MissingAwait - not needed when using callbacks
this.context.decodeAudioData(
wav,
(b) => resolve(b),
(e) => {
logger.error("Still failed to decode recording: ", e);
reject(e);
},
);
} catch (e) {
logger.error("Caught decoding error:", e);
reject(e);
});
} catch (e) {
logger.error("Caught decoding error:", e);
reject(e);
}
});
}
},
);
});
// Update the waveform to the real waveform once we have channel data to use. We don't
// exactly trust the user-provided waveform to be accurate...
const waveform = Array.from(this.audioBuf.getChannelData(0));
this.resampledWaveform = makePlaybackWaveform(waveform);
this.resampledWaveform = await this.makePlaybackWaveform(this.audioBuf.getChannelData(0));
}
this.waveformObservable.update(this.resampledWaveform);
this.clock.flagLoadTime(); // must happen first because setting the duration fires a clock update
this.clock.durationSeconds = this.element ? this.element.duration : this.audioBuf.duration;
this.clock.durationSeconds = this.element?.duration ?? this.audioBuf!.duration;
// Signal that we're not decoding anymore. This is done last to ensure the clock is updated for
// when the downstream callers try to use it.
this.emit(PlaybackState.Stopped); // signal that we're not decoding anymore
}
private onPlaybackEnd = async () => {
private makePlaybackWaveform(input: Float32Array): Promise<number[]> {
return this.worker.call({ data: Array.from(input) }).then((resp) => resp.waveform);
}
private onPlaybackEnd = async (): Promise<void> => {
await this.context.suspend();
this.emit(PlaybackState.Stopped);
};
public async play() {
public async play(): Promise<void> {
// We can't restart a buffer source, so we need to create a new one if we hit the end
if (this.state === PlaybackState.Stopped) {
this.disconnectSource();
@ -220,42 +250,42 @@ export class Playback extends EventEmitter implements IDestroyable {
this.emit(PlaybackState.Playing);
}
private disconnectSource() {
private disconnectSource(): void {
if (this.element) return; // leave connected, we can (and must) re-use it
this.source?.disconnect();
this.source?.removeEventListener("ended", this.onPlaybackEnd);
}
private makeNewSourceBuffer() {
private makeNewSourceBuffer(): void {
if (this.element && this.source) return; // leave connected, we can (and must) re-use it
if (this.element) {
this.source = this.context.createMediaElementSource(this.element);
} else {
this.source = this.context.createBufferSource();
this.source.buffer = this.audioBuf;
this.source.buffer = this.audioBuf ?? null;
}
this.source.addEventListener("ended", this.onPlaybackEnd);
this.source.connect(this.context.destination);
}
public async pause() {
public async pause(): Promise<void> {
await this.context.suspend();
this.emit(PlaybackState.Paused);
}
public async stop() {
public async stop(): Promise<void> {
await this.onPlaybackEnd();
this.clock.flagStop();
}
public async toggle() {
public async toggle(): Promise<void> {
if (this.isPlaying) await this.pause();
else await this.play();
}
public async skipTo(timeSeconds: number) {
public async skipTo(timeSeconds: number): Promise<void> {
// Dev note: this function talks a lot about clock desyncs. There is a clock running
// independently to the audio context and buffer so that accurate human-perceptible
// time can be exposed. The PlaybackClock class has more information, but the short

View file

@ -15,7 +15,7 @@ limitations under the License.
*/
import { SimpleObservable } from "matrix-widget-api";
import { MatrixEvent } from "matrix-js-sdk/src/models/event";
import { MatrixEvent } from "matrix-js-sdk/src/matrix";
import { IDestroyable } from "../utils/IDestroyable";
@ -60,12 +60,11 @@ export class PlaybackClock implements IDestroyable {
private stopped = true;
private lastCheck = 0;
private observable = new SimpleObservable<number[]>();
private timerId: number;
private timerId?: number;
private clipDuration = 0;
private placeholderDuration = 0;
public constructor(private context: AudioContext) {
}
public constructor(private context: AudioContext) {}
public get durationSeconds(): number {
return this.clipDuration || this.placeholderDuration;
@ -90,7 +89,7 @@ export class PlaybackClock implements IDestroyable {
return this.observable;
}
private checkTime = (force = false) => {
private checkTime = (force = false): void => {
const now = this.timeSeconds; // calculated dynamically
if (this.lastCheck !== now || force) {
this.observable.update([now, this.durationSeconds]);
@ -103,8 +102,8 @@ export class PlaybackClock implements IDestroyable {
* The placeholders will be overridden once known.
* @param {MatrixEvent} event The event to use for placeholders.
*/
public populatePlaceholdersFrom(event: MatrixEvent) {
const durationMs = Number(event.getContent()['info']?.['duration']);
public populatePlaceholdersFrom(event: MatrixEvent): void {
const durationMs = Number(event.getContent()["info"]?.["duration"]);
if (Number.isFinite(durationMs)) this.placeholderDuration = durationMs / 1000;
}
@ -113,25 +112,23 @@ export class PlaybackClock implements IDestroyable {
* This is to ensure the clock isn't skewed into thinking it is ~0.5s into
* a clip when the duration is set.
*/
public flagLoadTime() {
public flagLoadTime(): void {
this.clipStart = this.context.currentTime;
}
public flagStart() {
public flagStart(): void {
if (this.stopped) {
this.clipStart = this.context.currentTime;
this.stopped = false;
}
if (!this.timerId) {
// cast to number because the types are wrong
// 100ms interval to make sure the time is as accurate as possible without
// being overly insane
this.timerId = <number><any>setInterval(this.checkTime, 100);
// 100ms interval to make sure the time is as accurate as possible without being overly insane
this.timerId = window.setInterval(this.checkTime, 100);
}
}
public flagStop() {
public flagStop(): void {
this.stopped = true;
// Reset the clock time now so that the update going out will trigger components
@ -139,13 +136,13 @@ export class PlaybackClock implements IDestroyable {
this.clipStart = this.context.currentTime;
}
public syncTo(contextTime: number, clipTime: number) {
public syncTo(contextTime: number, clipTime: number): void {
this.clipStart = contextTime - clipTime;
this.stopped = false; // count as a mid-stream pause (if we were stopped)
this.checkTime(true);
}
public destroy() {
public destroy(): void {
this.observable.close();
if (this.timerId) clearInterval(this.timerId);
}

View file

@ -14,8 +14,9 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import { DEFAULT_WAVEFORM, Playback, PlaybackState } from "./Playback";
import { Playback, PlaybackState } from "./Playback";
import { ManagedPlayback } from "./ManagedPlayback";
import { DEFAULT_WAVEFORM } from "./consts";
/**
* Handles management of playback instances to ensure certain functionality, like
@ -38,14 +39,14 @@ export class PlaybackManager {
* instances are paused.
* @param playback Optional. The playback to leave untouched.
*/
public pauseAllExcept(playback?: Playback) {
public pauseAllExcept(playback?: Playback): void {
this.instances
.filter(p => p !== playback && p.currentState === PlaybackState.Playing)
.forEach(p => p.pause());
.filter((p) => p !== playback && p.currentState === PlaybackState.Playing)
.forEach((p) => p.pause());
}
public destroyPlaybackInstance(playback: ManagedPlayback) {
this.instances = this.instances.filter(p => p !== playback);
public destroyPlaybackInstance(playback: ManagedPlayback): void {
this.instances = this.instances.filter((p) => p !== playback);
}
public createPlaybackInstance(buf: ArrayBuffer, waveform = DEFAULT_WAVEFORM): Playback {

View file

@ -14,9 +14,7 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import { MatrixEvent } from "matrix-js-sdk/src/models/event";
import { Room } from "matrix-js-sdk/src/models/room";
import { EventType } from "matrix-js-sdk/src/@types/event";
import { MatrixEvent, Room, EventType } from "matrix-js-sdk/src/matrix";
import { logger } from "matrix-js-sdk/src/logger";
import { Playback, PlaybackState } from "./Playback";
@ -45,10 +43,10 @@ export class PlaybackQueue {
private playbacks = new Map<string, Playback>(); // keyed by event ID
private clockStates = new Map<string, number>(); // keyed by event ID
private playbackIdOrder: string[] = []; // event IDs, last == current
private currentPlaybackId: string; // event ID, broken out from above for ease of use
private currentPlaybackId: string | null = null; // event ID, broken out from above for ease of use
private recentFullPlays = new Set<string>(); // event IDs
constructor(private room: Room) {
public constructor(private room: Room) {
this.loadClocks();
SdkContextClass.instance.roomViewStore.addRoomListener(this.room.roomId, (isActive) => {
@ -64,49 +62,49 @@ export class PlaybackQueue {
}
public static forRoom(roomId: string): PlaybackQueue {
const cli = MatrixClientPeg.get();
const cli = MatrixClientPeg.safeGet();
const room = cli.getRoom(roomId);
if (!room) throw new Error("Unknown room");
if (PlaybackQueue.queues.has(room.roomId)) {
return PlaybackQueue.queues.get(room.roomId);
return PlaybackQueue.queues.get(room.roomId)!;
}
const queue = new PlaybackQueue(room);
PlaybackQueue.queues.set(room.roomId, queue);
return queue;
}
private persistClocks() {
private persistClocks(): void {
localStorage.setItem(
`mx_voice_message_clocks_${this.room.roomId}`,
JSON.stringify(Array.from(this.clockStates.entries())),
);
}
private loadClocks() {
private loadClocks(): void {
const val = localStorage.getItem(`mx_voice_message_clocks_${this.room.roomId}`);
if (!!val) {
this.clockStates = new Map<string, number>(JSON.parse(val));
}
}
public unsortedEnqueue(mxEvent: MatrixEvent, playback: Playback) {
public unsortedEnqueue(mxEvent: MatrixEvent, playback: Playback): void {
// We don't ever detach our listeners: we expect the Playback to clean up for us
this.playbacks.set(mxEvent.getId(), playback);
this.playbacks.set(mxEvent.getId()!, playback);
playback.on(UPDATE_EVENT, (state) => this.onPlaybackStateChange(playback, mxEvent, state));
playback.clockInfo.liveData.onUpdate((clock) => this.onPlaybackClock(playback, mxEvent, clock));
}
private onPlaybackStateChange(playback: Playback, mxEvent: MatrixEvent, newState: PlaybackState) {
private onPlaybackStateChange(playback: Playback, mxEvent: MatrixEvent, newState: PlaybackState): void {
// Remember where the user got to in playback
const wasLastPlaying = this.currentPlaybackId === mxEvent.getId();
if (newState === PlaybackState.Stopped && this.clockStates.has(mxEvent.getId()) && !wasLastPlaying) {
if (newState === PlaybackState.Stopped && this.clockStates.has(mxEvent.getId()!) && !wasLastPlaying) {
// noinspection JSIgnoredPromiseFromCall
playback.skipTo(this.clockStates.get(mxEvent.getId()));
playback.skipTo(this.clockStates.get(mxEvent.getId()!)!);
} else if (newState === PlaybackState.Stopped) {
// Remove the now-useless clock for some space savings
this.clockStates.delete(mxEvent.getId());
this.clockStates.delete(mxEvent.getId()!);
if (wasLastPlaying) {
if (wasLastPlaying && this.currentPlaybackId) {
this.recentFullPlays.add(this.currentPlaybackId);
const orderClone = arrayFastClone(this.playbackIdOrder);
const last = orderClone.pop();
@ -116,8 +114,8 @@ export class PlaybackQueue {
const instance = this.playbacks.get(next);
if (!instance) {
logger.warn(
"Voice message queue desync: Missing playback for next message: "
+ `Current=${this.currentPlaybackId} Last=${last} Next=${next}`,
"Voice message queue desync: Missing playback for next message: " +
`Current=${this.currentPlaybackId} Last=${last} Next=${next}`,
);
} else {
this.playbackIdOrder = orderClone;
@ -133,7 +131,7 @@ export class PlaybackQueue {
// timeline is already most recent last, so we can iterate down that.
const timeline = arrayFastClone(this.room.getLiveTimeline().getEvents());
let scanForVoiceMessage = false;
let nextEv: MatrixEvent;
let nextEv: MatrixEvent | undefined;
for (const event of timeline) {
if (event.getId() === mxEvent.getId()) {
scanForVoiceMessage = true;
@ -149,8 +147,8 @@ export class PlaybackQueue {
break; // Stop automatic playback: next useful event is not a voice message
}
const havePlayback = this.playbacks.has(event.getId());
const isRecentlyCompleted = this.recentFullPlays.has(event.getId());
const havePlayback = this.playbacks.has(event.getId()!);
const isRecentlyCompleted = this.recentFullPlays.has(event.getId()!);
if (havePlayback && !isRecentlyCompleted) {
nextEv = event;
break;
@ -164,19 +162,19 @@ export class PlaybackQueue {
} else {
this.playbackIdOrder = orderClone;
const instance = this.playbacks.get(nextEv.getId());
const instance = this.playbacks.get(nextEv.getId()!);
PlaybackManager.instance.pauseAllExcept(instance);
// This should cause a Play event, which will re-populate our playback order
// and update our current playback ID.
// noinspection JSIgnoredPromiseFromCall
instance.play();
instance?.play();
}
}
} else {
logger.warn(
"Voice message queue desync: Expected playback stop to be last in order. "
+ `Current=${this.currentPlaybackId} Last=${last} EventID=${mxEvent.getId()}`,
"Voice message queue desync: Expected playback stop to be last in order. " +
`Current=${this.currentPlaybackId} Last=${last} EventID=${mxEvent.getId()}`,
);
}
}
@ -188,15 +186,15 @@ export class PlaybackQueue {
if (order.length === 0 || order[order.length - 1] !== this.currentPlaybackId) {
const lastInstance = this.playbacks.get(this.currentPlaybackId);
if (
lastInstance.currentState === PlaybackState.Playing
|| lastInstance.currentState === PlaybackState.Paused
lastInstance &&
[PlaybackState.Playing, PlaybackState.Paused].includes(lastInstance.currentState)
) {
order.push(this.currentPlaybackId);
}
}
}
this.currentPlaybackId = mxEvent.getId();
this.currentPlaybackId = mxEvent.getId()!;
if (order.length === 0 || order[order.length - 1] !== this.currentPlaybackId) {
order.push(this.currentPlaybackId);
}
@ -210,11 +208,11 @@ export class PlaybackQueue {
}
}
private onPlaybackClock(playback: Playback, mxEvent: MatrixEvent, clocks: number[]) {
private onPlaybackClock(playback: Playback, mxEvent: MatrixEvent, clocks: number[]): void {
if (playback.currentState === PlaybackState.Decoding) return; // ignore pre-ready values
if (playback.currentState !== PlaybackState.Stopped) {
this.clockStates.set(mxEvent.getId(), clocks[0]); // [0] is the current seek position
this.clockStates.set(mxEvent.getId()!, clocks[0]); // [0] is the current seek position
}
}
}

Some files were not shown because too many files have changed in this diff Show more