Merge remote-tracking branch 'origin/develop' into last-admin-leave-room-warning
This commit is contained in:
commit
0fdb300858
2886 changed files with 393845 additions and 234022 deletions
|
@ -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
54
src/@types/commonmark.ts
Normal 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 */
|
||||
}
|
|
@ -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);
|
61
src/@types/global.d.ts
vendored
61
src/@types/global.d.ts
vendored
|
@ -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
53
src/@types/matrix-js-sdk.d.ts
vendored
Normal 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
65
src/@types/opus-recorder.d.ts
vendored
Normal 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;
|
||||
}
|
|
@ -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);
|
||||
}
|
||||
};
|
||||
|
|
2
src/@types/raw-loader.d.ts
vendored
2
src/@types/raw-loader.d.ts
vendored
|
@ -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
24
src/@types/react.d.ts
vendored
Normal 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;
|
||||
}
|
|
@ -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
|
2
src/@types/worker-loader.d.ts
vendored
2
src/@types/worker-loader.d.ts
vendored
|
@ -16,7 +16,7 @@ limitations under the License.
|
|||
|
||||
declare module "*.worker.ts" {
|
||||
class WebpackWorker extends Worker {
|
||||
constructor();
|
||||
public constructor();
|
||||
}
|
||||
|
||||
export default WebpackWorker;
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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 />;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
|
|
|
@ -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 "";
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -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();
|
||||
}
|
||||
|
|
383
src/DateUtils.ts
383
src/DateUtils.ts
|
@ -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,
|
||||
);
|
||||
|
|
|
@ -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();
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
|
|
@ -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}`;
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -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: {
|
||||
|
|
|
@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -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
823
src/Lifecycle.ts
823
src/Lifecycle.ts
File diff suppressed because it is too large
Load diff
|
@ -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,
|
||||
|
|
219
src/Login.ts
219
src/Login.ts
|
@ -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`);
|
||||
|
|
200
src/Markdown.ts
200
src/Markdown.ts
|
@ -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);
|
||||
|
|
|
@ -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, {
|
||||
|
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
|
|
287
src/Modal.tsx
287
src/Modal.tsx
|
@ -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()));
|
||||
|
|
|
@ -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)}</>;
|
||||
}
|
||||
}
|
||||
|
|
368
src/Notifier.ts
368
src/Notifier.ts
|
@ -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;
|
||||
|
|
|
@ -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 {
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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" });
|
||||
}
|
||||
},
|
||||
});
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
14
src/Roles.ts
14
src/Roles.ts
|
@ -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 });
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
|
|
62
src/Rooms.ts
62
src/Rooms.ts
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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));
|
||||
}
|
||||
}
|
||||
|
||||
|
|
191
src/Searching.ts
191
src/Searching.ts
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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 = {};
|
||||
|
|
|
@ -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
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
68
src/Terms.ts
68
src/Terms.ts
|
@ -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
23
src/Typeguards.ts
Normal 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;
|
||||
}
|
164
src/Unread.ts
164
src/Unread.ts
|
@ -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);
|
||||
}
|
||||
|
|
|
@ -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");
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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
46
src/WorkerManager.ts
Normal 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;
|
||||
}
|
||||
}
|
|
@ -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);
|
||||
};
|
||||
|
|
|
@ -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: {
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
});
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
});
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
|
|
@ -36,7 +36,7 @@ export const MenuItemCheckbox: React.FC<IProps> = ({ children, label, active, di
|
|||
disabled={disabled}
|
||||
aria-label={label}
|
||||
>
|
||||
{ children }
|
||||
{children}
|
||||
</RovingAccessibleButton>
|
||||
);
|
||||
};
|
||||
|
|
|
@ -36,7 +36,7 @@ export const MenuItemRadio: React.FC<IProps> = ({ children, label, active, disab
|
|||
disabled={disabled}
|
||||
aria-label={label}
|
||||
>
|
||||
{ children }
|
||||
{children}
|
||||
</RovingAccessibleButton>
|
||||
);
|
||||
};
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
|
|
@ -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}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
|
|
@ -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}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
|
|
@ -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.
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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,
|
||||
};
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
|
|
|
@ -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,
|
||||
});
|
||||
};
|
||||
|
|
|
@ -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"
|
||||
|
|
|
@ -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"
|
||||
/>
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
File diff suppressed because it is too large
Load diff
|
@ -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>
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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();
|
||||
}
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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
Loading…
Add table
Add a link
Reference in a new issue