Absorb the matrix-react-sdk repository (#28192)

Co-authored-by: github-merge-queue <118344674+github-merge-queue@users.noreply.github.com>
Co-authored-by: github-merge-queue <github-merge-queue@users.noreply.github.com>
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: Florian Duros <florian.duros@ormaz.fr>
Co-authored-by: Kim Brose <kim.brose@nordeck.net>
Co-authored-by: Florian Duros <florianduros@element.io>
Co-authored-by: R Midhun Suresh <hi@midhun.dev>
Co-authored-by: dbkr <986903+dbkr@users.noreply.github.com>
Co-authored-by: ElementRobot <releases@riot.im>
Co-authored-by: dbkr <dbkr@users.noreply.github.com>
Co-authored-by: David Baker <dbkr@users.noreply.github.com>
Co-authored-by: Michael Telatynski <7t3chguy@gmail.com>
Co-authored-by: Richard van der Hoff <1389908+richvdh@users.noreply.github.com>
Co-authored-by: David Langley <davidl@element.io>
Co-authored-by: Michael Weimann <michaelw@matrix.org>
Co-authored-by: Timshel <Timshel@users.noreply.github.com>
Co-authored-by: Sahil Silare <32628578+sahil9001@users.noreply.github.com>
Co-authored-by: Will Hunt <will@half-shot.uk>
Co-authored-by: Hubert Chathi <hubert@uhoreg.ca>
Co-authored-by: Andrew Ferrazzutti <andrewf@element.io>
Co-authored-by: Robin <robin@robin.town>
Co-authored-by: Tulir Asokan <tulir@maunium.net>
This commit is contained in:
Michael Telatynski
2024-10-16 13:31:55 +01:00
committed by GitHub
parent 2b99496025
commit c05c429803
3280 changed files with 586617 additions and 905 deletions

52
src/@types/common.ts Normal file
View File

@@ -0,0 +1,52 @@
/*
Copyright 2024 New Vector Ltd.
Copyright 2020 The Matrix.org Foundation C.I.C.
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only
Please see LICENSE files in the repository root for full details.
*/
import { JSXElementConstructor } from "react";
export type { NonEmptyArray, XOR, Writeable } from "matrix-js-sdk/src/matrix";
export type ComponentClass = keyof JSX.IntrinsicElements | JSXElementConstructor<any>;
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];
};
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];

46
src/@types/commonmark.ts Normal file
View File

@@ -0,0 +1,46 @@
/*
Copyright 2024 New Vector Ltd.
Copyright 2022 The Matrix.org Foundation C.I.C.
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only
Please see LICENSE files in the repository root for full details.
*/
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 */
}

29
src/@types/diff-dom.d.ts vendored Normal file
View File

@@ -0,0 +1,29 @@
/*
Copyright 2024 New Vector Ltd.
Copyright 2021 The Matrix.org Foundation C.I.C.
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only
Please see LICENSE files in the repository root for full details.
*/
declare module "diff-dom" {
export interface IDiff {
action: string;
name: string;
text?: string;
route: number[];
value: HTMLElement | string;
element: HTMLElement | string;
oldValue: HTMLElement | string;
newValue: HTMLElement | string;
}
interface IOpts {}
export class DiffDOM {
public constructor(opts?: IOpts);
public apply(tree: unknown, diffs: IDiff[]): unknown;
public undo(tree: unknown, diffs: IDiff[]): unknown;
public diff(a: HTMLElement | string, b: HTMLElement | string): IDiff[];
}
}

14
src/@types/electron-to-chromium.d.ts vendored Normal file
View File

@@ -0,0 +1,14 @@
/*
Copyright 2024 New Vector Ltd.
Copyright 2024 The Matrix.org Foundation C.I.C.
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only
Please see LICENSE files in the repository root for full details.
*/
declare module "electron-to-chromium/versions" {
const versionMap: {
[electronVersion: string]: string;
};
export default versionMap;
}

188
src/@types/global.d.ts vendored
View File

@@ -1,13 +1,51 @@
/*
Copyright 2020-2024 New Vector Ltd.
Copyright 2020, 2021 The Matrix.org Foundation C.I.C.
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only
Please see LICENSE files in the repository root for full details.
*/
import "matrix-react-sdk/src/@types/global"; // load matrix-react-sdk's type extensions first
// eslint-disable-next-line no-restricted-imports
import "matrix-js-sdk/src/@types/global"; // load matrix-js-sdk's type extensions first
import "@types/modernizr";
import type { Renderer } from "react-dom";
import type { logger } from "matrix-js-sdk/src/logger";
import ContentMessages from "../ContentMessages";
import { IMatrixClientPeg } from "../MatrixClientPeg";
import ToastStore from "../stores/ToastStore";
import DeviceListener from "../DeviceListener";
import { RoomListStore } from "../stores/room-list/Interface";
import { PlatformPeg } from "../PlatformPeg";
import RoomListLayoutStore from "../stores/room-list/RoomListLayoutStore";
import { IntegrationManagers } from "../integrations/IntegrationManagers";
import { ModalManager } from "../Modal";
import SettingsStore from "../settings/SettingsStore";
import { Notifier } from "../Notifier";
import RightPanelStore from "../stores/right-panel/RightPanelStore";
import WidgetStore from "../stores/WidgetStore";
import LegacyCallHandler from "../LegacyCallHandler";
import UserActivity from "../UserActivity";
import { ModalWidgetStore } from "../stores/ModalWidgetStore";
import { WidgetLayoutStore } from "../stores/widgets/WidgetLayoutStore";
import VoipUserMapper from "../VoipUserMapper";
import { SpaceStoreClass } from "../stores/spaces/SpaceStore";
import TypingStore from "../stores/TypingStore";
import { EventIndexPeg } from "../indexing/EventIndexPeg";
import { VoiceRecordingStore } from "../stores/VoiceRecordingStore";
import PerformanceMonitor from "../performance";
import UIStore from "../stores/UIStore";
import { SetupEncryptionStore } from "../stores/SetupEncryptionStore";
import { RoomScrollStateStore } from "../stores/RoomScrollStateStore";
import { ConsoleLogger, IndexedDBLogStore } from "../rageshake/rageshake";
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 */
type ElectronChannel =
| "app_onAction"
@@ -34,10 +72,57 @@ declare global {
mxSendRageshake: (text: string, withLogs?: boolean) => void;
matrixLogger: typeof logger;
matrixChat: ReturnType<Renderer>;
mxSendSentryReport: (userText: string, issueUrl: string, error: Error) => Promise<void>;
mxLoginWithAccessToken: (hsUrl: string, accessToken: string) => Promise<void>;
mxAutoRageshakeStore?: AutoRageshakeStore;
mxDispatcher: MatrixDispatcher;
mxMatrixClientPeg: IMatrixClientPeg;
mxReactSdkConfig: DeepReadonly<IConfigOptions>;
// Needed for Safari, unknown to TypeScript
webkitAudioContext: typeof AudioContext;
// https://docs.microsoft.com/en-us/previous-versions/hh772328(v=vs.85)
// we only ever check for its existence, so we can ignore its actual type
MSStream?: unknown;
// 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;
};
mxContentMessages: ContentMessages;
mxToastStore: ToastStore;
mxDeviceListener: DeviceListener;
mxRoomListStore: RoomListStore;
mxRoomListLayoutStore: RoomListLayoutStore;
mxPlatformPeg: PlatformPeg;
mxIntegrationManagers: typeof IntegrationManagers;
singletonModalManager: ModalManager;
mxSettingsStore: SettingsStore;
mxNotifier: typeof Notifier;
mxRightPanelStore: RightPanelStore;
mxWidgetStore: WidgetStore;
mxWidgetLayoutStore: WidgetLayoutStore;
mxLegacyCallHandler: LegacyCallHandler;
mxUserActivity: UserActivity;
mxModalWidgetStore: ModalWidgetStore;
mxVoipUserMapper: VoipUserMapper;
mxSpaceStore: SpaceStoreClass;
mxVoiceRecordingStore: VoiceRecordingStore;
mxTypingStore: TypingStore;
mxEventIndexPeg: EventIndexPeg;
mxPerformanceMonitor: PerformanceMonitor;
mxPerformanceEntryNames: any;
mxUIStore: UIStore;
mxSetupEncryptionStore?: SetupEncryptionStore;
mxRoomScrollStateStore?: RoomScrollStateStore;
mxActiveWidgetStore?: ActiveWidgetStore;
mxOnRecaptchaLoaded?: () => void;
// electron-only
electron?: Electron;
// opera-only
opera?: any;
@@ -49,6 +134,103 @@ declare global {
on(channel: ElectronChannel, listener: (event: Event, ...args: any[]) => void): void;
send(channel: ElectronChannel, ...args: any[]): void;
}
interface DesktopCapturerSource {
id: string;
name: string;
thumbnailURL: string;
}
interface GetSourcesOptions {
types: Array<string>;
thumbnailSize?: {
height: number;
width: number;
};
fetchWindowIcons?: boolean;
}
interface Document {
// Safari & IE11 only have this prefixed: we used prefixed versions
// previously so let's continue to support them for now
webkitExitFullscreen(): Promise<void>;
msExitFullscreen(): Promise<void>;
readonly webkitFullscreenElement: Element | null;
readonly msFullscreenElement: Element | null;
}
interface Navigator {
userLanguage?: string;
}
interface StorageEstimate {
usageDetails?: { [key: string]: number };
}
interface Element {
// Safari & IE11 only have this prefixed: we used prefixed versions
// previously so let's continue to support them for now
webkitRequestFullScreen(options?: FullscreenOptions): Promise<void>;
msRequestFullscreen(options?: FullscreenOptions): Promise<void>;
// scrollIntoView(arg?: boolean | _ScrollIntoViewOptions): void;
}
// https://github.com/microsoft/TypeScript/issues/28308#issuecomment-650802278
interface AudioWorkletProcessor {
readonly port: MessagePort;
process(inputs: Float32Array[][], outputs: Float32Array[][], parameters: Record<string, Float32Array>): boolean;
}
// https://github.com/microsoft/TypeScript/issues/28308#issuecomment-650802278
const AudioWorkletProcessor: {
prototype: AudioWorkletProcessor;
new (options?: AudioWorkletNodeOptions): AudioWorkletProcessor;
};
// https://github.com/microsoft/TypeScript-DOM-lib-generator/issues/1029#issuecomment-881509595
interface AudioParamDescriptor {
readonly port: MessagePort;
}
/**
* In future, browsers will support focusVisible option.
* See https://developer.mozilla.org/en-US/docs/Web/API/HTMLElement/focus#focusvisible
*/
interface FocusOptions {
focusVisible: boolean;
}
// https://github.com/microsoft/TypeScript/issues/28308#issuecomment-650802278
function registerProcessor(
name: string,
processorCtor: (new (options?: AudioWorkletNodeOptions) => AudioWorkletProcessor) & {
parameterDescriptors?: AudioParamDescriptor[];
},
): void;
// eslint-disable-next-line no-var
var grecaptcha:
| undefined
| {
reset: (id: string) => void;
render: (
divId: string,
options: {
sitekey: string;
callback: (response: string) => void;
},
) => string;
isReady: () => boolean;
};
// eslint-disable-next-line no-var, camelcase
var mx_rage_logger: ConsoleLogger;
// eslint-disable-next-line no-var, camelcase
var mx_rage_initPromise: Promise<void>;
// eslint-disable-next-line no-var, camelcase
var mx_rage_initStoragePromise: Promise<void>;
// eslint-disable-next-line no-var, camelcase
var mx_rage_store: IndexedDBLogStore;
}
// add method which is missing from the node typing
@@ -57,3 +239,5 @@ declare module "url" {
format(): string;
}
}
/* eslint-enable @typescript-eslint/naming-convention */

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

@@ -0,0 +1,77 @@
/*
Copyright 2024 New Vector Ltd.
Copyright 2024 The Matrix.org Foundation C.I.C.
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only
Please see LICENSE files in the repository root for full details.
*/
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";
import type { EncryptedFile } from "matrix-js-sdk/src/types";
// Extend Matrix JS SDK types via Typescript declaration merging to support unspecced event fields and types
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;
}
export interface TimelineEvents {
"io.element.performance_metric": {
"io.element.performance_metrics": {
forEventId: string;
responseTs: number;
kind: "send_time";
};
};
}
export interface AudioContent {
// MSC1767 + Ideals of MSC2516 as MSC3245
// https://github.com/matrix-org/matrix-doc/pull/3245
"org.matrix.msc1767.text"?: string;
"org.matrix.msc1767.file"?: {
url?: string;
file?: EncryptedFile;
name: string;
mimetype: string;
size: number;
};
"org.matrix.msc1767.audio"?: {
duration: number;
// https://github.com/matrix-org/matrix-doc/pull/3246
waveform?: number[];
};
"org.matrix.msc3245.voice"?: {};
"io.element.voice_broadcast_chunk"?: { sequence: number };
}
}

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

@@ -0,0 +1,57 @@
/*
Copyright 2024 New Vector Ltd.
Copyright 2023 The Matrix.org Foundation C.I.C.
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only
Please see LICENSE files in the repository root for full details.
*/
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;
}

18
src/@types/png-chunks-extract.d.ts vendored Normal file
View File

@@ -0,0 +1,18 @@
/*
Copyright 2024 New Vector Ltd.
Copyright 2021 The Matrix.org Foundation C.I.C.
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only
Please see LICENSE files in the repository root for full details.
*/
declare module "png-chunks-extract" {
interface IChunk {
name: string;
data: Uint8Array;
}
function extractPngChunks(data: Uint8Array | Buffer): IChunk[];
export default extractPngChunks;
}

48
src/@types/polyfill.ts Normal file
View File

@@ -0,0 +1,48 @@
/*
Copyright 2024 New Vector Ltd.
Copyright 2020 The Matrix.org Foundation C.I.C.
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only
Please see LICENSE files in the repository root for full details.
*/
// This is intended to fix re-resizer because of its unguarded `instanceof TouchEvent` checks.
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;
}
public constructor(eventType: string, params?: any) {
super(eventType, params);
}
};
}
}

View File

@@ -1,3 +1,11 @@
/*
Copyright 2024 New Vector Ltd.
Copyright 2021 The Matrix.org Foundation C.I.C.
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only
Please see LICENSE files in the repository root for full details.
*/
declare module "!!raw-loader!*" {
const contents: string;
export default contents;

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

@@ -0,0 +1,16 @@
/*
Copyright 2024 New Vector Ltd.
Copyright 2023 The Matrix.org Foundation C.I.C.
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only
Please see LICENSE files in the repository root for full details.
*/
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;
}

15
src/@types/sanitize-html.d.ts vendored Normal file
View File

@@ -0,0 +1,15 @@
/*
Copyright 2024 New Vector Ltd.
Copyright 2020 The Matrix.org Foundation C.I.C.
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only
Please see LICENSE files in the repository root for full details.
*/
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
// separate type definition module.
nestingLimit?: number;
}

1
src/@types/svg.d.ts vendored
View File

@@ -1,6 +1,7 @@
/*
Copyright 2024 New Vector Ltd.
Copyright 2022 The Matrix.org Foundation C.I.C.
Copyright 2021 Šimon Brandner <simon.bra.ag@gmail.com>
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only
Please see LICENSE files in the repository root for full details.

15
src/@types/worker-loader.d.ts vendored Normal file
View File

@@ -0,0 +1,15 @@
/*
Copyright 2024 New Vector Ltd.
Copyright 2021 The Matrix.org Foundation C.I.C.
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only
Please see LICENSE files in the repository root for full details.
*/
declare module "*.worker.ts" {
class WebpackWorker extends Worker {
public constructor();
}
export default WebpackWorker;
}

336
src/AddThreepid.ts Normal file
View File

@@ -0,0 +1,336 @@
/*
Copyright 2024 New Vector Ltd.
Copyright 2019 The Matrix.org Foundation C.I.C.
Copyright 2017 Vector Creations Ltd
Copyright 2016 OpenMarket Ltd
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only
Please see LICENSE files in the repository root for full details.
*/
import {
IAddThreePidOnlyBody,
IAuthData,
IRequestMsisdnTokenResponse,
IRequestTokenResponse,
MatrixClient,
MatrixError,
HTTPError,
IThreepid,
} from "matrix-js-sdk/src/matrix";
import Modal from "./Modal";
import { _t, UserFriendlyError } from "./languageHandler";
import IdentityAuthClient from "./IdentityAuthClient";
import { SSOAuthEntry } from "./components/views/auth/InteractiveAuthEntryComponents";
import InteractiveAuthDialog, { InteractiveAuthDialogProps } from "./components/views/dialogs/InteractiveAuthDialog";
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.
*
* This involves getting an email token from the identity server to "prove" that
* the client owns the given email address, which is then passed to the
* add threepid API on the homeserver.
*
* Diagrams of the intended API flows here are available at:
*
* https://gist.github.com/jryans/839a09bf0c5a70e2f36ed990d50ed928
*/
export default class AddThreepid {
private sessionId?: string;
private submitUrl?: string;
private bind = false;
private readonly clientSecret: string;
public constructor(private readonly matrixClient: MatrixClient) {
this.clientSecret = matrixClient.generateClientSecret();
}
/**
* Attempt to add an email threepid to the homeserver.
* This will trigger a side-effect of sending an email to the provided email address.
* @param {string} emailAddress The email address to add
* @return {Promise} Resolves when the email has been sent. Then call checkEmailLinkClicked().
*/
public async addEmailAddress(emailAddress: string): Promise<IRequestTokenResponse> {
try {
const res = await this.matrixClient.requestAdd3pidEmailToken(emailAddress, this.clientSecret, 1);
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;
}
}
/**
* Attempt to bind an email threepid on the identity server via the homeserver.
* This will trigger a side-effect of sending an email to the provided email address.
* @param {string} emailAddress The email address to add
* @return {Promise} Resolves when the email has been sent. Then call checkEmailLinkClicked().
*/
public async bindEmailAddress(emailAddress: string): Promise<IRequestTokenResponse> {
this.bind = true;
// 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;
}
}
/**
* Attempt to add a MSISDN threepid to the homeserver.
* This will trigger a side-effect of sending an SMS to the provided phone number.
* @param {string} phoneCountry The ISO 2 letter code of the country to resolve phoneNumber in
* @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 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;
} 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;
}
}
/**
* Attempt to bind a MSISDN threepid on the identity server via the homeserver.
* This will trigger a side-effect of sending an SMS to the provided phone number.
* @param {string} phoneCountry The ISO 2 letter code of the country to resolve phoneNumber in
* @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 async bindMsisdn(phoneCountry: string, phoneNumber: string): Promise<IRequestMsisdnTokenResponse> {
this.bind = true;
// 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;
}
}
/**
* Checks if the email link has been clicked by attempting to add the threepid
* @return {Promise} Resolves if the email address was added. Rejects with an object
* with a "message" property which contains a human-readable message detailing why
* the request failed.
*/
public async checkEmailLinkClicked(): Promise<[success?: boolean, result?: IAuthData | Error | null]> {
try {
if (this.bind) {
const authClient = new IdentityAuthClient();
const identityAccessToken = await authClient.getAccessToken();
if (!identityAccessToken) {
throw new UserFriendlyError("settings|general|identity_server_no_token");
}
await this.matrixClient.bindThreePid({
sid: this.sessionId!,
client_secret: this.clientSecret,
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 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?: IAddThreePidOnlyBody["auth"] | null): Promise<{}> => {
return this.matrixClient.addThreePidOnly({
sid: this.sessionId!,
client_secret: this.clientSecret,
auth: auth ?? undefined,
});
};
/**
* Takes a phone number verification code as entered by the user and validates
* it with the identity server, then if successful, adds the phone number.
* @param {string} msisdnToken phone number verification code as entered by the user
* @return {Promise} Resolves if the phone number was added. Rejects with an object
* with a "message" property which contains a human-readable message detailing why
* the request failed.
*/
public async haveMsisdnToken(msisdnToken: string): Promise<[success?: boolean, result?: IAuthData | Error | null]> {
const authClient = new IdentityAuthClient();
if (this.submitUrl) {
await this.matrixClient.submitMsisdnTokenOtherUrl(
this.submitUrl,
this.sessionId!,
this.clientSecret,
msisdnToken,
);
} else if (this.bind) {
await this.matrixClient.submitMsisdnToken(
this.sessionId!,
this.clientSecret,
msisdnToken,
await authClient.getAccessToken(),
);
} else {
throw new UserFriendlyError("settings|general|add_msisdn_misconfigured");
}
if (this.bind) {
await this.matrixClient.bindThreePid({
sid: this.sessionId!,
client_secret: this.clientSecret,
id_server: getIdServerDomain(this.matrixClient),
id_access_token: await authClient.getAccessToken(),
});
return [true];
} 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("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;
}
}
}
}

86
src/AsyncWrapper.tsx Normal file
View File

@@ -0,0 +1,86 @@
/*
Copyright 2024 New Vector Ltd.
Copyright 2015-2021 The Matrix.org Foundation C.I.C.
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only
Please see LICENSE files in the repository root for full details.
*/
import React, { ComponentType, PropsWithChildren } from "react";
import { logger } from "matrix-js-sdk/src/logger";
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 {
// A promise which resolves with the real component
prom: Promise<ComponentType<any> | AsyncImport<ComponentType<any>>>;
onFinished(): void;
}
interface IState {
component?: ComponentType<PropsWithChildren<any>>;
error?: Error;
}
/**
* Wrap an asynchronous loader function with a react component which shows a
* spinner until the real component loads.
*/
export default class AsyncWrapper extends React.Component<IProps, IState> {
private unmounted = false;
public state: IState = {};
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 });
});
}
public componentWillUnmount(): void {
this.unmounted = true;
}
private onWrapperCancelClick = (): void => {
this.props.onFinished();
};
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("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 />;
}
}
}

179
src/Avatar.ts Normal file
View File

@@ -0,0 +1,179 @@
/*
Copyright 2024 New Vector Ltd.
Copyright 2015, 2016 OpenMarket Ltd
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only
Please see LICENSE files in the repository root for full details.
*/
import { RoomMember, User, Room, ResizeMethod } from "matrix-js-sdk/src/matrix";
import { useIdColorHash } from "@vector-im/compound-web";
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 | undefined,
width: number,
height: number,
resizeMethod: ResizeMethod,
): string {
let url: string | null | undefined;
if (member?.getMxcAvatarUrl()) {
url = mediaFromMxc(member.getMxcAvatarUrl()).getThumbnailOfSourceHttp(width, height, resizeMethod);
}
if (!url) {
// 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 : "");
}
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,
height: number,
resizeMethod?: ResizeMethod,
): string | null {
if (!user.avatarUrl) return null;
return mediaFromMxc(user.avatarUrl).getThumbnailOfSourceHttp(width, height, resizeMethod);
}
function isValidHexColor(color: string): boolean {
return (
typeof color === "string" &&
(color.length === 7 || color.length === 9) &&
color.charAt(0) === "#" &&
!color
.slice(1)
.split("")
.some((c) => isNaN(parseInt(c, 16)))
);
}
function urlForColor(color: string): string {
const size = 40;
const canvas = document.createElement("canvas");
canvas.width = size;
canvas.height = size;
const ctx = canvas.getContext("2d");
// bail out when using jsdom in unit tests
if (!ctx) {
return "";
}
ctx.fillStyle = color;
ctx.fillRect(0, 0, size, size);
return canvas.toDataURL();
}
// XXX: Ideally we'd clear this cache when the theme changes
// but since this function is at global scope, it's a bit
// hard to install a listener here, even if there were a clear event to listen to
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
// 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 = 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
// with custom theming
if (isValidHexColor(color)) {
dataUrl = urlForColor(color);
colorToDataURLCache.set(color, dataUrl);
} else {
dataUrl = "";
}
}
return dataUrl;
}
/**
* returns the first (non-sigil) character of 'name',
* converted to uppercase
* @param {string} name
* @return {string} the first letter
*/
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");
return undefined;
}
if (name.length < 1) {
return undefined;
}
const initial = name[0];
if ((initial === "@" || initial === "#" || initial === "+") && name[1]) {
name = name.substring(1);
}
return getFirstGrapheme(name).toUpperCase();
}
export function avatarUrlForRoom(
room: Room | null,
width?: number,
height?: number,
resizeMethod?: ResizeMethod,
): string | null {
if (!room) return null; // null-guard
if (room.getMxcAvatarUrl()) {
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)) {
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()) {
const media = mediaFromMxc(otherMember.getMxcAvatarUrl());
if (width !== undefined && height !== undefined) {
return media.getThumbnailOfSourceHttp(width, height, resizeMethod);
}
return media.srcHttp;
}
return null;
}

459
src/BasePlatform.ts Normal file
View File

@@ -0,0 +1,459 @@
/*
Copyright 2024 New Vector Ltd.
Copyright 2020 The Matrix.org Foundation C.I.C.
Copyright 2018 New Vector Ltd
Copyright 2016 Aviral Dasgupta
Copyright 2016 OpenMarket Ltd
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only
Please see LICENSE files in the repository root for full details.
*/
import {
MatrixClient,
MatrixEvent,
Room,
SSOAction,
encodeUnpaddedBase64,
OidcRegistrationClientMetadata,
} from "matrix-js-sdk/src/matrix";
import { logger } from "matrix-js-sdk/src/logger";
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";
import { hideToast as hideUpdateToast } from "./toasts/UpdateToast";
import { MatrixClientPeg } from "./MatrixClientPeg";
import { idbLoad, idbSave, idbDelete } from "./utils/StorageAccess";
import { ViewRoomPayload } from "./dispatcher/payloads/ViewRoomPayload";
import { IConfigOptions } from "./IConfigOptions";
import SdkConfig from "./SdkConfig";
import { buildAndEncodePickleKey, encryptPickleKey } from "./utils/tokens/pickling";
export const SSO_HOMESERVER_URL_KEY = "mx_sso_hs_url";
export const SSO_ID_SERVER_URL_KEY = "mx_sso_is_url";
export const SSO_IDP_ID_KEY = "mx_sso_idp_id";
export enum UpdateCheckStatus {
Checking = "CHECKING",
Error = "ERROR",
NotAvailable = "NOTAVAILABLE",
Downloading = "DOWNLOADING",
Ready = "READY",
}
export interface UpdateStatus {
/**
* The current phase of the manual update check.
*/
status: UpdateCheckStatus;
/**
* Detail string relating to the current status, typically for error details.
*/
detail?: string;
}
const UPDATE_DEFER_KEY = "mx_defer_update";
/**
* Base class for classes that provide platform-specific functionality
* eg. Setting an application badge or displaying notifications
*
* Instances of this class are provided by the application.
*/
export default abstract class BasePlatform {
protected notificationCount = 0;
protected errorDidOccur = false;
protected constructor() {
dis.register(this.onAction);
this.startUpdateCheck = this.startUpdateCheck.bind(this);
}
public abstract getConfig(): Promise<IConfigOptions | undefined>;
public abstract getDefaultDeviceDisplayName(): string;
protected onAction = (payload: ActionPayload): void => {
switch (payload.action) {
case "on_client_not_viable":
case Action.OnLoggedOut:
this.setNotificationCount(0);
break;
}
};
// Used primarily for Analytics
public abstract getHumanReadableName(): string;
public setNotificationCount(count: number): void {
this.notificationCount = count;
}
public setErrorStatus(errorDidOccur: boolean): void {
this.errorDidOccur = errorDidOccur;
}
/**
* Whether we can call checkForUpdate on this platform build
*/
public async canSelfUpdate(): Promise<boolean> {
return false;
}
public startUpdateCheck(): void {
hideUpdateToast();
localStorage.removeItem(UPDATE_DEFER_KEY);
dis.dispatch<CheckUpdatesPayload>({
action: Action.CheckUpdates,
status: UpdateCheckStatus.Checking,
});
}
/**
* Update the currently running app to the latest available version
* and replace this instance of the app with the new version.
*/
public installUpdate(): void {}
/**
* Check if the version update has been deferred and that deferment is still in effect
* @param newVersion the version string to check
*/
protected shouldShowUpdate(newVersion: string): boolean {
// If the user registered on this client in the last 24 hours then do not show them the update toast
if (MatrixClientPeg.userRegisteredWithinLastHours(24)) return false;
try {
const [version, deferUntil] = JSON.parse(localStorage.getItem(UPDATE_DEFER_KEY)!);
return newVersion !== version || Date.now() > deferUntil;
} catch (e) {
return true;
}
}
/**
* Ignore the pending update and don't prompt about this version
* until the next morning (8am).
*/
public deferUpdate(newVersion: string): void {
const date = new Date(Date.now() + 24 * 60 * 60 * 1000);
date.setHours(8, 0, 0, 0); // set to next 8am
localStorage.setItem(UPDATE_DEFER_KEY, JSON.stringify([newVersion, date.getTime()]));
hideUpdateToast();
}
/**
* Return true if platform supports multi-language
* spell-checking, otherwise false.
*/
public supportsSpellCheckSettings(): boolean {
return false;
}
/**
* Returns true if platform allows overriding native context menus
*/
public allowOverridingNativeContextMenus(): boolean {
return false;
}
/**
* Returns true if the platform supports displaying
* notifications, otherwise false.
* @returns {boolean} whether the platform supports displaying notifications
*/
public supportsNotifications(): boolean {
return false;
}
/**
* Returns true if the application currently has permission
* to display notifications. Otherwise false.
* @returns {boolean} whether the application has permission to display notifications
*/
public maySendNotifications(): boolean {
return false;
}
/**
* Requests permission to send notifications. Returns
* a promise that is resolved when the user has responded
* to the request. The promise has a single string argument
* that is 'granted' if the user allowed the request or
* 'denied' otherwise.
*/
public abstract requestNotificationPermission(): Promise<string>;
public displayNotification(
title: string,
msg: string,
avatarUrl: string | null,
room: Room,
ev?: MatrixEvent,
): Notification {
const notifBody: NotificationOptions = {
body: msg,
silent: true, // we play our own sounds
};
if (avatarUrl) notifBody["icon"] = avatarUrl;
const notification = new window.Notification(title, notifBody);
notification.onclick = () => {
const payload: ViewRoomPayload = {
action: Action.ViewRoom,
room_id: room.roomId,
metricsTrigger: "Notification",
};
if (ev?.getThread()) {
payload.event_id = ev.getId();
}
dis.dispatch(payload);
window.focus();
};
return notification;
}
public loudNotification(ev: MatrixEvent, room: Room): void {}
public clearNotification(notif: Notification): void {
// Some browsers don't support this, e.g Safari on iOS
// https://developer.mozilla.org/en-US/docs/Web/API/Notification/close
if (notif.close) {
notif.close();
}
}
/**
* Returns true if the platform requires URL previews in tooltips, otherwise false.
* @returns {boolean} whether the platform requires URL previews in tooltips
*/
public needsUrlTooltips(): boolean {
return false;
}
/**
* Returns a promise that resolves to a string representing the current version of the application.
*/
public abstract getAppVersion(): Promise<string>;
/**
* Restarts the application, without necessarily reloading
* any application code
*/
public abstract reload(): void;
public supportsSetting(settingName?: string): boolean {
return false;
}
public async getSettingValue(settingName: string): Promise<any> {
return undefined;
}
public setSettingValue(settingName: string, value: any): Promise<void> {
throw new Error("Unimplemented");
}
/**
* Get our platform specific EventIndexManager.
*
* @return {BaseEventIndexManager} The EventIndex manager for our platform,
* can be null if the platform doesn't support event indexing.
*/
public getEventIndexingManager(): BaseEventIndexManager | null {
return null;
}
public setLanguage(preferredLangs: string[]): void {}
public setSpellCheckEnabled(enabled: boolean): void {}
public async getSpellCheckEnabled(): Promise<boolean> {
return false;
}
public setSpellCheckLanguages(preferredLangs: string[]): void {}
public getSpellCheckLanguages(): Promise<string[]> | null {
return null;
}
public async getDesktopCapturerSources(options: GetSourcesOptions): Promise<Array<DesktopCapturerSource>> {
return [];
}
public supportsDesktopCapturer(): boolean {
return false;
}
public supportsJitsiScreensharing(): boolean {
return true;
}
public overrideBrowserShortcuts(): boolean {
return false;
}
public navigateForwardBack(back: boolean): void {}
public getAvailableSpellCheckLanguages(): Promise<string[]> | null {
return null;
}
/**
* The URL to return to after a successful SSO 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;
return url;
}
/**
* Begin Single Sign On flows.
* @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,
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()!);
}
if (idpId) {
localStorage.setItem(SSO_IDP_ID_KEY, idpId);
}
const callbackUrl = this.getSSOCallbackUrl(fragmentAfterLogin);
window.location.href = mxClient.getSsoLoginUrl(callbackUrl.toString(), loginType, idpId, action); // redirect to SSO
}
/**
* Get a previously stored pickle key. The pickle key is used for
* encrypting libolm objects and react-sdk-crypto data.
* @param {string} userId the user ID for the user that the pickle key is for.
* @param {string} deviceId the device ID that the pickle key is for.
* @returns {string|null} the previously stored pickle key, or null if no
* pickle key has been stored.
*/
public async getPickleKey(userId: string, deviceId: string): Promise<string | null> {
let data: { encrypted?: BufferSource; iv?: BufferSource; cryptoKey?: CryptoKey } | undefined;
try {
data = await idbLoad("pickleKey", [userId, deviceId]);
} catch (e) {
logger.error("idbLoad for pickleKey failed", e);
}
return (await buildAndEncodePickleKey(data, userId, deviceId)) ?? null;
}
/**
* Create and store a pickle key for encrypting libolm objects.
* @param {string} userId the user ID for the user that the pickle key is for.
* @param {string} deviceId the device ID that the pickle key is for.
* @returns {string|null} the pickle key, or null if the platform does not
* support storing pickle keys.
*/
public async createPickleKey(userId: string, deviceId: string): Promise<string | null> {
const randomArray = new Uint8Array(32);
crypto.getRandomValues(randomArray);
const data = await encryptPickleKey(randomArray, userId, deviceId);
if (data === undefined) {
// no crypto support
return null;
}
try {
await idbSave("pickleKey", [userId, deviceId], data);
} catch (e) {
return null;
}
return encodeUnpaddedBase64(randomArray);
}
/**
* 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} deviceId the device ID that the pickle key is for.
*/
public async destroyPickleKey(userId: string, deviceId: string): Promise<void> {
try {
await idbDelete("pickleKey", [userId, deviceId]);
} catch (e) {
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;
}
/**
* Fallback Client URI to use for OIDC client registration for if one is not specified in config.json
*/
public get defaultOidcClientUri(): string {
return window.location.origin;
}
/**
* Metadata to use for dynamic OIDC client registrations
*/
public async getOidcClientMetadata(): Promise<OidcRegistrationClientMetadata> {
const config = SdkConfig.get();
return {
clientName: config.brand,
clientUri: config.oidc_metadata?.client_uri ?? this.defaultOidcClientUri,
redirectUris: [this.getOidcCallbackUrl().href],
logoUri: config.oidc_metadata?.logo_uri ?? new URL("vector-icons/1024.png", this.baseUrl).href,
applicationType: "web",
contacts: config.oidc_metadata?.contacts,
tosUri: config.oidc_metadata?.tos_uri ?? config.terms_and_conditions_links?.[0]?.url,
policyUri: config.oidc_metadata?.policy_uri ?? 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 "";
}
/**
* The URL to return to after a successful OIDC authentication
*/
public getOidcCallbackUrl(): URL {
const url = new URL(window.location.href);
// The redirect URL has to exactly match that registered at the OIDC server, so
// ensure that the fragment part of the URL is empty.
url.hash = "";
return url;
}
}

26
src/BlurhashEncoder.ts Normal file
View File

@@ -0,0 +1,26 @@
/*
Copyright 2024 New Vector Ltd.
Copyright 2021 The Matrix.org Foundation C.I.C.
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only
Please see LICENSE files in the repository root for full details.
*/
// @ts-ignore - `.ts` is needed here to make TS happy
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();
public static get instance(): BlurhashEncoder {
return BlurhashEncoder.internalInstance;
}
private readonly worker = new WorkerManager<Request, Response>(blurhashWorkerFactory());
public getBlurhash(imageData: ImageData): Promise<string> {
return this.worker.call({ imageData }).then((resp) => resp.blurhash);
}
}

687
src/ContentMessages.ts Normal file
View File

@@ -0,0 +1,687 @@
/*
Copyright 2024 New Vector Ltd.
Copyright 2020 The Matrix.org Foundation C.I.C.
Copyright 2019 New Vector Ltd
Copyright 2015, 2016 OpenMarket Ltd
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only
Please see LICENSE files in the repository root for full details.
*/
import {
MatrixClient,
MsgType,
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 { logger } from "matrix-js-sdk/src/logger";
import { removeElement } from "matrix-js-sdk/src/utils";
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 {
UploadCanceledPayload,
UploadErrorPayload,
UploadFinishedPayload,
UploadProgressPayload,
UploadStartedPayload,
} from "./dispatcher/payloads/UploadPayload";
import { RoomUpload } from "./models/RoomUpload";
import SettingsStore from "./settings/SettingsStore";
import { decorateStartSendingTime, sendRoundTripMetric } from "./sendTimePerformanceMetrics";
import { TimelineRenderingType } from "./contexts/RoomContext";
import { addReplyToMessageContent } from "./utils/Reply";
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 { attachMentions, attachRelation } from "./components/views/rooms/SendMessageComposer";
import { doMaybeLocalRoomAction } from "./utils/local-room";
import { SdkContextClass } from "./contexts/SDKContext";
// scraped out of a macOS hidpi (5660ppm) screenshot png
// 5669 px (x-axis) , 5669 px (y-axis) , per metre
const PHYS_HIDPI = [0x00, 0x00, 0x16, 0x25, 0x00, 0x00, 0x16, 0x25, 0x01];
export class UploadCanceledError extends Error {}
interface IMediaConfig {
"m.upload.size"?: number;
}
/**
* Load a file into a newly created image element.
*
* @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): 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 (): void {
URL.revokeObjectURL(objectUrl);
resolve(img);
};
img.onerror = function (e): void {
reject(e);
};
});
img.src = objectUrl;
// check for hi-dpi PNGs and fudge display resolution as needed.
// this is mainly needed for macOS screencaps
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 false;
return chunk.data.every((val, i) => val === PHYS_HIDPI[i]);
}
}
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;
return { width, height, img };
}
// Minimum size for image files before we generate a thumbnail for them.
const IMAGE_SIZE_THRESHOLD_THUMBNAIL = 1 << 15; // 32KB
// Minimum size improvement for image thumbnails, if both are not met then don't bother uploading thumbnail.
const IMAGE_THUMBNAIL_MIN_REDUCTION_SIZE = 1 << 16; // 1MB
const IMAGE_THUMBNAIL_MIN_REDUCTION_PERCENT = 0.1; // 10%
// We don't apply these thresholds to video thumbnails as a poster image is always useful
// and videos tend to be much larger.
// Image mime types for which to always include a thumbnail for even if it is larger than the input for wider support.
const ALWAYS_INCLUDE_THUMBNAIL = ["image/avif", "image/webp"];
/**
* Read the metadata for an image file and create and upload a thumbnail of the image.
*
* @param {MatrixClient} matrixClient A matrixClient to upload the thumbnail with.
* @param {String} roomId The ID of the room the image will be uploaded in.
* @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<ImageInfo> {
let thumbnailType = "image/png";
if (imageFile.type === "image/jpeg") {
thumbnailType = "image/jpeg";
}
const imageElement = await loadImageElement(imageFile);
const result = await createThumbnail(imageElement.img, imageElement.width, imageElement.height, thumbnailType);
const imageInfo = result.info;
// 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;
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)
) {
delete imageInfo["thumbnail_info"];
return imageInfo;
}
}
const uploadResult = await uploadFile(matrixClient, roomId, result.thumbnail);
imageInfo["thumbnail_url"] = uploadResult.url;
imageInfo["thumbnail_file"] = uploadResult.file;
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 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 a html element
const video = document.createElement("video");
video.preload = "metadata";
video.playsInline = true;
video.muted = true;
const reader = new FileReader();
reader.onload = function (ev): void {
// Wait until we have enough data to thumbnail the first frame.
video.onloadeddata = async function (): Promise<void> {
resolve(video);
video.pause();
};
video.onerror = function (e): void {
reject(e);
};
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;")) {
dataUrl = dataUrl.replace("data:video/quicktime;", "data:video/mp4;");
}
video.src = dataUrl;
video.load();
video.play();
};
reader.onerror = function (e): void {
reject(e);
};
reader.readAsDataURL(videoFile);
});
}
/**
* Read the metadata for a video file and create and upload a thumbnail of the video.
*
* @param {MatrixClient} matrixClient A matrixClient to upload the thumbnail with.
* @param {String} roomId The ID of the room the video will be uploaded to.
* @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<VideoInfo> {
const thumbnailType = "image/jpeg";
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;
});
}
/**
* Read the file as an ArrayBuffer.
* @param {File} file The file to read
* @return {Promise} A promise that resolves with an ArrayBuffer when the file
* is read.
*/
function readFileAsArrayBuffer(file: File | Blob): Promise<ArrayBuffer> {
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onload = function (e): void {
resolve(e.target?.result as ArrayBuffer);
};
reader.onerror = function (e): void {
reject(e);
};
reader.readAsArrayBuffer(file);
});
}
/**
* Upload the file to the content repository.
* If the room is encrypted then encrypt the file before uploading.
*
* @param {MatrixClient} matrixClient The matrix client to upload the file with.
* @param {String} roomId The ID of the room being uploaded to.
* @param {File} file The file to upload.
* @param {Function?} progressHandler optional callback to be called when a chunk of
* data is uploaded.
* @param {AbortController?} controller optional abortController to use for this upload.
* @return {Promise} A promise that resolves with an object.
* If the file is unencrypted then the object will have a "url" key.
* If the file is encrypted then the object will have a "file" key.
*/
export async function uploadFile(
matrixClient: MatrixClient,
roomId: string,
file: File | Blob,
progressHandler?: UploadOpts["progressHandler"],
controller?: AbortController,
): Promise<{ url?: string; file?: EncryptedFile }> {
const abortController = controller ?? new AbortController();
// If the room is encrypted then encrypt the file before uploading it.
if (matrixClient.isRoomEncrypted(roomId)) {
// First read the file into memory.
const data = await readFileAsArrayBuffer(file);
if (abortController.signal.aborted) throw new UploadCanceledError();
// Then encrypt the file.
const encryptResult = await encrypt.encryptAttachment(data);
if (abortController.signal.aborted) throw new UploadCanceledError();
// Pass the encrypted data as a Blob to the uploader.
const blob = new Blob([encryptResult.data]);
const { content_uri: url } = await matrixClient.uploadContent(blob, {
progressHandler,
abortController,
includeFilename: false,
type: "application/octet-stream",
});
if (abortController.signal.aborted) throw new UploadCanceledError();
// If the attachment is encrypted then bundle the URL along with the information
// needed to decrypt the attachment and add it under a file key.
return {
file: {
...encryptResult.info,
url,
} as EncryptedFile,
};
} else {
const { content_uri: url } = await matrixClient.uploadContent(file, { progressHandler, abortController });
if (abortController.signal.aborted) throw new UploadCanceledError();
// If the attachment isn't encrypted then include the URL directly.
return { url };
}
}
export default class ContentMessages {
private inprogress: RoomUpload[] = [];
private mediaConfig: IMediaConfig | null = null;
public sendStickerContentToRoom(
url: string,
roomId: string,
threadId: string | null,
info: ImageInfo,
text: string,
matrixClient: MatrixClient,
): Promise<ISendEventResponse> {
return doMaybeLocalRoomAction(
roomId,
(actualRoomId: string) => matrixClient.sendStickerMessage(actualRoomId, threadId, url, info, text),
matrixClient,
).catch((e) => {
logger.warn(`Failed to send content with URL ${url} to room ${roomId}`, e);
throw e;
});
}
public getUploadLimit(): number | null {
if (this.mediaConfig !== null && this.mediaConfig["m.upload.size"] !== undefined) {
return this.mediaConfig["m.upload.size"];
} else {
return null;
}
}
public async sendContentListToRoom(
files: File[],
roomId: string,
relation: IEventRelation | undefined,
matrixClient: MatrixClient,
context = TimelineRenderingType.Room,
): Promise<void> {
if (matrixClient.isGuest()) {
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, 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: File[] = [];
const okFiles: File[] = [];
for (const file of files) {
if (this.isFileSizeAcceptable(file)) {
okFiles.push(file);
} else {
tooBigFiles.push(file);
}
}
if (tooBigFiles.length > 0) {
const { finished } = Modal.createDialog(UploadFailureDialog, {
badFiles: tooBigFiles,
totalFiles: files.length,
contentMessages: this,
});
const [shouldContinue] = await finished;
if (!shouldContinue) return;
}
let uploadAll = false;
// Promise to complete before sending next file into room, used for synchronisation of file-sending
// to match the order the files were specified in
let promBefore: Promise<any> = Promise.resolve();
for (let i = 0; i < okFiles.length; ++i) {
const file = okFiles[i];
const loopPromiseBefore = promBefore;
if (!uploadAll) {
const { finished } = Modal.createDialog(UploadConfirmDialog, {
file,
currentIndex: i,
totalFiles: okFiles.length,
});
const [shouldContinue, shouldUploadAll] = await finished;
if (!shouldContinue) break;
if (shouldUploadAll) {
uploadAll = true;
}
}
promBefore = doMaybeLocalRoomAction(
roomId,
(actualRoomId) =>
this.sendContentToRoom(
file,
actualRoomId,
relation,
matrixClient,
replyToEvent ?? undefined,
loopPromiseBefore,
),
matrixClient,
);
}
if (replyToEvent) {
// Clear event being replied to
dis.dispatch({
action: "reply_to_event",
event: null,
context,
});
}
// Focus the correct composer
dis.dispatch({
action: Action.FocusSendMessageComposer,
context,
});
}
public getCurrentUploads(relation?: IEventRelation): 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;
return (noRelation || matchingRelation) && !roomUpload.cancelled;
});
}
public cancelUpload(upload: RoomUpload): void {
upload.abort();
dis.dispatch<UploadCanceledPayload>({ action: Action.UploadCanceled, upload });
}
public async sendContentToRoom(
file: File,
roomId: string,
relation: IEventRelation | undefined,
matrixClient: MatrixClient,
replyToEvent: MatrixEvent | undefined,
promBefore?: Promise<any>,
): Promise<void> {
const fileName = file.name || _t("common|attachment");
const content: Omit<MediaEventContent, "info"> & { info: Partial<MediaEventInfo> } = {
body: fileName,
info: {
size: file.size,
},
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, {
includeLegacyFallback: false,
});
}
if (SettingsStore.getValue("Performance.addSendMessageTimingMetadata")) {
decorateStartSendingTime(content);
}
// if we have a mime type for the file, add it to the message metadata
if (file.type) {
content.info.mimetype = file.type;
}
const upload = new RoomUpload(roomId, fileName, relation, file.size);
this.inprogress.push(upload);
dis.dispatch<UploadStartedPayload>({ action: Action.UploadStarted, upload });
function onProgress(progress: UploadProgress): void {
upload.onProgress(progress);
dis.dispatch<UploadProgressPayload>({ action: Action.UploadProgress, upload });
}
try {
if (file.type.startsWith("image/")) {
content.msgtype = MsgType.Image;
try {
const imageInfo = await infoForImageFile(matrixClient, roomId, file);
Object.assign(content.info, imageInfo);
} catch (e) {
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) {
content.msgtype = MsgType.Audio;
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);
Object.assign(content.info, videoInfo);
} catch (e) {
// Failed to thumbnail, fall back to uploading an m.file
logger.error(e);
content.msgtype = MsgType.File;
}
} else {
content.msgtype = MsgType.File;
}
if (upload.cancelled) throw new UploadCanceledError();
const result = await uploadFile(matrixClient, roomId, file, onProgress, upload.abortController);
content.file = result.file;
content.url = result.url;
if (upload.cancelled) throw new UploadCanceledError();
// Await previous message being sent into the room
if (promBefore) await promBefore;
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 ?? null, content as MediaEventContent);
if (SettingsStore.getValue("Performance.addSendMessageTimingMetadata")) {
sendRoundTripMetric(matrixClient, roomId, response.event_id);
}
dis.dispatch<UploadFinishedPayload>({ action: Action.UploadFinished, upload });
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 instanceof HTTPError && error.httpStatus === 413) {
this.mediaConfig = null;
}
if (!upload.cancelled) {
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"),
description: desc,
});
dis.dispatch<UploadErrorPayload>({ action: Action.UploadFailed, upload, error });
}
} finally {
removeElement(this.inprogress, (e) => e.promise === upload.promise);
}
}
private isFileSizeAcceptable(file: File): boolean {
if (
this.mediaConfig !== null &&
this.mediaConfig["m.upload.size"] !== undefined &&
file.size > this.mediaConfig["m.upload.size"]
) {
return false;
}
return true;
}
private ensureMediaConfigFetched(matrixClient: MatrixClient): Promise<void> {
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;
});
}
public static sharedInstance(): ContentMessages {
if (window.mxContentMessages === undefined) {
window.mxContentMessages = new ContentMessages();
}
return window.mxContentMessages;
}
}

362
src/DateUtils.ts Normal file
View File

@@ -0,0 +1,362 @@
/*
Copyright 2024 New Vector Ltd.
Copyright 2022 The Matrix.org Foundation C.I.C.
Copyright 2017 Vector Creations Ltd
Copyright 2015, 2016 OpenMarket Ltd
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only
Please see LICENSE files in the repository root for full details.
*/
import { Optional } from "matrix-events-sdk";
import { _t, getUserLanguage } from "./languageHandler";
import { getUserTimezone } from "./TimezoneHandler";
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));
}
/**
* 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)));
}
// 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",
};
}
/**
* 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();
if (date.toDateString() === now.toDateString()) {
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",
timeZone: getUserTimezone(),
}).format(date);
} else if (now.getFullYear() === date.getFullYear()) {
return new Intl.DateTimeFormat(_locale, {
...getTwelveHourOptions(showTwelveHour),
weekday: "short",
month: "short",
day: "numeric",
hour: "numeric",
minute: "2-digit",
timeZone: getUserTimezone(),
}).format(date);
}
return formatFullDate(date, showTwelveHour, false, _locale);
}
/**
* 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",
timeZone: getUserTimezone(),
}).format(date);
}
/**
* 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,
timeZone: getUserTimezone(),
}).format(date);
}
/**
* 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}`;
}
/**
* 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",
timeZone: getUserTimezone(),
}).format(date);
}
/**
* 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",
timeZone: getUserTimezone(),
}).format(date);
}
export function formatSeconds(inSeconds: number): string {
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;
}
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()) <= 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: Optional<Date>, nextEventDate: Optional<Date>): boolean {
if (!nextEventDate || !prevEventDate) {
return false;
}
// Return early for events that are > 24h apart
if (!withinPast24Hours(prevEventDate, nextEventDate)) {
return true;
}
// Compare weekdays
return prevEventDate.getDay() !== nextEventDate.getDay();
}
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)
* @param date The date to format.
* @returns The date string in ISO format.
*/
export function formatFullDateNoDayISO(date: Date): string {
return date.toISOString();
}
/**
* 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",
timeZone: getUserTimezone(),
}).format(date);
}
export function formatRelativeTime(date: Date, showTwelveHour = false): string {
const now = new Date();
if (withinCurrentDay(date, now)) {
return formatTime(date, showTwelveHour);
} else {
const months = getMonthsArray();
let relativeDate = `${months[date.getMonth()]} ${date.getDate()}`;
if (!withinCurrentYear(date, now)) {
relativeDate += `, ${date.getFullYear()}`;
}
return relativeDate;
}
}
/**
* 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
* i.e. 23:13:57 -> 23h, 24:13:57 -> 1d, 44:56:56 -> 2d
*/
export function formatDuration(durationMs: number): string {
if (durationMs >= DAY_MS) {
return _t("time|short_days", { value: Math.round(durationMs / DAY_MS) });
}
if (durationMs >= HOUR_MS) {
return _t("time|short_hours", { value: Math.round(durationMs / HOUR_MS) });
}
if (durationMs >= MINUTE_MS) {
return _t("time|short_minutes", { value: Math.round(durationMs / MINUTE_MS) });
}
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",
timeZone: getUserTimezone(),
}).format(timestamp);

View File

@@ -0,0 +1,430 @@
/*
Copyright 2024 New Vector Ltd.
Copyright 2018-2021 The Matrix.org Foundation C.I.C.
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only
Please see LICENSE files in the repository root for full details.
*/
import { ScalableBloomFilter } from "bloom-filters";
import { HttpApiEvent, MatrixClient, MatrixEventEvent, MatrixEvent } from "matrix-js-sdk/src/matrix";
import { Error as ErrorEvent } from "@matrix-org/analytics-events/types/typescript/Error";
import { DecryptionFailureCode, CryptoEvent } from "matrix-js-sdk/src/crypto-api";
import { PosthogAnalytics } from "./PosthogAnalytics";
import { MEGOLM_ENCRYPTION_ALGORITHM } from "./utils/crypto";
/** The key that we use to store the `reportedEvents` bloom filter in localstorage */
const DECRYPTION_FAILURE_STORAGE_KEY = "mx_decryption_failure_event_ids";
export class DecryptionFailure {
/**
* The time between our initial failure to decrypt and our successful
* decryption (if we managed to decrypt).
*/
public timeToDecryptMillis?: number;
public constructor(
public readonly failedEventId: string,
public readonly errorCode: DecryptionFailureCode,
/**
* The time that we failed to decrypt the event. If we failed to decrypt
* multiple times, this will be the time of the first failure.
*/
public readonly ts: number,
/**
* Is the sender on a different server from us?
*/
public readonly isFederated: boolean | undefined,
/**
* Was the failed event ever visible to the user?
*/
public wasVisibleToUser: boolean,
/**
* Has the user verified their own cross-signing identity, as of the most
* recent decryption attempt for this event?
*/
public userTrustsOwnIdentity: boolean | undefined,
) {}
}
type ErrorCode = ErrorEvent["name"];
/** Properties associated with decryption errors, for classifying the error. */
export type ErrorProperties = Omit<ErrorEvent, "eventName" | "domain" | "name" | "context">;
type TrackingFn = (trackedErrCode: ErrorCode, rawError: string, properties: ErrorProperties) => void;
export type ErrCodeMapFn = (errcode: DecryptionFailureCode) => ErrorCode;
export class DecryptionFailureTracker {
private static internalInstance = new DecryptionFailureTracker(
(errorCode, rawError, properties) => {
const event: ErrorEvent = {
eventName: "Error",
domain: "E2EE",
name: errorCode,
context: `mxc_crypto_error_type_${rawError}`,
...properties,
};
PosthogAnalytics.instance.trackEvent<ErrorEvent>(event);
},
(errorCode) => {
// Map JS-SDK error codes to tracker codes for aggregation
switch (errorCode) {
case DecryptionFailureCode.MEGOLM_UNKNOWN_INBOUND_SESSION_ID:
case DecryptionFailureCode.MEGOLM_KEY_WITHHELD:
return "OlmKeysNotSentError";
case DecryptionFailureCode.MEGOLM_KEY_WITHHELD_FOR_UNVERIFIED_DEVICE:
return "RoomKeysWithheldForUnverifiedDevice";
case DecryptionFailureCode.OLM_UNKNOWN_MESSAGE_INDEX:
return "OlmIndexError";
case DecryptionFailureCode.HISTORICAL_MESSAGE_NO_KEY_BACKUP:
case DecryptionFailureCode.HISTORICAL_MESSAGE_BACKUP_UNCONFIGURED:
case DecryptionFailureCode.HISTORICAL_MESSAGE_WORKING_BACKUP:
return "HistoricalMessage";
case DecryptionFailureCode.HISTORICAL_MESSAGE_USER_NOT_JOINED:
return "ExpectedDueToMembership";
default:
return "UnknownError";
}
},
);
/** Map of event IDs to `DecryptionFailure` items.
*
* Every `CHECK_INTERVAL_MS`, this map is checked for failures that happened >
* `MAXIMUM_LATE_DECRYPTION_PERIOD` ago (considered undecryptable), or
* decryptions that took > `GRACE_PERIOD_MS` (considered late decryptions).
*
* Any such events are then reported via the `TrackingFn`.
*/
public failures: Map<string, DecryptionFailure> = new Map();
/** Set of event IDs that have been visible to the user.
*
* This will only contain events that are not already in `reportedEvents`.
*/
public visibleEvents: Set<string> = new Set();
/** Bloom filter tracking event IDs of failures that were reported previously */
private reportedEvents: ScalableBloomFilter = new ScalableBloomFilter();
/** Set to an interval ID when `start` is called */
public checkInterval: number | null = null;
public trackInterval: number | null = null;
/** Call `checkFailures` every `CHECK_INTERVAL_MS`. */
public static CHECK_INTERVAL_MS = 40000;
/** If the event is successfully decrypted in less than 4s, we don't report. */
public static GRACE_PERIOD_MS = 4000;
/** Maximum time for an event to be decrypted to be considered a late
* decryption. If it takes longer, we consider it undecryptable. */
public static MAXIMUM_LATE_DECRYPTION_PERIOD = 60000;
/** Properties that will be added to all reported events (mainly reporting
* information about the Matrix client). */
private baseProperties?: ErrorProperties = {};
/** The user's domain (homeserver name). */
private userDomain?: string;
/** Whether the user has verified their own cross-signing keys. */
private userTrustsOwnIdentity: boolean | undefined = undefined;
/** Whether we are currently checking our own verification status. */
private checkingVerificationStatus: boolean = false;
/** Whether we should retry checking our own verification status after we're
* done our current check. i.e. we got notified that our keys changed while
* we were already checking, so the result could be out of date. */
private retryVerificationStatus: boolean = false;
/**
* Create a new DecryptionFailureTracker.
*
* Call `start(client)` to start the tracker. The tracker will listen for
* decryption events on the client and track decryption failures, and will
* automatically stop tracking when the client logs out.
*
* @param {function} fn The tracking function, which will be called when failures
* are tracked. The function should have a signature `(trackedErrorCode, rawError, properties) => {...}`,
* where `errorCode` matches the output of `errorCodeMapFn`, `rawError` is the original
* error (that is, the input to `errorCodeMapFn`), and `properties` is a map of the
* error properties for classifying the error.
*
* @param {function} errorCodeMapFn The function used to map decryption failure reason codes to the
* `trackedErrorCode`.
*
* @param {boolean} checkReportedEvents Check if we have already reported an event.
* Defaults to `true`. This is only used for tests, to avoid possible false positives from
* the Bloom filter. This should be set to `false` for all tests except for those
* that specifically test the `reportedEvents` functionality.
*/
private constructor(
private readonly fn: TrackingFn,
private readonly errorCodeMapFn: ErrCodeMapFn,
private readonly checkReportedEvents: boolean = true,
) {
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");
}
}
public static get instance(): DecryptionFailureTracker {
return DecryptionFailureTracker.internalInstance;
}
private loadReportedEvents(): void {
const storedFailures = localStorage.getItem(DECRYPTION_FAILURE_STORAGE_KEY);
if (storedFailures) {
this.reportedEvents = ScalableBloomFilter.fromJSON(JSON.parse(storedFailures));
} else {
this.reportedEvents = new ScalableBloomFilter();
}
}
private saveReportedEvents(): void {
localStorage.setItem(DECRYPTION_FAILURE_STORAGE_KEY, JSON.stringify(this.reportedEvents.saveAsJSON()));
}
/** Callback for when an event is decrypted.
*
* This function is called by our `MatrixEventEvent.Decrypted` event
* handler after a decryption attempt on an event, whether the decryption
* is successful or not.
*
* @param e the event that was decrypted
*
* @param nowTs the current timestamp
*/
private eventDecrypted(e: MatrixEvent, nowTs: number): void {
// for now we only track megolm decryption failures
if (e.getWireContent().algorithm != MEGOLM_ENCRYPTION_ALGORITHM) {
return;
}
const errCode = e.decryptionFailureReason;
if (errCode === null) {
// Could be an event in the failures, remove it
this.removeDecryptionFailuresForEvent(e, nowTs);
return;
}
const eventId = e.getId()!;
// if it's already reported, we don't need to do anything
if (this.reportedEvents.has(eventId) && this.checkReportedEvents) {
return;
}
// if we already have a record of this event, use the previously-recorded timestamp
const failure = this.failures.get(eventId);
const ts = failure ? failure.ts : nowTs;
const sender = e.getSender();
const senderDomain = sender?.replace(/^.*?:/, "");
let isFederated: boolean | undefined;
if (this.userDomain !== undefined && senderDomain !== undefined) {
isFederated = this.userDomain !== senderDomain;
}
const wasVisibleToUser = this.visibleEvents.has(eventId);
this.failures.set(
eventId,
new DecryptionFailure(eventId, errCode, ts, isFederated, wasVisibleToUser, this.userTrustsOwnIdentity),
);
}
public addVisibleEvent(e: MatrixEvent): void {
const eventId = e.getId()!;
// if it's already reported, we don't need to do anything
if (this.reportedEvents.has(eventId) && this.checkReportedEvents) {
return;
}
// if we've already marked the event as a failure, mark it as visible
// in the failure object
const failure = this.failures.get(eventId);
if (failure) {
failure.wasVisibleToUser = true;
}
this.visibleEvents.add(eventId);
}
public removeDecryptionFailuresForEvent(e: MatrixEvent, nowTs: number): void {
const eventId = e.getId()!;
const failure = this.failures.get(eventId);
if (failure) {
this.failures.delete(eventId);
const timeToDecryptMillis = nowTs - failure.ts;
if (timeToDecryptMillis < DecryptionFailureTracker.GRACE_PERIOD_MS) {
// the event decrypted on time, so we don't need to report it
return;
} else if (timeToDecryptMillis <= DecryptionFailureTracker.MAXIMUM_LATE_DECRYPTION_PERIOD) {
// The event is a late decryption, so store the time it took.
// If the time to decrypt is longer than
// MAXIMUM_LATE_DECRYPTION_PERIOD, we consider the event as
// undecryptable, and leave timeToDecryptMillis undefined
failure.timeToDecryptMillis = timeToDecryptMillis;
}
this.reportFailure(failure);
}
}
private async handleKeysChanged(client: MatrixClient): Promise<void> {
if (this.checkingVerificationStatus) {
// Flag that we'll need to do another check once the current check completes.
this.retryVerificationStatus = true;
return;
}
this.checkingVerificationStatus = true;
try {
do {
this.retryVerificationStatus = false;
this.userTrustsOwnIdentity = (
await client.getCrypto()!.getUserVerificationStatus(client.getUserId()!)
).isCrossSigningVerified();
} while (this.retryVerificationStatus);
} finally {
this.checkingVerificationStatus = false;
}
}
/**
* Start checking for and tracking failures.
*/
public async start(client: MatrixClient): Promise<void> {
this.loadReportedEvents();
await this.calculateClientProperties(client);
this.registerHandlers(client);
this.checkInterval = window.setInterval(
() => this.checkFailures(Date.now()),
DecryptionFailureTracker.CHECK_INTERVAL_MS,
);
}
private async calculateClientProperties(client: MatrixClient): Promise<void> {
const baseProperties: ErrorProperties = {};
this.baseProperties = baseProperties;
this.userDomain = client.getDomain() ?? undefined;
if (this.userDomain === "matrix.org") {
baseProperties.isMatrixDotOrg = true;
} else if (this.userDomain !== undefined) {
baseProperties.isMatrixDotOrg = false;
}
const crypto = client.getCrypto();
if (crypto) {
const version = crypto.getVersion();
if (version.startsWith("Rust SDK")) {
baseProperties.cryptoSDK = "Rust";
} else {
baseProperties.cryptoSDK = "Legacy";
}
this.userTrustsOwnIdentity = (
await crypto.getUserVerificationStatus(client.getUserId()!)
).isCrossSigningVerified();
}
}
private registerHandlers(client: MatrixClient): void {
// After the client attempts to decrypt an event, we examine it to see
// if it needs to be reported.
const decryptedHandler = (e: MatrixEvent): void => this.eventDecrypted(e, Date.now());
// When our keys change, we check if the cross-signing keys are now trusted.
const keysChangedHandler = (): void => {
this.handleKeysChanged(client).catch((e) => {
console.log("Error handling KeysChanged event", e);
});
};
// When logging out, remove our handlers and destroy state
const loggedOutHandler = (): void => {
client.removeListener(MatrixEventEvent.Decrypted, decryptedHandler);
client.removeListener(CryptoEvent.KeysChanged, keysChangedHandler);
client.removeListener(HttpApiEvent.SessionLoggedOut, loggedOutHandler);
this.stop();
};
client.on(MatrixEventEvent.Decrypted, decryptedHandler);
client.on(CryptoEvent.KeysChanged, keysChangedHandler);
client.on(HttpApiEvent.SessionLoggedOut, loggedOutHandler);
}
/**
* Clear state and stop checking for and tracking failures.
*/
private stop(): void {
if (this.checkInterval) clearInterval(this.checkInterval);
if (this.trackInterval) clearInterval(this.trackInterval);
this.userTrustsOwnIdentity = undefined;
this.failures = new Map();
this.visibleEvents = new Set();
}
/**
* Mark failures as undecryptable or late. Only mark one failure per event ID.
*
* @param {number} nowTs the timestamp that represents the time now.
*/
public checkFailures(nowTs: number): void {
const failuresNotReady: Map<string, DecryptionFailure> = new Map();
for (const [eventId, failure] of this.failures) {
if (
failure.timeToDecryptMillis !== undefined ||
nowTs > failure.ts + DecryptionFailureTracker.MAXIMUM_LATE_DECRYPTION_PERIOD
) {
// we report failures under two conditions:
// - if `timeToDecryptMillis` is set, we successfully decrypted
// the event, but we got the key late. We report it so that we
// have the late decrytion stats.
// - we haven't decrypted yet and it's past the time for it to be
// considered a "late" decryption, so we count it as
// undecryptable.
this.reportFailure(failure);
} else {
// the event isn't old enough, so we still need to keep track of it
failuresNotReady.set(eventId, failure);
}
}
this.failures = failuresNotReady;
this.saveReportedEvents();
}
/**
* If there are failures that should be tracked, call the given trackDecryptionFailure
* function with the failures that should be tracked.
*/
private reportFailure(failure: DecryptionFailure): void {
const errorCode = failure.errorCode;
const trackedErrorCode = this.errorCodeMapFn(errorCode);
const properties: ErrorProperties = {
timeToDecryptMillis: failure.timeToDecryptMillis ?? -1,
wasVisibleToUser: failure.wasVisibleToUser,
};
if (failure.isFederated !== undefined) {
properties.isFederated = failure.isFederated;
}
if (failure.userTrustsOwnIdentity !== undefined) {
properties.userTrustsOwnIdentity = failure.userTrustsOwnIdentity;
}
if (this.baseProperties) {
Object.assign(properties, this.baseProperties);
}
this.fn(trackedErrorCode, errorCode, properties);
this.reportedEvents.add(failure.failedEventId);
// once we've added it to reportedEvents, we won't check
// visibleEvents for it any more
this.visibleEvents.delete(failure.failedEventId);
}
}

503
src/DeviceListener.ts Normal file
View File

@@ -0,0 +1,503 @@
/*
Copyright 2024 New Vector Ltd.
Copyright 2020 The Matrix.org Foundation C.I.C.
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only
Please see LICENSE files in the repository root for full details.
*/
import {
MatrixEvent,
ClientEvent,
EventType,
MatrixClient,
RoomStateEvent,
SyncState,
ClientStoppedError,
} from "matrix-js-sdk/src/matrix";
import { logger } from "matrix-js-sdk/src/logger";
import { CryptoEvent, KeyBackupInfo } from "matrix-js-sdk/src/crypto-api";
import { CryptoSessionStateChange } from "@matrix-org/analytics-events/types/typescript/CryptoSessionStateChange";
import { PosthogAnalytics } from "./PosthogAnalytics";
import dis from "./dispatcher/dispatcher";
import {
hideToast as hideBulkUnverifiedSessionsToast,
showToast as showBulkUnverifiedSessionsToast,
} from "./toasts/BulkUnverifiedSessionsToast";
import {
hideToast as hideSetupEncryptionToast,
Kind as SetupKind,
showToast as showSetupEncryptionToast,
} from "./toasts/SetupEncryptionToast";
import {
hideToast as hideUnverifiedSessionsToast,
showToast as showUnverifiedSessionsToast,
} from "./toasts/UnverifiedSessionToast";
import { accessSecretStorage, isSecretStorageBeingAccessed } from "./SecurityManager";
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 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;
// 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 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 = 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;
// Remember the current analytics state to avoid sending the same event multiple times.
private analyticsVerificationState?: string;
private analyticsRecoveryState?: string;
public static sharedInstance(): DeviceListener {
if (!window.mxDeviceListener) window.mxDeviceListener = new DeviceListener();
return window.mxDeviceListener;
}
public start(matrixClient: MatrixClient): void {
this.running = true;
this.client = matrixClient;
this.client.on(CryptoEvent.DevicesUpdated, this.onDevicesUpdated);
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",
null,
this.onRecordClientInformationSettingChange,
);
this.dispatcherRef = dis.register(this.onAction);
this.recheck();
this.updateClientInformation();
}
public stop(): void {
this.running = false;
if (this.client) {
this.client.removeListener(CryptoEvent.DevicesUpdated, this.onDevicesUpdated);
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 = undefined;
}
this.dismissed.clear();
this.dismissedThisDeviceToast = false;
this.keyBackupInfo = null;
this.keyBackupFetchedAt = null;
this.keyBackupStatusChecked = false;
this.ourDeviceIdsAtStart = null;
this.displayingToastsForDeviceIds = new Set();
this.client = undefined;
}
/**
* Dismiss notifications about our own unverified devices
*
* @param {String[]} deviceIds List of device IDs to dismiss notifications for
*/
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);
}
this.recheck();
}
public dismissEncryptionSetup(): void {
this.dismissedThisDeviceToast = true;
this.recheck();
}
private async ensureDeviceIdsAtStartPopulated(): Promise<void> {
if (this.ourDeviceIdsAtStart === null) {
this.ourDeviceIdsAtStart = await this.getDeviceIds();
}
}
/** 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 onDevicesUpdated = 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 = this.client.getSafeUserId();
if (users.includes(myUserId)) await this.ensureDeviceIdsAtStartPopulated();
this.recheck();
};
private onUserTrustStatusChanged = (userId: string): void => {
if (!this.client) return;
if (userId !== this.client.getUserId()) return;
this.recheck();
};
private onCrossSingingKeysChanged = (): void => {
this.recheck();
};
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"
) {
this.recheck();
}
};
private onSync = (state: SyncState, prevState: SyncState | null): void => {
if (state === "PREPARED" && prevState === null) {
this.recheck();
}
};
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
// encrypted room. This also catches encrypted room creation as well.
this.recheck();
};
private onAction = ({ action }: ActionPayload): void => {
if (action !== Action.OnLoggedIn) return;
this.recheck();
this.updateClientInformation();
};
/**
* 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(): 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 = this.client;
return cli?.getRooms().some((r) => cli.isRoomEncrypted(r.roomId)) ?? false;
}
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);
}
});
}
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;
// 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 crypto.isCrossSigningReady();
const secretStorageReady = await crypto.isSecretStorageReady();
const allSystemsReady = crossSigningReady && secretStorageReady;
await this.reportCryptoSessionStateToAnalytics(cli);
if (this.dismissedThisDeviceToast || allSystemsReady) {
hideSetupEncryptionToast();
this.checkKeyBackupStatus();
} else if (this.shouldShowSetupEncryptionToast()) {
// make sure our keys are finished downloading
await crypto.getUserDeviceInfo([cli.getSafeUserId()]);
// cross signing isn't enabled - nag to enable it
// There are 3 different toasts for:
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();
} else {
const backupInfo = await this.getKeyBackupInfo();
if (backupInfo) {
// No cross-signing on account but key backup available (upgrade encryption)
showSetupEncryptionToast(SetupKind.UPGRADE_ENCRYPTION);
} else {
// No cross-signing or key backup on account (set up encryption)
await cli.waitForClientWellKnown();
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();
accessSecretStorage();
} else {
showSetupEncryptionToast(SetupKind.SET_UP_ENCRYPTION);
}
}
}
}
// This needs to be done after awaiting on getUserDeviceInfo() above, so
// we make sure we get the devices after the fetch is done.
await this.ensureDeviceIdsAtStartPopulated();
// Unverified devices that were there last time the app ran
// (technically could just be a boolean: we don't actually
// need to remember the device IDs, but for the sake of
// symmetry...).
const oldUnverifiedDeviceIds = new Set<string>();
// 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 = await this.getDeviceIds();
for (const deviceId of devices) {
if (deviceId === cli.deviceId) continue;
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(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(","));
const isBulkUnverifiedSessionsReminderSnoozed = isBulkUnverifiedDeviceReminderSnoozed();
// Display or hide the batch toast for old unverified sessions
// don't show the toast if the current device is unverified
if (
oldUnverifiedDeviceIds.size > 0 &&
isCurrentDeviceTrusted &&
this.enableBulkUnverifiedSessionsReminder &&
!isBulkUnverifiedSessionsReminderSnoozed
) {
showBulkUnverifiedSessionsToast(oldUnverifiedDeviceIds);
} else {
hideBulkUnverifiedSessionsToast();
}
// Show toasts for new unverified devices if they aren't already there
for (const deviceId of newUnverifiedDeviceIds) {
showUnverifiedSessionsToast(deviceId);
}
// ...and hide any we don't need any more
for (const deviceId of this.displayingToastsForDeviceIds) {
if (!newUnverifiedDeviceIds.has(deviceId)) {
logger.debug("Hiding unverified session toast for " + deviceId);
hideUnverifiedSessionsToast(deviceId);
}
}
this.displayingToastsForDeviceIds = newUnverifiedDeviceIds;
}
/**
* Reports current recovery state to analytics.
* Checks if the session is verified and if the recovery is correctly set up (i.e all secrets known locally and in 4S).
* @param cli - the matrix client
* @private
*/
private async reportCryptoSessionStateToAnalytics(cli: MatrixClient): Promise<void> {
const crypto = cli.getCrypto()!;
const secretStorageReady = await crypto.isSecretStorageReady();
const crossSigningStatus = await crypto.getCrossSigningStatus();
const backupInfo = await this.getKeyBackupInfo();
const is4SEnabled = (await cli.secretStorage.getDefaultKeyId()) != null;
const deviceVerificationStatus = await crypto.getDeviceVerificationStatus(cli.getUserId()!, cli.getDeviceId()!);
const verificationState =
deviceVerificationStatus?.signedByOwner && deviceVerificationStatus?.crossSigningVerified
? "Verified"
: "NotVerified";
let recoveryState: "Disabled" | "Enabled" | "Incomplete";
if (!is4SEnabled) {
recoveryState = "Disabled";
} else {
const allCrossSigningSecretsCached =
crossSigningStatus.privateKeysCachedLocally.masterKey &&
crossSigningStatus.privateKeysCachedLocally.selfSigningKey &&
crossSigningStatus.privateKeysCachedLocally.userSigningKey;
if (backupInfo != null) {
// There is a backup. Check that all secrets are stored in 4S and known locally.
// If they are not, recovery is incomplete.
const backupPrivateKeyIsInCache = (await crypto.getSessionBackupPrivateKey()) != null;
if (secretStorageReady && allCrossSigningSecretsCached && backupPrivateKeyIsInCache) {
recoveryState = "Enabled";
} else {
recoveryState = "Incomplete";
}
} else {
// No backup. Just consider cross-signing secrets.
if (secretStorageReady && allCrossSigningSecretsCached) {
recoveryState = "Enabled";
} else {
recoveryState = "Incomplete";
}
}
}
if (this.analyticsVerificationState === verificationState && this.analyticsRecoveryState === recoveryState) {
// No changes, no need to send the event nor update the user properties
return;
}
this.analyticsRecoveryState = recoveryState;
this.analyticsVerificationState = verificationState;
// Update user properties
PosthogAnalytics.instance.setProperty("recoveryState", recoveryState);
PosthogAnalytics.instance.setProperty("verificationState", verificationState);
PosthogAnalytics.instance.trackEvent<CryptoSessionStateChange>({
eventName: "CryptoSessionState",
verificationState: verificationState,
recoveryState: recoveryState,
});
}
/**
* 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;
}
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 (!activeKeyBackupVersion) {
dis.dispatch({ action: Action.ReportKeyBackupNotEnabled });
}
};
private keyBackupStatusChecked = false;
private onRecordClientInformationSettingChange: CallbackFn = (
_originalSettingName,
_roomId,
_level,
_newLevel,
newValue,
) => {
const prevValue = this.shouldRecordClientInformation;
this.shouldRecordClientInformation = !!newValue;
if (this.shouldRecordClientInformation !== prevValue) {
this.updateClientInformation();
}
};
private updateClientInformation = async (): Promise<void> => {
if (!this.client) return;
try {
if (this.shouldRecordClientInformation) {
await recordClientInformation(this.client, SdkConfig.get(), PlatformPeg.get() ?? undefined);
} else {
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);
}
};
}

78
src/DraftCleaner.ts Normal file
View File

@@ -0,0 +1,78 @@
/*
Copyright 2024 New Vector Ltd.
Copyright 2024 The Matrix.org Foundation C.I.C.
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only
Please see LICENSE files in the repository root for full details.
*/
import { logger } from "matrix-js-sdk/src/logger";
import { MatrixClientPeg } from "./MatrixClientPeg";
import { EDITOR_STATE_STORAGE_PREFIX } from "./components/views/rooms/SendMessageComposer";
import { WYSIWYG_EDITOR_STATE_STORAGE_PREFIX } from "./components/views/rooms/MessageComposer";
// The key used to persist the the timestamp we last cleaned up drafts
export const DRAFT_LAST_CLEANUP_KEY = "mx_draft_cleanup";
// The period of time we wait between cleaning drafts
export const DRAFT_CLEANUP_PERIOD = 1000 * 60 * 60 * 24 * 30;
/**
* Checks if `DRAFT_CLEANUP_PERIOD` has expired, if so, deletes any stord editor drafts that exist for rooms that are not in the known list.
*/
export function cleanUpDraftsIfRequired(): void {
if (!shouldCleanupDrafts()) {
return;
}
logger.debug(`Cleaning up editor drafts...`);
cleaupDrafts();
try {
localStorage.setItem(DRAFT_LAST_CLEANUP_KEY, String(Date.now()));
} catch (error) {
logger.error("Failed to persist draft cleanup key", error);
}
}
/**
*
* @returns {bool} True if the timestamp has not been persisted or the `DRAFT_CLEANUP_PERIOD` has expired.
*/
function shouldCleanupDrafts(): boolean {
try {
const lastCleanupTimestamp = localStorage.getItem(DRAFT_LAST_CLEANUP_KEY);
if (!lastCleanupTimestamp) {
return true;
}
const parsedTimestamp = Number.parseInt(lastCleanupTimestamp || "", 10);
if (!Number.isInteger(parsedTimestamp)) {
return true;
}
return Date.now() > parsedTimestamp + DRAFT_CLEANUP_PERIOD;
} catch (error) {
return true;
}
}
/**
* Clear all drafts for the CIDER and WYSIWYG editors if the room does not exist in the known rooms.
*/
function cleaupDrafts(): void {
for (let i = 0; i < localStorage.length; i++) {
const keyName = localStorage.key(i);
if (!keyName) continue;
let roomId: string | undefined = undefined;
if (keyName.startsWith(EDITOR_STATE_STORAGE_PREFIX)) {
roomId = keyName.slice(EDITOR_STATE_STORAGE_PREFIX.length).split("_$")[0];
}
if (keyName.startsWith(WYSIWYG_EDITOR_STATE_STORAGE_PREFIX)) {
roomId = keyName.slice(WYSIWYG_EDITOR_STATE_STORAGE_PREFIX.length).split("_$")[0];
}
if (!roomId) continue;
// Remove the prefix and the optional event id suffix to leave the room id
const room = MatrixClientPeg.safeGet().getRoom(roomId);
if (!room) {
logger.debug(`Removing draft for unknown room with key ${keyName}`);
localStorage.removeItem(keyName);
}
}
}

13
src/Editing.ts Normal file
View File

@@ -0,0 +1,13 @@
/*
Copyright 2024 New Vector Ltd.
Copyright 2022 The Matrix.org Foundation C.I.C.
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only
Please see LICENSE files in the repository root for full details.
*/
import { TimelineRenderingType } from "./contexts/RoomContext";
export const editorRoomKey = (roomId: string, context: TimelineRenderingType): string =>
`mx_edit_room_${roomId}_${context}`;
export const editorStateKey = (eventId: string): string => `mx_edit_state_${eventId}`;

583
src/HtmlUtils.tsx Normal file
View File

@@ -0,0 +1,583 @@
/*
Copyright 2024 New Vector Ltd.
Copyright 2019 Michael Telatynski <7t3chguy@gmail.com>
Copyright 2019 The Matrix.org Foundation C.I.C.
Copyright 2017, 2018 New Vector Ltd
Copyright 2015, 2016 OpenMarket Ltd
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only
Please see LICENSE files in the repository root for full details.
*/
import React, { LegacyRef, ReactNode } from "react";
import sanitizeHtml from "sanitize-html";
import classNames from "classnames";
import katex from "katex";
import { decode } from "html-entities";
import { IContent } from "matrix-js-sdk/src/matrix";
import { Optional } from "matrix-events-sdk";
import escapeHtml from "escape-html";
import { getEmojiFromUnicode } from "@matrix-org/emojibase-bindings";
import { IExtendedSanitizeOptions } from "./@types/sanitize-html";
import SettingsStore from "./settings/SettingsStore";
import { stripHTMLReply, stripPlainReply } from "./utils/Reply";
import { PERMITTED_URL_SCHEMES } from "./utils/UrlUtils";
import { sanitizeHtmlParams, transformTags } from "./Linkify";
import { graphemeSegmenter } from "./utils/strings";
export { Linkify, linkifyElement, linkifyAndSanitizeHtml } from "./Linkify";
// Anything outside the basic multilingual plane will be a surrogate pair
const SURROGATE_PAIR_PATTERN = /([\ud800-\udbff])([\udc00-\udfff])/;
// And there a bunch more symbol characters that emojibase has within the
// BMP, so this includes the ranges from 'letterlike symbols' to
// 'miscellaneous symbols and arrows' which should catch all of them
// (with plenty of false positives, but that's OK)
const SYMBOL_PATTERN = /([\u2100-\u2bff])/;
// Regex pattern for non-emoji characters that can appear in an "all-emoji" message
// (Zero-Width Space, other whitespace)
const EMOJI_SEPARATOR_REGEX = /[\u200B\s]/g;
// Regex for emoji. This includes any RGI_Emoji sequence followed by an optional
// emoji presentation VS (U+FE0F), but not those sequences that are followed by
// a text presentation VS (U+FE0E). We also count lone regional indicators
// (U+1F1E6-U+1F1FF). Technically this regex produces false negatives for emoji
// followed by U+FE0E when the emoji doesn't have a text variant, but in
// practice this doesn't matter.
export const EMOJI_REGEX = (() => {
try {
// Per our support policy, v mode is available to us, but we still don't
// want the app to completely crash on older platforms. We use the
// constructor here to avoid a syntax error on such platforms.
return new RegExp("\\p{RGI_Emoji}(?!\\uFE0E)(?:(?<!\\uFE0F)\\uFE0F)?|[\\u{1f1e6}-\\u{1f1ff}]", "v");
} catch (_e) {
// v mode not supported; fall back to matching nothing
return /(?!)/;
}
})();
const BIGEMOJI_REGEX = (() => {
try {
return new RegExp(`^(${EMOJI_REGEX.source})+$`, "iv");
} catch (_e) {
// Fall back, just like for EMOJI_REGEX
return /(?!)/;
}
})();
/*
* Return true if the given string contains emoji
* Uses a much, much simpler regex than emojibase's so will give false
* positives, but useful for fast-path testing strings to see if they
* need emojification.
*/
function mightContainEmoji(str?: string): boolean {
return !!str && (SURROGATE_PAIR_PATTERN.test(str) || SYMBOL_PATTERN.test(str));
}
/**
* Returns the shortcode for an emoji character.
*
* @param {String} char The emoji character
* @return {String} The shortcode (such as :thumbup:)
*/
export function unicodeToShortcode(char: string): string {
const shortcodes = getEmojiFromUnicode(char)?.shortcodes;
return shortcodes?.length ? `:${shortcodes[0]}:` : "";
}
/*
* Given an untrusted HTML string, return a React node with an sanitized version
* of that HTML.
*/
export function sanitizedHtmlNode(insaneHtml: string): ReactNode {
const saneHtml = sanitizeHtml(insaneHtml, sanitizeHtmlParams);
return <div dangerouslySetInnerHTML={{ __html: saneHtml }} dir="auto" />;
}
export function getHtmlText(insaneHtml: string): string {
return sanitizeHtml(insaneHtml, {
allowedTags: [],
allowedAttributes: {},
selfClosing: [],
allowedSchemes: [],
disallowedTagsMode: "discard",
});
}
/**
* Tests if a URL from an untrusted source may be safely put into the DOM
* The biggest threat here is javascript: URIs.
* Note that the HTML sanitiser library has its own internal logic for
* doing this, to which we pass the same list of schemes. This is used in
* other places we need to sanitise URLs.
* @return true if permitted, otherwise false
*/
export function isUrlPermitted(inputUrl: string): boolean {
try {
// URL parser protocol includes the trailing colon
return PERMITTED_URL_SCHEMES.includes(new URL(inputUrl).protocol.slice(0, -1));
} catch (e) {
return false;
}
}
// this is the same as the above except with less rewriting
const composerSanitizeHtmlParams: IExtendedSanitizeOptions = {
...sanitizeHtmlParams,
transformTags: {
"code": transformTags["code"],
"*": transformTags["*"],
},
};
// reduced set of allowed tags to avoid turning topics into Myspace
const topicSanitizeHtmlParams: IExtendedSanitizeOptions = {
...sanitizeHtmlParams,
allowedTags: [
"font", // custom to matrix for IRC-style font coloring
"del", // for markdown
"s",
"a",
"sup",
"sub",
"b",
"i",
"u",
"strong",
"em",
"strike",
"br",
"div",
"span",
],
};
abstract class BaseHighlighter<T extends React.ReactNode> {
public constructor(
public highlightClass: string,
public highlightLink?: string,
) {}
/**
* Apply the highlights to a section of text
*
* @param {string} safeSnippet The snippet of text to apply the highlights
* 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.
*
* returns a list of results (strings for HtmlHighligher, react nodes for
* TextHighlighter).
*/
public applyHighlights(safeSnippet: string, safeHighlights: string[]): T[] {
let lastOffset = 0;
let offset: number;
let nodes: T[] = [];
const safeHighlight = safeHighlights[0];
while ((offset = safeSnippet.toLowerCase().indexOf(safeHighlight.toLowerCase(), lastOffset)) >= 0) {
// handle preamble
if (offset > lastOffset) {
const subSnippet = safeSnippet.substring(lastOffset, offset);
nodes = nodes.concat(this.applySubHighlights(subSnippet, safeHighlights));
}
// do highlight. use the original string rather than safeHighlight
// to preserve the original casing.
const endOffset = offset + safeHighlight.length;
nodes.push(this.processSnippet(safeSnippet.substring(offset, endOffset), true));
lastOffset = endOffset;
}
// handle postamble
if (lastOffset !== safeSnippet.length) {
const subSnippet = safeSnippet.substring(lastOffset, undefined);
nodes = nodes.concat(this.applySubHighlights(subSnippet, safeHighlights));
}
return nodes;
}
private applySubHighlights(safeSnippet: string, safeHighlights: string[]): T[] {
if (safeHighlights[1]) {
// recurse into this range to check for the next set of highlight matches
return this.applyHighlights(safeSnippet, safeHighlights.slice(1));
} else {
// no more highlights to be found, just return the unhighlighted string
return [this.processSnippet(safeSnippet, false)];
}
}
protected abstract processSnippet(snippet: string, highlight: boolean): T;
}
class HtmlHighlighter extends BaseHighlighter<string> {
/* highlight the given snippet if required
*
* snippet: content of the span; must have been sanitised
* highlight: true to highlight as a search match
*
* returns an HTML string
*/
protected processSnippet(snippet: string, highlight: boolean): string {
if (!highlight) {
// nothing required here
return snippet;
}
let span = `<span class="${this.highlightClass}">${snippet}</span>`;
if (this.highlightLink) {
span = `<a href="${encodeURI(this.highlightLink)}">${span}</a>`;
}
return span;
}
}
const emojiToHtmlSpan = (emoji: string): string =>
`<span class='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
* in the same <span>.
* @param {string} message the text to format
* @param {boolean} isHtmlMessage whether the message contains HTML
* @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
*/
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)[] = [];
if (!message) return result;
let text = "";
let key = 0;
for (const data of graphemeSegmenter.segment(message)) {
if (EMOJI_REGEX.test(data.segment)) {
if (text) {
result.push(text);
text = "";
}
result.push(emojiToSpan(data.segment, key));
key++;
} else {
text += data.segment;
}
}
if (text) {
result.push(text);
}
return result;
}
interface EventAnalysis {
bodyHasEmoji: boolean;
isHtmlMessage: boolean;
strippedBody: string;
safeBody?: string; // safe, sanitised HTML, preferred over `strippedBody` which is fully plaintext
isFormattedBody: boolean;
}
export interface EventRenderOpts {
highlightLink?: string;
disableBigEmoji?: boolean;
stripReplyFallback?: boolean;
forComposerQuote?: boolean;
}
function analyseEvent(content: IContent, highlights: Optional<string[]>, opts: EventRenderOpts = {}): EventAnalysis {
let sanitizeParams = sanitizeHtmlParams;
if (opts.forComposerQuote) {
sanitizeParams = composerSanitizeHtmlParams;
}
try {
const isFormattedBody =
content.format === "org.matrix.custom.html" && typeof content.formatted_body === "string";
let bodyHasEmoji = false;
let isHtmlMessage = false;
let safeBody: string | undefined; // safe, sanitised HTML, preferred over `strippedBody` which is fully plaintext
// sanitizeHtml can hang if an unclosed HTML tag is thrown at it
// A search for `<foo` will make the browser crash an alternative would be to escape HTML special characters
// but that would bring no additional benefit as the highlighter does not work with those special chars
const safeHighlights = highlights
?.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 : "";
if (opts.stripReplyFallback && formattedBody) formattedBody = stripHTMLReply(formattedBody);
const strippedBody = opts.stripReplyFallback ? stripPlainReply(plainBody) : plainBody;
bodyHasEmoji = mightContainEmoji(isFormattedBody ? formattedBody! : plainBody);
const highlighter = safeHighlights?.length
? new HtmlHighlighter("mx_EventTile_searchHighlight", opts.highlightLink)
: null;
if (isFormattedBody) {
if (highlighter) {
// XXX: We sanitize the HTML whilst also highlighting its text nodes, to avoid accidentally trying
// to highlight HTML tags themselves. However, this does mean that we don't highlight textnodes which
// 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("");
};
}
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")) {
[...phtml.querySelectorAll<HTMLElement>("div[data-mx-maths], 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.body.innerHTML;
}
} else if (highlighter) {
safeBody = highlighter.applyHighlights(escapeHtml(plainBody), safeHighlights!).join("");
}
return { bodyHasEmoji, isHtmlMessage, strippedBody, safeBody, isFormattedBody };
} finally {
delete sanitizeParams.textFilter;
}
}
export function bodyToDiv(
content: IContent,
highlights: Optional<string[]>,
opts: EventRenderOpts = {},
ref?: React.Ref<HTMLDivElement>,
): ReactNode {
const { strippedBody, formattedBody, emojiBodyElements, className } = bodyToNode(content, highlights, opts);
return formattedBody ? (
<div
key="body"
ref={ref}
className={className}
dangerouslySetInnerHTML={{ __html: formattedBody }}
dir="auto"
/>
) : (
<div key="body" ref={ref} className={className} dir="auto">
{emojiBodyElements || strippedBody}
</div>
);
}
export function bodyToSpan(
content: IContent,
highlights: Optional<string[]>,
opts: EventRenderOpts = {},
ref?: React.Ref<HTMLSpanElement>,
includeDir = true,
): ReactNode {
const { strippedBody, formattedBody, emojiBodyElements, className } = bodyToNode(content, highlights, opts);
return formattedBody ? (
<span
key="body"
ref={ref}
className={className}
dangerouslySetInnerHTML={{ __html: formattedBody }}
dir={includeDir ? "auto" : undefined}
/>
) : (
<span key="body" ref={ref} className={className} dir={includeDir ? "auto" : undefined}>
{emojiBodyElements || strippedBody}
</span>
);
}
interface BodyToNodeReturn {
strippedBody: string;
formattedBody?: string;
emojiBodyElements: JSX.Element[] | undefined;
className: string;
}
function bodyToNode(content: IContent, highlights: Optional<string[]>, opts: EventRenderOpts = {}): BodyToNodeReturn {
const eventInfo = analyseEvent(content, highlights, opts);
let emojiBody = false;
if (!opts.disableBigEmoji && eventInfo.bodyHasEmoji) {
const contentBody = eventInfo.safeBody ?? eventInfo.strippedBody;
let contentBodyTrimmed = contentBody !== undefined ? contentBody.trim() : "";
// 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?.[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.
(eventInfo.strippedBody === eventInfo.safeBody || // replies have the html fallbacks, account for that here
content.formatted_body === undefined ||
(!content.formatted_body.includes("http:") && !content.formatted_body.includes("https:")));
}
const className = classNames({
"mx_EventTile_body": true,
"mx_EventTile_bigEmoji": emojiBody,
"markdown-body": eventInfo.isHtmlMessage && !emojiBody,
// Override the global `notranslate` class set by the top-level `matrixchat` div.
"translate": true,
});
let formattedBody = eventInfo.safeBody;
if (eventInfo.isFormattedBody && eventInfo.bodyHasEmoji && eventInfo.safeBody) {
// This has to be done after the emojiBody check as to not break big emoji on replies
formattedBody = formatEmojis(eventInfo.safeBody, true).join("");
}
let emojiBodyElements: JSX.Element[] | undefined;
if (!eventInfo.safeBody && eventInfo.bodyHasEmoji) {
emojiBodyElements = formatEmojis(eventInfo.strippedBody, false) as JSX.Element[];
}
return { strippedBody: eventInfo.strippedBody, formattedBody, emojiBodyElements, className };
}
/**
* Turn a matrix event body into html
*
* content: 'content' of the MatrixEvent
*
* highlights: optional list of words to highlight, ordered by longest word first
*
* opts.highlightLink: optional href to add to highlighted words
* opts.disableBigEmoji: optional argument to disable the big emoji class.
* opts.stripReplyFallback: optional argument specifying the event is a reply and so fallback needs removing
* opts.forComposerQuote: optional param to lessen the url rewriting done by sanitization, for quoting into composer
* opts.ref: React ref to attach to any React components returned (not compatible with opts.returnString)
*/
export function bodyToHtml(content: IContent, highlights: Optional<string[]>, opts: EventRenderOpts = {}): string {
const eventInfo = analyseEvent(content, highlights, opts);
let formattedBody = eventInfo.safeBody;
if (eventInfo.isFormattedBody && eventInfo.bodyHasEmoji && formattedBody) {
// This has to be done after the emojiBody check above as to not break big emoji on replies
formattedBody = formatEmojis(eventInfo.safeBody, true).join("");
}
return formattedBody ?? eventInfo.strippedBody;
}
/**
* Turn a room topic into html
* @param topic plain text topic
* @param htmlTopic optional html topic
* @param ref React ref to attach to any React components returned
* @param allowExtendedHtml whether to allow extended HTML tags such as headings and lists
* @return The HTML-ified node.
*/
export function topicToHtml(
topic?: string,
htmlTopic?: string,
ref?: LegacyRef<HTMLSpanElement>,
allowExtendedHtml = false,
): ReactNode {
if (!SettingsStore.getValue("feature_html_topic")) {
htmlTopic = undefined;
}
let isFormattedTopic = !!htmlTopic;
let topicHasEmoji = false;
let safeTopic = "";
try {
topicHasEmoji = mightContainEmoji(isFormattedTopic ? htmlTopic! : topic);
if (isFormattedTopic) {
safeTopic = sanitizeHtml(htmlTopic!, allowExtendedHtml ? sanitizeHtmlParams : topicSanitizeHtmlParams);
if (topicHasEmoji) {
safeTopic = formatEmojis(safeTopic, true).join("");
}
}
} catch {
isFormattedTopic = false; // Fall back to plain-text topic
}
let emojiBodyElements: JSX.Element[] | undefined;
if (!isFormattedTopic && topicHasEmoji) {
emojiBodyElements = formatEmojis(topic, false);
}
if (isFormattedTopic) {
if (!safeTopic) return null;
return <span ref={ref} dangerouslySetInnerHTML={{ __html: safeTopic }} dir="auto" />;
}
if (!emojiBodyElements && !topic) return null;
return (
<span ref={ref} dir="auto">
{emojiBodyElements || topic}
</span>
);
}
/**
* Returns if a node is a block element or not.
* Only takes html nodes into account that are allowed in matrix messages.
*
* @param {Node} node
* @returns {bool}
*/
export function checkBlockNode(node: Node): boolean {
switch (node.nodeName) {
case "H1":
case "H2":
case "H3":
case "H4":
case "H5":
case "H6":
case "PRE":
case "BLOCKQUOTE":
case "P":
case "UL":
case "OL":
case "LI":
case "HR":
case "TABLE":
case "THEAD":
case "TBODY":
case "TR":
case "TH":
case "TD":
return true;
case "DIV":
// don't treat math nodes as block nodes for deserializing
return !(node as HTMLElement).hasAttribute("data-mx-maths");
default:
return false;
}
}

222
src/IConfigOptions.ts Normal file
View File

@@ -0,0 +1,222 @@
/*
Copyright 2024 New Vector Ltd.
Copyright 2019-2022 The Matrix.org Foundation C.I.C.
Copyright 2016 OpenMarket Ltd
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only
Please see LICENSE files in the repository root for full details.
*/
import { IClientWellKnown } from "matrix-js-sdk/src/matrix";
import { ValidatedServerConfig } from "./utils/ValidatedServerConfig";
// Convention decision: All config options are lower_snake_case
// We use an isolated file for the interface so we can mess around with the eslint options.
/* eslint-disable camelcase */
/* eslint @typescript-eslint/naming-convention: ["error", { "selector": "property", "format": ["snake_case"] } ] */
// see element-web config.md for non-developer docs
export interface IConfigOptions {
// dev note: while true that this is arbitrary JSON, it's valuable to enforce that all
// config options are documented for "find all usages" sort of searching.
// [key: string]: any;
// Properties of this interface are roughly grouped by their subject matter, such as
// "instance customisation", "login stuff", "branding", etc. Use blank lines to denote
// a logical separation of properties, but keep similar ones near each other.
// Exactly one of the following must be supplied
default_server_config?: IClientWellKnown; // copy/paste of client well-known
default_server_name?: string; // domain to do well-known lookup on
default_hs_url?: string; // http url
default_is_url?: string; // used in combination with default_hs_url, but for the identity server
// This is intended to be overridden by app startup and not specified by the user
// This is also why it's allowed to have an interface that isn't snake_case
validated_server_config?: ValidatedServerConfig;
fallback_hs_url?: string;
disable_custom_urls?: boolean;
disable_guests?: boolean;
disable_login_language_selector?: boolean;
disable_3pid_login?: boolean;
brand: string;
branding?: {
welcome_background_url?: string | string[]; // chosen at random if array
auth_header_logo_url?: string;
auth_footer_links?: { text: string; url: string }[];
};
force_verification?: boolean; // if true, users must verify new logins
map_style_url?: string; // for location-shared maps
embedded_pages?: {
welcome_url?: string;
home_url?: string;
login_for_welcome?: boolean;
};
permalink_prefix?: string;
update_base_url?: string;
desktop_builds: {
available: boolean;
logo: string; // url
url: string; // download url
url_macos?: string;
url_win64?: string;
url_win32?: string;
url_linux?: string;
};
mobile_builds: {
ios: string | null; // download url
android: string | null; // download url
fdroid: string | null; // download url
};
mobile_guide_toast?: boolean;
default_theme?: "light" | "dark" | string; // custom themes are strings
default_country_code?: string; // ISO 3166 alpha2 country code
default_federate?: boolean;
default_device_display_name?: string; // for device naming on login+registration
setting_defaults?: Record<string, any>; // <SettingName, Value>
integrations_ui_url?: string;
integrations_rest_url?: string;
integrations_widgets_urls?: string[];
default_widget_container_height?: number; // height in pixels
show_labs_settings: boolean;
features?: Record<string, boolean>; // <FeatureName, EnabledBool>
bug_report_endpoint_url?: string; // omission disables bug reporting
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;
};
jitsi_widget?: {
skip_built_in_welcome_screen?: boolean;
};
voip?: {
obey_asserted_identity?: boolean; // MSC3086
};
element_call: {
url?: string;
guest_spa_url?: string;
use_exclusively?: boolean;
participant_limit?: number;
brand?: string;
};
logout_redirect_url?: string;
// sso_immediate_redirect is deprecated in favour of sso_redirect_options.immediate
sso_immediate_redirect?: boolean;
sso_redirect_options?: ISsoRedirectOptions;
custom_translations_url?: string;
report_event?: {
admin_message_md: string; // message for how to contact the server owner when reporting an event
};
room_directory?: {
servers: string[];
};
posthog?: {
project_api_key: string;
api_host: string; // hostname
};
analytics_owner?: string; // defaults to `brand`
privacy_policy_url?: string; // location for cookie policy
enable_presence_by_hs_url?: Record<string, boolean>; // <HomeserverName, Enabled>
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;
};
};
};
sync_timeline_limit?: number;
dangerously_allow_unsafe_and_insecure_passwords?: boolean; // developer option
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?: {
[issuer: string]: { client_id: string };
};
/**
* Configuration for OIDC dynamic registration where a static OIDC client is not configured.
*/
oidc_metadata?: {
client_uri?: string;
logo_uri?: string;
tos_uri?: string;
policy_uri?: string;
contacts?: string[];
};
}
export interface ISsoRedirectOptions {
immediate?: boolean;
on_welcome_page?: boolean;
on_login_page?: boolean;
}

177
src/IdentityAuthClient.tsx Normal file
View File

@@ -0,0 +1,177 @@
/*
Copyright 2024 New Vector Ltd.
Copyright 2019 The Matrix.org Foundation C.I.C.
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only
Please see LICENSE files in the repository root for full details.
*/
import React from "react";
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 {
doesAccountDataHaveIdentityServer,
doesIdentityServerHaveTerms,
setToDefaultIdentityServer,
} 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 | null = null;
private tempClient?: MatrixClient;
private authEnabled = true;
/**
* Creates a new identity auth client
* @param {string} identityUrl The URL to contact the identity server with.
* When provided, this class will operate solely within memory, refusing to
* persist any information such as tokens. Default null (not provided).
*/
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
// though, and making all of them take one could lead to developer
// confusion about what the idBaseUrl does on a client. Therefore, we
// just make a new client and live with it.
this.tempClient = createClient({
baseUrl: "", // invalid by design
idBaseUrl: identityUrl,
});
}
}
// 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 MatrixClientPeg.safeGet();
}
private writeToken(): void {
if (this.tempClient) return; // temporary client: ignore
if (this.accessToken) {
window.localStorage.setItem("mx_is_access_token", this.accessToken);
} else {
window.localStorage.removeItem("mx_is_access_token");
}
}
private readToken(): string | null {
if (this.tempClient) return null; // temporary client: ignore
return window.localStorage.getItem("mx_is_access_token");
}
// Returns a promise that resolves to the access_token string from the IS
public async getAccessToken({ check = true } = {}): Promise<string | null> {
if (!this.authEnabled) {
// The current IS doesn't support authentication
return null;
}
let token: string | null = this.accessToken;
if (!token) {
token = this.readToken();
}
if (!token) {
token = await this.registerForToken(check);
if (token) {
this.accessToken = token;
this.writeToken();
}
return token;
}
if (check) {
try {
await this.checkToken(token);
} catch (e) {
if (e instanceof TermsNotSignedError || e instanceof AbortedIdentityActionError) {
// Retrying won't help this
throw e;
}
// Retry in case token expired
token = await this.registerForToken();
if (token) {
this.accessToken = token;
this.writeToken();
}
}
}
return token;
}
private async checkToken(token: string): Promise<void> {
const identityServerUrl = this.identityClient.getIdentityServerUrl()!;
try {
await this.identityClient.getIdentityAccount(token);
} catch (e) {
if (e instanceof MatrixError && e.errcode === "M_TERMS_NOT_SIGNED") {
logger.log("Identity server requires new terms to be agreed to");
await startTermsFlow(this.matrixClient, [new Service(SERVICE_TYPES.IS, identityServerUrl, token)]);
return;
}
throw e;
}
if (
!this.tempClient &&
!doesAccountDataHaveIdentityServer(this.matrixClient) &&
!(await doesIdentityServerHaveTerms(this.matrixClient, identityServerUrl))
) {
const { finished } = Modal.createDialog(QuestionDialog, {
title: _t("terms|identity_server_no_terms_title"),
description: (
<div>
<p>
{_t(
"terms|identity_server_no_terms_description_1",
{},
{
server: () => <strong>{abbreviateUrl(identityServerUrl)}</strong>,
},
)}
</p>
<p>{_t("terms|identity_server_no_terms_description_2")}</p>
</div>
),
button: _t("action|trust"),
});
const [confirmed] = await finished;
if (confirmed) {
setToDefaultIdentityServer(this.matrixClient);
} else {
throw new AbortedIdentityActionError("User aborted identity server action without terms");
}
}
// We should ensure the token in `localStorage` is cleared
// appropriately. We already clear storage on sign out, but we'll need
// additional clearing when changing ISes in settings as part of future
// privacy work.
// See also https://github.com/vector-im/element-web/issues/10455.
}
public async registerForToken(check = true): Promise<string> {
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.identityClient.registerWithIdentityServer(hsOpenIdToken);
const identityAccessToken = token ? token : accessToken;
if (check) await this.checkToken(identityAccessToken);
return identityAccessToken;
}
}

54
src/ImageUtils.ts Normal file
View File

@@ -0,0 +1,54 @@
/*
Copyright 2024 New Vector Ltd.
Copyright 2015, 2016 , 2020 Copyright 2020 The Matrix.org Foundation C.I.C.
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only
Please see LICENSE files in the repository root for full details.
*/
/**
* Returns the actual height that an image of dimensions (fullWidth, fullHeight)
* will occupy if resized to fit inside a thumbnail bounding box of size
* (thumbWidth, thumbHeight).
*
* If the aspect ratio of the source image is taller than the aspect ratio of
* the thumbnail bounding box, then we return the thumbHeight parameter unchanged.
* Otherwise we return the thumbHeight parameter scaled down appropriately to
* reflect the actual height the scaled thumbnail occupies.
*
* This is very useful for calculating how much height a thumbnail will actually
* consume in the timeline, when performing scroll offset calculations
* (e.g. scroll locking)
*/
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
return null;
}
if (fullWidth < thumbWidth && fullHeight < thumbHeight) {
// no scaling needs to be applied
return fullHeight;
}
const widthMulti = thumbWidth / fullWidth;
const heightMulti = thumbHeight / fullHeight;
if (widthMulti < heightMulti) {
// width is the dominant dimension so scaling will be fixed on that
return Math.floor(widthMulti * fullHeight);
} else {
// height is the dominant dimension so scaling will be fixed on that
return Math.floor(heightMulti * fullHeight);
}
}

159
src/KeyBindingsDefaults.ts Normal file
View File

@@ -0,0 +1,159 @@
/*
Copyright 2024 New Vector Ltd.
Copyright 2022 Šimon Brandner <simon.bra.ag@gmail.com>
Copyright 2021 Clemens Zeidler
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only
Please see LICENSE files in the repository root for full details.
*/
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 { getKeyboardShortcuts } from "./accessibility/KeyboardShortcutUtils";
export const getBindingsByCategory = (category: CategoryName): KeyBinding[] => {
return CATEGORIES[category].settingNames.reduce<KeyBinding[]>((bindings, action) => {
const keyCombo = getKeyboardShortcuts()[action]?.default;
if (keyCombo) {
bindings.push({ action, keyCombo });
}
return bindings;
}, []);
};
const messageComposerBindings = (): KeyBinding[] => {
const bindings = getBindingsByCategory(CategoryName.COMPOSER);
if (SettingsStore.getValue("MessageComposerInput.ctrlEnterToSend")) {
bindings.push({
action: KeyBindingAction.SendMessage,
keyCombo: {
key: Key.ENTER,
ctrlOrCmdKey: true,
},
});
bindings.push({
action: KeyBindingAction.NewLine,
keyCombo: {
key: Key.ENTER,
},
});
bindings.push({
action: KeyBindingAction.NewLine,
keyCombo: {
key: Key.ENTER,
shiftKey: true,
},
});
} else {
bindings.push({
action: KeyBindingAction.SendMessage,
keyCombo: {
key: Key.ENTER,
},
});
bindings.push({
action: KeyBindingAction.NewLine,
keyCombo: {
key: Key.ENTER,
shiftKey: true,
},
});
if (IS_MAC) {
bindings.push({
action: KeyBindingAction.NewLine,
keyCombo: {
key: Key.ENTER,
altKey: true,
},
});
}
}
return bindings;
};
const autocompleteBindings = (): KeyBinding[] => {
const bindings = getBindingsByCategory(CategoryName.AUTOCOMPLETE);
bindings.push({
action: KeyBindingAction.ForceCompleteAutocomplete,
keyCombo: {
key: Key.TAB,
},
});
bindings.push({
action: KeyBindingAction.ForceCompleteAutocomplete,
keyCombo: {
key: Key.TAB,
ctrlKey: true,
},
});
bindings.push({
action: KeyBindingAction.CompleteAutocomplete,
keyCombo: {
key: Key.ENTER,
},
});
bindings.push({
action: KeyBindingAction.CompleteAutocomplete,
keyCombo: {
key: Key.ENTER,
ctrlKey: true,
},
});
return bindings;
};
const roomListBindings = (): KeyBinding[] => {
return getBindingsByCategory(CategoryName.ROOM_LIST);
};
const roomBindings = (): KeyBinding[] => {
const bindings = getBindingsByCategory(CategoryName.ROOM);
if (SettingsStore.getValue("ctrlFForSearch")) {
bindings.push({
action: KeyBindingAction.SearchInRoom,
keyCombo: {
key: Key.F,
ctrlOrCmdKey: true,
},
});
}
return bindings;
};
const navigationBindings = (): KeyBinding[] => {
return getBindingsByCategory(CategoryName.NAVIGATION);
};
const accessibilityBindings = (): KeyBinding[] => {
return getBindingsByCategory(CategoryName.ACCESSIBILITY);
};
const callBindings = (): KeyBinding[] => {
return getBindingsByCategory(CategoryName.CALLS);
};
const labsBindings = (): KeyBinding[] => {
if (!SdkConfig.get("show_labs_settings")) return [];
return getBindingsByCategory(CategoryName.LABS);
};
export const defaultBindingsProvider: IKeyBindingsProvider = {
getMessageComposerBindings: messageComposerBindings,
getAutocompleteBindings: autocompleteBindings,
getRoomListBindings: roomListBindings,
getRoomBindings: roomBindings,
getNavigationBindings: navigationBindings,
getAccessibilityBindings: accessibilityBindings,
getCallBindings: callBindings,
getLabsBindings: labsBindings,
};

180
src/KeyBindingsManager.ts Normal file
View File

@@ -0,0 +1,180 @@
/*
Copyright 2024 New Vector Ltd.
Copyright 2022 Šimon Brandner <simon.bra.ag@gmail.com>
Copyright 2021 Clemens Zeidler
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only
Please see LICENSE files in the repository root for full details.
*/
import { KeyBindingAction } from "./accessibility/KeyboardShortcuts";
import { defaultBindingsProvider } from "./KeyBindingsDefaults";
import { IS_MAC } from "./Keyboard";
/**
* Represent a key combination.
*
* The combo is evaluated strictly, i.e. the KeyboardEvent must match exactly what is specified in the KeyCombo.
*/
export type KeyCombo = {
key: string;
/** On PC: ctrl is pressed; on Mac: meta is pressed */
ctrlOrCmdKey?: boolean;
altKey?: boolean;
ctrlKey?: boolean;
metaKey?: boolean;
shiftKey?: boolean;
};
export type KeyBinding = {
action: KeyBindingAction;
keyCombo: KeyCombo;
};
/**
* Helper method to check if a KeyboardEvent matches a KeyCombo
*
* Note, this method is only exported for testing.
*/
export function isKeyComboMatch(ev: KeyboardEvent | React.KeyboardEvent, combo: KeyCombo, onMac: boolean): boolean {
if (combo.key !== undefined) {
// When shift is pressed, letters are returned as upper case chars. In this case do a lower case comparison.
// This works for letter combos such as shift + U as well for none letter combos such as shift + Escape.
// If shift is not pressed, the toLowerCase conversion can be avoided.
if (ev.shiftKey) {
if (ev.key.toLowerCase() !== combo.key.toLowerCase()) {
return false;
}
} else if (ev.key !== combo.key) {
return false;
}
}
const comboCtrl = combo.ctrlKey ?? false;
const comboAlt = combo.altKey ?? false;
const comboShift = combo.shiftKey ?? false;
const comboMeta = combo.metaKey ?? false;
// Tests mock events may keep the modifiers undefined; convert them to booleans
const evCtrl = ev.ctrlKey ?? false;
const evAlt = ev.altKey ?? false;
const evShift = ev.shiftKey ?? false;
const evMeta = ev.metaKey ?? false;
// 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) {
return false;
}
} else {
if (!evCtrl || evMeta !== comboMeta || evAlt !== comboAlt || evShift !== comboShift) {
return false;
}
}
return true;
}
if (evMeta !== comboMeta || evCtrl !== comboCtrl || evAlt !== comboAlt || evShift !== comboShift) {
return false;
}
return true;
}
export type KeyBindingGetter = () => KeyBinding[];
export interface IKeyBindingsProvider {
[key: string]: KeyBindingGetter;
}
export class KeyBindingsManager {
/**
* List of key bindings providers.
*
* Key bindings from the first provider(s) in the list will have precedence over key bindings from later providers.
*
* To overwrite the default key bindings add a new providers before the default provider, e.g. a provider for
* customized key bindings.
*/
public bindingsProviders: IKeyBindingsProvider[] = [defaultBindingsProvider];
/**
* Finds a matching KeyAction for a given KeyboardEvent
*/
private getAction(
getters: KeyBindingGetter[],
ev: KeyboardEvent | React.KeyboardEvent,
): KeyBindingAction | undefined {
for (const getter of getters) {
const bindings = getter();
const binding = bindings.find((it) => isKeyComboMatch(ev, it.keyCombo, IS_MAC));
if (binding) {
return binding.action;
}
}
return undefined;
}
public getMessageComposerAction(ev: KeyboardEvent | React.KeyboardEvent): KeyBindingAction | undefined {
return this.getAction(
this.bindingsProviders.map((it) => it.getMessageComposerBindings),
ev,
);
}
public getAutocompleteAction(ev: KeyboardEvent | React.KeyboardEvent): KeyBindingAction | undefined {
return this.getAction(
this.bindingsProviders.map((it) => it.getAutocompleteBindings),
ev,
);
}
public getRoomListAction(ev: KeyboardEvent | React.KeyboardEvent): KeyBindingAction | undefined {
return this.getAction(
this.bindingsProviders.map((it) => it.getRoomListBindings),
ev,
);
}
public getRoomAction(ev: KeyboardEvent | React.KeyboardEvent): KeyBindingAction | undefined {
return this.getAction(
this.bindingsProviders.map((it) => it.getRoomBindings),
ev,
);
}
public getNavigationAction(ev: KeyboardEvent | React.KeyboardEvent): KeyBindingAction | undefined {
return this.getAction(
this.bindingsProviders.map((it) => it.getNavigationBindings),
ev,
);
}
public getAccessibilityAction(ev: KeyboardEvent | React.KeyboardEvent): KeyBindingAction | undefined {
return this.getAction(
this.bindingsProviders.map((it) => it.getAccessibilityBindings),
ev,
);
}
public getCallAction(ev: KeyboardEvent | React.KeyboardEvent): KeyBindingAction | undefined {
return this.getAction(
this.bindingsProviders.map((it) => it.getCallBindings),
ev,
);
}
public getLabsAction(ev: KeyboardEvent | React.KeyboardEvent): KeyBindingAction | undefined {
return this.getAction(
this.bindingsProviders.map((it) => it.getLabsBindings),
ev,
);
}
}
const manager = new KeyBindingsManager();
export function getKeyBindingsManager(): KeyBindingsManager {
return manager;
}

81
src/Keyboard.ts Normal file
View File

@@ -0,0 +1,81 @@
/*
Copyright 2024 New Vector Ltd.
Copyright 2019 The Matrix.org Foundation C.I.C.
Copyright 2017 New Vector Ltd
Copyright 2016 OpenMarket Ltd
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only
Please see LICENSE files in the repository root for full details.
*/
import React from "react";
export const Key = {
HOME: "Home",
END: "End",
PAGE_UP: "PageUp",
PAGE_DOWN: "PageDown",
BACKSPACE: "Backspace",
DELETE: "Delete",
ARROW_UP: "ArrowUp",
ARROW_DOWN: "ArrowDown",
ARROW_LEFT: "ArrowLeft",
ARROW_RIGHT: "ArrowRight",
F6: "F6",
TAB: "Tab",
ESCAPE: "Escape",
ENTER: "Enter",
ALT: "Alt",
CONTROL: "Control",
META: "Meta",
SHIFT: "Shift",
CONTEXT_MENU: "ContextMenu",
COMMA: ",",
PERIOD: ".",
LESS_THAN: "<",
GREATER_THAN: ">",
BACKTICK: "`",
SPACE: " ",
SLASH: "/",
SQUARE_BRACKET_LEFT: "[",
SQUARE_BRACKET_RIGHT: "]",
SEMICOLON: ";",
A: "a",
B: "b",
C: "c",
D: "d",
E: "e",
F: "f",
G: "g",
H: "h",
I: "i",
J: "j",
K: "k",
L: "l",
M: "m",
N: "n",
O: "o",
P: "p",
Q: "q",
R: "r",
S: "s",
T: "t",
U: "u",
V: "v",
W: "w",
X: "x",
Y: "y",
Z: "z",
};
export const IS_MAC = navigator.platform.toUpperCase().includes("MAC");
export const IS_ELECTRON = window.electron;
export function isOnlyCtrlOrCmdKeyEvent(ev: React.KeyboardEvent | KeyboardEvent): boolean {
if (IS_MAC) {
return ev.metaKey && !ev.altKey && !ev.ctrlKey && !ev.shiftKey;
} else {
return ev.ctrlKey && !ev.altKey && !ev.metaKey && !ev.shiftKey;
}
}

1178
src/LegacyCallHandler.tsx Normal file

File diff suppressed because it is too large Load Diff

1132
src/Lifecycle.ts Normal file

File diff suppressed because it is too large Load Diff

247
src/Linkify.tsx Normal file
View File

@@ -0,0 +1,247 @@
/*
Copyright 2024 New Vector Ltd.
Copyright 2024 The Matrix.org Foundation C.I.C.
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only
Please see LICENSE files in the repository root for full details.
*/
import React, { ReactElement } from "react";
import sanitizeHtml from "sanitize-html";
import { merge } from "lodash";
import _Linkify from "linkify-react";
import {
_linkifyElement,
_linkifyString,
ELEMENT_URL_PATTERN,
options as linkifyMatrixOptions,
} from "./linkify-matrix";
import { IExtendedSanitizeOptions } from "./@types/sanitize-html";
import SettingsStore from "./settings/SettingsStore";
import { tryTransformPermalinkToLocalHref } from "./utils/permalinks/Permalinks";
import { mediaFromMxc } from "./customisations/Media";
import { PERMITTED_URL_SCHEMES } from "./utils/UrlUtils";
const COLOR_REGEX = /^#[0-9a-fA-F]{6}$/;
const MEDIA_API_MXC_REGEX = /\/_matrix\/media\/r0\/(?:download|thumbnail)\/(.+?)\/(.+?)(?:[?/]|$)/;
export const transformTags: NonNullable<IExtendedSanitizeOptions["transformTags"]> = {
// custom to matrix
// add blank targets to all hyperlinks except vector URLs
"a": function (tagName: string, attribs: sanitizeHtml.Attributes) {
if (attribs.href) {
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 (
transformed !== attribs.href || // it could be converted so handle locally symbols e.g. @user:server.tdl, matrix: and matrix.to
attribs.href.match(ELEMENT_URL_PATTERN) // for https links to Element domains
) {
delete attribs.target;
}
} else {
// Delete the href attrib if it is falsy
delete attribs.href;
}
attribs.rel = "noreferrer noopener"; // https://mathiasbynens.github.io/rel-noopener/
return { tagName, attribs };
},
"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
// we don't want to allow images with `https?` `src`s.
// We also drop inline images (as if they were not present at all) when the "show
// images" preference is disabled. Future work might expose some UI to reveal them
// like standalone image events have.
if (!src || !SettingsStore.getValue("showImages")) {
return { tagName, attribs: {} };
}
if (!src.startsWith("mxc://")) {
const match = MEDIA_API_MXC_REGEX.exec(src);
if (match) {
src = `mxc://${match[1]}/${match[2]}`;
}
}
if (!src.startsWith("mxc://")) {
return { tagName, attribs: {} };
}
const requestedWidth = Number(attribs.width);
const requestedHeight = Number(attribs.height);
const width = Math.min(requestedWidth || 800, 800);
const height = Math.min(requestedHeight || 600, 600);
// specify width/height as max values instead of absolute ones to allow object-fit to do its thing
// we only allow our own styles for this tag so overwrite the attribute
attribs.style = `max-width: ${width}px; max-height: ${height}px;`;
if (requestedWidth) {
attribs.style += "width: 100%;";
}
if (requestedHeight) {
attribs.style += "height: 100%;";
}
attribs.src = mediaFromMxc(src).getThumbnailOfSourceHttp(width, height)!;
return { tagName, attribs };
},
"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-_");
});
attribs.class = classes.join(" ");
}
return { tagName, attribs };
},
// eslint-disable-next-line @typescript-eslint/naming-convention
"*": 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.
if (tagName !== "img") {
delete attribs.style;
}
// Sanitise and transform data-mx-color and data-mx-bg-color to their CSS
// equivalents
const customCSSMapper: Record<string, string> = {
"data-mx-color": "color",
"data-mx-bg-color": "background-color",
// $customAttributeKey: $cssAttributeKey
};
let style = "";
Object.keys(customCSSMapper).forEach((customAttributeKey) => {
const cssAttributeKey = customCSSMapper[customAttributeKey];
const customAttributeValue = attribs[customAttributeKey];
if (
customAttributeValue &&
typeof customAttributeValue === "string" &&
COLOR_REGEX.test(customAttributeValue)
) {
style += cssAttributeKey + ":" + customAttributeValue + ";";
delete attribs[customAttributeKey];
}
});
if (style) {
attribs.style = style + (attribs.style || "");
}
return { tagName, attribs };
},
};
export const sanitizeHtmlParams: IExtendedSanitizeOptions = {
allowedTags: [
// These tags are suggested by the spec https://spec.matrix.org/v1.10/client-server-api/#mroommessage-msgtypes
"font", // custom to matrix for IRC-style font coloring
"del", // for markdown
"s",
"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
// 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
},
// 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"],
// URL schemes we permit
allowedSchemes: PERMITTED_URL_SCHEMES,
allowProtocolRelative: false,
transformTags,
// 50 levels deep "should be enough for anyone"
nestingLimit: 50,
};
/* 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>
);
}
/**
* Linkifies the given string. This is a wrapper around 'linkifyjs/string'.
*
* @param {string} str string to linkify
* @param {object} [options] Options for linkifyString. Default: linkifyMatrixOptions
* @returns {string} Linkified string
*/
export function linkifyString(str: string, options = linkifyMatrixOptions): string {
return _linkifyString(str, options);
}
/**
* Linkifies the given DOM element. This is a wrapper around 'linkifyjs/element'.
*
* @param {object} element DOM element to linkify
* @param {object} [options] Options for linkifyElement. Default: linkifyMatrixOptions
* @returns {object}
*/
export function linkifyElement(element: HTMLElement, options = linkifyMatrixOptions): HTMLElement {
return _linkifyElement(element, options);
}
/**
* Linkify the given string and sanitize the HTML afterwards.
*
* @param {string} dirtyHtml The HTML string to sanitize and linkify
* @param {object} [options] Options for linkifyString. Default: linkifyMatrixOptions
* @returns {string}
*/
export function linkifyAndSanitizeHtml(dirtyHtml: string, options = linkifyMatrixOptions): string {
return sanitizeHtml(linkifyString(dirtyHtml, options), sanitizeHtmlParams);
}

52
src/Livestream.ts Normal file
View File

@@ -0,0 +1,52 @@
/*
Copyright 2024 New Vector Ltd.
Copyright 2021 The Matrix.org Foundation C.I.C.
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only
Please see LICENSE files in the repository root for full details.
*/
import { ClientWidgetApi } from "matrix-widget-api";
import { MatrixClient } from "matrix-js-sdk/src/matrix";
import SdkConfig from "./SdkConfig";
import { ElementWidgetActions } from "./stores/widgets/ElementWidgetActions";
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/";
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",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
room_id: roomId,
openid_token: openIdToken,
}),
});
const respBody = await response.json();
return respBody["stream_id"];
}
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,
});
}

289
src/Login.ts Normal file
View File

@@ -0,0 +1,289 @@
/*
Copyright 2024 New Vector Ltd.
Copyright 2015-2021 The Matrix.org Foundation C.I.C.
Copyright 2019 Michael Telatynski <7t3chguy@gmail.com>
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only
Please see LICENSE files in the repository root for full details.
*/
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 { IMatrixClientCreds } from "./MatrixClientPeg";
import { ModuleRunner } from "./modules/ModuleRunner";
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 flows: Array<ClientLoginFlow> = [];
private readonly defaultDeviceDisplayName?: string;
private delegatedAuthentication?: OidcClientConfig;
private tempClient: MatrixClient | null = null; // memoize
public constructor(
private hsUrl: string,
private isUrl: string,
private fallbackHsUrl: string | null,
opts: ILoginOptions,
) {
this.defaultDeviceDisplayName = opts.defaultDeviceDisplayName;
this.delegatedAuthentication = opts.delegatedAuthentication;
}
public getHomeserverUrl(): string {
return this.hsUrl;
}
public getIdentityServerUrl(): string {
return this.isUrl;
}
public setHomeserverUrl(hsUrl: string): void {
this.tempClient = null; // clear memoization
this.hsUrl = hsUrl;
}
public setIdentityServerUrl(isUrl: string): void {
this.tempClient = null; // clear memoization
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) {
this.tempClient = createClient({
baseUrl: this.hsUrl,
idBaseUrl: this.isUrl,
});
}
return this.tempClient;
}
/**
* 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 }: { 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 | undefined,
phoneCountry: string | undefined,
phoneNumber: string | undefined,
password: string,
): Promise<IMatrixClientCreds> {
const isEmail = !!username && username.indexOf("@") > 0;
let identifier;
if (phoneCountry && phoneNumber) {
identifier = {
type: "m.id.phone",
country: phoneCountry,
phone: phoneNumber,
// XXX: Synapse historically wanted `number` and not `phone`
number: phoneNumber,
};
} else if (isEmail) {
identifier = {
type: "m.id.thirdparty",
medium: "email",
address: username,
};
} else {
identifier = {
type: "m.id.user",
user: username,
};
}
const loginParams = {
password,
identifier,
initial_device_display_name: this.defaultDeviceDisplayName,
};
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: 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;
});
}
}
/**
* 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
*
* @param {string} hsUrl the base url of the Homeserver used to log in.
* @param {string} isUrl the base url of the default identity server
* @param {string} loginType the type of login to do
* @param {ILoginParams} loginParams the parameters for the login
*
* @returns {IMatrixClientCreds}
*/
export async function sendLoginRequest(
hsUrl: string,
isUrl: string | undefined,
loginType: string,
loginParams: Omit<LoginRequest, "type">,
): Promise<IMatrixClientCreds> {
const client = createClient({
baseUrl: hsUrl,
idBaseUrl: isUrl,
});
const data = await client.login(loginType, loginParams);
const wellknown = data.well_known;
if (wellknown) {
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"]?.["base_url"]) {
// TODO: should we prompt here?
isUrl = wellknown["m.identity_server"]["base_url"];
logger.log(`Overrode IS setting with ${isUrl} from login response`);
}
}
const creds: IMatrixClientCreds = {
homeserverUrl: hsUrl,
identityServerUrl: isUrl,
userId: data.user_id,
deviceId: data.device_id,
accessToken: data.access_token,
};
ModuleRunner.instance.extensions.cryptoSetup.examineLoginResponse(data, creds);
return creds;
}

388
src/Markdown.ts Normal file
View File

@@ -0,0 +1,388 @@
/*
Copyright 2024 New Vector Ltd.
Copyright 2021 The Matrix.org Foundation C.I.C.
Copyright 2016 OpenMarket Ltd
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only
Please see LICENSE files in the repository root for full details.
*/
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 { linkify } from "./linkify-matrix";
const ALLOWED_HTML_TAGS = ["sub", "sup", "del", "s", "u", "br", "br/"];
// These types of node are definitely text
const TEXT_NODES = ["text", "softbreak", "linebreak", "paragraph", "document"];
function isAllowedHtmlTag(node: commonmark.Node): boolean {
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 the tags we allow
// shouldn't really have any anyway.
const matches = /^<\/?(.*)>$/.exec(node.literal);
if (matches && matches.length == 2) {
const tag = matches[1];
return ALLOWED_HTML_TAGS.indexOf(tag) > -1;
}
return false;
}
/*
* Returns true if the parse output containing the node
* comprises multiple block level elements (ie. lines),
* or false if it is only a single line.
*/
function isMultiLine(node: commonmark.Node): boolean {
let par = node;
while (par.parent) {
par = par.parent;
}
return par.firstChild != par.lastChild;
}
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) {
let n = 0;
let char = literal[n];
while (char !== " " && char !== null && n <= literal.length) {
if (char === " ") {
break;
}
if (char) {
text += char;
}
n += 1;
char = literal[n];
}
if (char === " ") {
break;
}
}
currentNode = currentNode.next;
}
return text;
}
const formattingChangesByNodeType = {
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;
};
/**
* Class that wraps commonmark, adding the ability to see whether
* a given message actually uses any markdown syntax or whether
* it's plain text.
*/
export default class Markdown {
private input: string;
private parsed: commonmark.Node;
public constructor(input: string) {
this.input = input;
const parser = new commonmark.Parser();
this.parsed = parser.parse(this.input);
this.parsed = this.repairLinks(this.parsed);
}
/**
* This method is modifying the parsed AST in such a way that links are always
* properly linkified instead of sometimes being wrongly emphasised in case
* if you were to write a link like the example below:
* https://my_weird-link_domain.domain.com
* ^ this link would be parsed to something like this:
* <a href="https://my">https://my</a><b>weird-link</b><a href="https://domain.domain.com">domain.domain.com</a>
* This method makes it so the link gets properly modified to a version where it is
* not emphasised until it actually ends.
* See: https://github.com/vector-im/element-web/issues/4674
* @param parsed
*/
private repairLinks(parsed: commonmark.Node): commonmark.Node {
const walker = parsed.walker();
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 (event.entering) {
isInPara = true;
} else {
isInPara = false;
}
}
if (isInPara) {
// Clear saved string when line ends
if (
node.type === "softbreak" ||
node.type === "linebreak" ||
// Also start calculating the text from the beginning on any spaces
(node.type === "text" && node.literal === " ")
) {
text = "";
continue;
}
// Break up text nodes on spaces, so that we don't shoot past them without resetting
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) => {
if (part) {
const nextNode = new commonmark.Node("text");
nextNode.literal = part;
node.insertAfter(nextNode);
// Make the iterator aware of the newly inserted node
walker.resumeAt(nextNode, true);
}
});
}
// 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 (event.entering) {
const foundLinks = linkify.find(text);
for (const { value } of foundLinks) {
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}${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");
emphasisTextNode.literal = nonEmphasizedText;
previousNode.insertAfter(emphasisTextNode);
node.firstChild.literal = "";
event = node.walker().next();
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: ",
text,
);
logger.error(
"Markdown links escaping found too many links for modified text: ",
newText,
);
}
}
}
} else {
if (shouldUnlinkFormattingNode) {
node.unlink();
shouldUnlinkFormattingNode = false;
}
}
}
}
previousNode = node;
}
return parsed;
}
public isPlainText(): boolean {
const walker = this.parsed.walker();
let ev: commonmark.NodeWalkingStep | null;
while ((ev = walker.next())) {
const node = ev.node;
if (TEXT_NODES.indexOf(node.type) > -1) {
// definitely text
continue;
} 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.
if (isAllowedHtmlTag(node)) {
return false;
}
} else {
return false;
}
}
return true;
}
public toHTML({ externalLinks = false } = {}): string {
const renderer = new commonmark.HtmlRenderer({
safe: false,
// Set soft breaks to hard HTML breaks: commonmark
// puts softbreaks in for multiple lines in a blockquote,
// 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 />",
});
// 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
// out any <p/> tag (no matter where it is in the tree) which doesn't
// contain \n's.
// On the flip side, <p/>s are quite opionated and restricted on where
// you can nest them.
//
// Let's try sending with <p/>s anyway for now, though.
const realParagraph = renderer.paragraph;
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
// p tag. If, however, we have multiple nodes, each gets
// its own p tag to keep them as separate paragraphs.
// 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)) {
realParagraph.call(this, node, entering);
}
};
renderer.link = function (node, entering) {
const attrs = this.attrs(node);
if (entering && node.destination) {
attrs.push(["href", this.esc(node.destination)]);
if (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"]);
}
this.tag("a", attrs);
} else {
this.tag("/a");
}
};
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) {
/*
// as with `paragraph`, we only insert line breaks
// if there are multiple lines in the markdown.
const isMultiLine = is_multi_line(node);
if (isMultiLine) this.cr();
*/
renderer.html_inline(node);
/*
if (isMultiLine) this.cr();
*/
};
return renderer.render(this.parsed);
}
/*
* Render the markdown message to plain text. That is, essentially
* just remove any backslashes escaping what would otherwise be
* markdown syntax
* (to fix https://github.com/vector-im/element-web/issues/2870).
*
* N.B. this does **NOT** render arbitrary MD to plain text - only MD
* which has no formatting. Otherwise it emits HTML(!).
*/
public toPlaintext(): string {
const renderer = new commonmark.HtmlRenderer({ safe: false });
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");
}
}
};
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);
}
}

471
src/MatrixClientPeg.ts Normal file
View File

@@ -0,0 +1,471 @@
/*
Copyright 2024 New Vector Ltd.
Copyright 2019-2023 The Matrix.org Foundation C.I.C.
Copyright 2017, 2018 , 2019 New Vector Ltd
Copyright 2017 Vector Creations Ltd.
Copyright 2015, 2016 OpenMarket Ltd
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only
Please see LICENSE files in the repository root for full details.
*/
import {
EventTimeline,
EventTimelineSet,
ICreateClientOpts,
IStartClientOpts,
MatrixClient,
MemoryStore,
PendingEventOrdering,
RoomNameState,
RoomNameType,
TokenRefreshFunction,
} from "matrix-js-sdk/src/matrix";
import { VerificationMethod } from "matrix-js-sdk/src/types";
import * as utils from "matrix-js-sdk/src/utils";
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 MatrixClientBackedSettingsHandler from "./settings/handlers/MatrixClientBackedSettingsHandler";
import * as StorageManager from "./utils/StorageManager";
import IdentityAuthClient from "./IdentityAuthClient";
import { crossSigningCallbacks } from "./SecurityManager";
import { SlidingSyncManager } from "./SlidingSyncManager";
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 { setDeviceIsolationMode } from "./settings/controllers/DeviceIsolationModeController.ts";
export interface IMatrixClientCreds {
homeserverUrl: string;
identityServerUrl?: string;
userId: string;
deviceId?: string;
accessToken: string;
refreshToken?: string;
guest?: boolean;
pickleKey?: string;
freshLogin?: boolean;
}
export interface MatrixClientPegAssignOpts {
/**
* If we are using Rust crypto, a key with which to encrypt the indexeddb.
*
* If provided, it must be exactly 32 bytes of data. If both this and
* {@link MatrixClientPegAssignOpts.rustCryptoStorePassword} are undefined,
* the store will be unencrypted.
*/
rustCryptoStoreKey?: Uint8Array;
/**
* If we are using Rust crypto, a password which will be used to derive a key to encrypt the store with.
*
* An alternative to {@link MatrixClientPegAssignOpts.rustCryptoStoreKey}. Ignored if `rustCryptoStoreKey` is set.
*
* Deriving a key from a password is (deliberately) a slow operation, so prefer to pass a `rustCryptoStoreKey`
* directly where possible.
*/
rustCryptoStorePassword?: string;
}
/**
* Holds the current instance of the `MatrixClient` to use across the codebase.
* Looking for an `MatrixClient`? Just look for the `MatrixClientPeg` on the peg
* board. "Peg" is the literal meaning of something you hang something on. So
* you'll find a `MatrixClient` hanging on the `MatrixClientPeg`.
*/
export interface IMatrixClientPeg {
/**
* The opts used to start the client
*/
opts: IStartClientOpts;
/**
* Get the current MatrixClient, if any
*/
get(): MatrixClient | null;
/**
* Get the current MatrixClient, throwing an error if there isn't one
*/
safeGet(): MatrixClient;
/**
* Unset the current MatrixClient
*/
unset(): void;
/**
* Prepare the MatrixClient for use, including initialising the store and crypto, but do not start it.
*/
assign(opts?: MatrixClientPegAssignOpts): Promise<IStartClientOpts>;
/**
* Prepare the MatrixClient for use, including initialising the store and crypto, and start it.
*/
start(opts?: MatrixClientPegAssignOpts): Promise<void>;
/**
* 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
* can send them to the welcome user (obviously this doesn't
* guarantee they'll get a chat with the welcome user).
*
* @param {string} uid The user ID of the user we've just registered
*/
setJustRegisteredUserId(uid: string | null): void;
/**
* Returns true if the current user has just been registered by this
* client as determined by setJustRegisteredUserId()
*
* @returns {bool} True if user has just been registered
*/
currentUserIsJustRegistered(): boolean;
/**
* If the current user has been registered by this device then this
* returns a boolean of whether it was within the last N hours given.
*/
userRegisteredWithinLastHours(hours: number): boolean;
/**
* If the current user has been registered by this device then this
* returns a boolean of whether it was after a given timestamp.
*/
userRegisteredAfter(date: Date): boolean;
/**
* Replace this MatrixClientPeg's client with a client instance that has
* 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, tokenRefreshFunction?: TokenRefreshFunction): void;
}
/**
* Wrapper object for handling the js-sdk Matrix Client object in the react-sdk
* Handles the creation/initialisation of client objects.
* This module provides a singleton instance of this class so the 'current'
* Matrix Client object is available easily.
*/
class MatrixClientPegClass implements IMatrixClientPeg {
// These are the default options used when when the
// client is started in 'start'. These can be altered
// at any time up to after the 'will_start_client'
// event is finished processing.
public opts: IStartClientOpts = {
initialSyncLimit: 20,
};
private matrixClient: MatrixClient | null = null;
private justRegisteredUserId: string | null = null;
public get(): MatrixClient | null {
return this.matrixClient;
}
public safeGet(): MatrixClient {
if (!this.matrixClient) {
throw new UserFriendlyError("error_user_not_logged_in");
}
return this.matrixClient;
}
public unset(): void {
this.matrixClient = null;
MatrixActionCreators.stop();
}
public setJustRegisteredUserId(uid: string | null): void {
this.justRegisteredUserId = uid;
if (uid) {
const registrationTime = Date.now().toString();
window.localStorage.setItem("mx_registration_time", registrationTime);
}
}
public currentUserIsJustRegistered(): boolean {
return !!this.matrixClient && this.matrixClient.credentials.userId === this.justRegisteredUserId;
}
public userRegisteredWithinLastHours(hours: number): boolean {
if (hours <= 0) {
return false;
}
try {
const registrationTime = parseInt(window.localStorage.getItem("mx_registration_time")!, 10);
const diff = Date.now() - registrationTime;
return diff / 36e5 <= hours;
} catch (e) {
return false;
}
}
public userRegisteredAfter(timestamp: Date): boolean {
try {
const registrationTime = parseInt(window.localStorage.getItem("mx_registration_time")!, 10);
return timestamp.getTime() <= registrationTime;
} catch (e) {
return false;
}
}
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();
};
/**
* Implementation of {@link IMatrixClientPeg.assign}.
*/
public async assign(assignOpts: MatrixClientPegAssignOpts = {}): Promise<IStartClientOpts> {
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);
this.matrixClient.store = new MemoryStore({
localStorage: localStorage,
});
} else {
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
if (!SettingsStore.getValue("lowBandwidth")) {
await this.initClientCrypto(assignOpts.rustCryptoStoreKey, assignOpts.rustCryptoStorePassword);
}
const opts = utils.deepCopy(this.opts);
// the react sdk doesn't work without this, so don't allow
opts.pendingEventOrdering = PendingEventOrdering.Detached;
opts.lazyLoadMembers = true;
opts.clientWellKnownPollPeriod = 2 * 60 * 60; // 2 hours
opts.threadSupport = true;
if (SettingsStore.getValue("feature_sliding_sync")) {
opts.slidingSync = await SlidingSyncManager.instance.setup(this.matrixClient);
} else {
SlidingSyncManager.instance.checkSupport(this.matrixClient);
}
// 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
*
* @param rustCryptoStoreKey - A key with which to encrypt the rust crypto indexeddb.
* If provided, it must be exactly 32 bytes of data. If both this and `rustCryptoStorePassword` are
* undefined, the store will be unencrypted.
*
* @param rustCryptoStorePassword - An alternative to `rustCryptoStoreKey`. Ignored if `rustCryptoStoreKey` is set.
* A password which will be used to derive a key to encrypt the store with. Deriving a key from a password is
* (deliberately) a slow operation, so prefer to pass a `rustCryptoStoreKey` directly where possible.
*/
private async initClientCrypto(rustCryptoStoreKey?: Uint8Array, rustCryptoStorePassword?: string): Promise<void> {
if (!this.matrixClient) {
throw new Error("createClient must be called first");
}
if (!rustCryptoStoreKey && !rustCryptoStorePassword) {
logger.error("Warning! Not using an encryption key for rust crypto store.");
}
// Record the fact that we used the Rust crypto stack with this client. This just guards against people
// rolling back to versions of EW that did not default to Rust crypto (which would lead to an error, since
// we cannot migrate from Rust to Legacy crypto).
await SettingsStore.setValue(Features.RustCrypto, null, SettingLevel.DEVICE, true);
await this.matrixClient.initRustCrypto({
storageKey: rustCryptoStoreKey,
storagePassword: rustCryptoStorePassword,
});
StorageManager.setCryptoInitialised(true);
setDeviceIsolationMode(this.matrixClient, SettingsStore.getValue("feature_exclude_insecure_devices"));
// TODO: device dehydration and whathaveyou
return;
}
/**
* Implementation of {@link IMatrixClientPeg.start}.
*/
public async start(assignOpts?: MatrixClientPegAssignOpts): Promise<void> {
const opts = await this.assign(assignOpts);
logger.log(`MatrixClientPeg: really starting MatrixClient`);
await this.matrixClient!.startClient(opts);
logger.log(`MatrixClientPeg: MatrixClient started`);
}
private namesToRoomName(names: string[], count: number): string | undefined {
const countWithoutMe = count - 1;
if (!names.length) {
return _t("empty_room");
}
if (names.length === 1 && countWithoutMe <= 1) {
return names[0];
}
}
private memberNamesToRoomName(names: string[], count: number): string {
const name = this.namesToRoomName(names, count);
if (name) return name;
if (names.length === 2 && count === 2) {
return formatList(names);
}
return formatList(names, 1);
}
private inviteeNamesToRoomName(names: string[], count: number): string {
const name = this.namesToRoomName(names, count);
if (name) return name;
if (names.length === 2 && count === 2) {
return _t("inviting_user1_and_user2", {
user1: names[0],
user2: names[1],
});
}
return _t("inviting_user_and_n_others", {
user: names[0],
count: count - 1,
});
}
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"),
// 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.
iceCandidatePoolSize: 20,
verificationMethods: [
VerificationMethod.Sas,
VerificationMethod.ShowQrCode,
VerificationMethod.Reciprocate,
],
identityServer: new IdentityAuthClient(),
// These are always installed regardless of the labs flag so that cross-signing features
// can toggle on without reloading and also be accessed immediately after login.
cryptoCallbacks: { ...crossSigningCallbacks },
roomNameGenerator: (_: string, state: RoomNameState) => {
switch (state.type) {
case RoomNameType.Generated:
switch (state.subtype) {
case "Inviting":
return this.inviteeNamesToRoomName(state.names, state.count);
default:
return this.memberNamesToRoomName(state.names, state.count);
}
case RoomNameType.EmptyRoom:
if (state.oldName) {
return _t("empty_room_was_name", {
oldName: state.oldName,
});
} else {
return _t("empty_room");
}
default:
return null;
}
},
};
this.matrixClient = createMatrixClient(opts);
this.matrixClient.setGuest(Boolean(creds.guest));
const notifTimelineSet = new EventTimelineSet(undefined, {
timelineSupport: true,
pendingEvents: false,
});
// XXX: what is our initial pagination token?! it somehow needs to be synchronised with /sync.
notifTimelineSet.getLiveTimeline().setPaginationToken("", EventTimeline.BACKWARDS);
this.matrixClient.setNotifTimelineSet(notifTimelineSet);
}
}
/**
* Note: You should be using a React context with access to a client rather than
* using this, as in a multi-account world this will not exist!
*/
export const MatrixClientPeg: IMatrixClientPeg = new MatrixClientPegClass();
if (!window.mxMatrixClientPeg) {
window.mxMatrixClientPeg = MatrixClientPeg;
}

215
src/MediaDeviceHandler.ts Normal file
View File

@@ -0,0 +1,215 @@
/*
Copyright 2024 New Vector Ltd.
Copyright 2021 Šimon Brandner <simon.bra.ag@gmail.com>
Copyright 2017 Michael Telatynski <7t3chguy@gmail.com>
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only
Please see LICENSE files in the repository root for full details.
*/
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 {
AudioOutput = "audiooutput",
AudioInput = "audioinput",
VideoInput = "videoinput",
}
export type IMediaDevices = Record<MediaDeviceKindEnum, Array<MediaDeviceInfo>>;
export enum MediaDeviceHandlerEvent {
AudioOutputChanged = "audio_output_changed",
}
export default class MediaDeviceHandler extends EventEmitter {
private static internalInstance?: MediaDeviceHandler;
public static get instance(): MediaDeviceHandler {
if (!MediaDeviceHandler.internalInstance) {
MediaDeviceHandler.internalInstance = new MediaDeviceHandler();
}
return MediaDeviceHandler.internalInstance;
}
public static async hasAnyLabeledDevices(): Promise<boolean> {
const devices = await navigator.mediaDevices.enumerateDevices();
return devices.some((d) => Boolean(d.label));
}
/**
* Gets the available audio input/output and video input devices
* from the browser: a thin wrapper around mediaDevices.enumerateDevices()
* that also returns results by type of devices. Note that this requires
* user media permissions and an active stream, otherwise you'll get blank
* device labels.
*
* Once the Permissions API
* (https://developer.mozilla.org/en-US/docs/Web/API/Permissions_API)
* is ready for primetime, it might help make this simpler.
*
* @return Promise<IMediaDevices> The available media devices
*/
public static async getDevices(): Promise<IMediaDevices | undefined> {
try {
const devices = await navigator.mediaDevices.enumerateDevices();
const output: Record<MediaDeviceKindEnum, MediaDeviceInfo[]> = {
[MediaDeviceKindEnum.AudioOutput]: [],
[MediaDeviceKindEnum.AudioInput]: [],
[MediaDeviceKindEnum.VideoInput]: [],
};
devices.forEach((device) => output[device.kind].push(device));
return output;
} catch (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
*/
public static async loadDevices(): Promise<void> {
const audioDeviceId = SettingsStore.getValue("webrtc_audioinput");
const videoDeviceId = SettingsStore.getValue("webrtc_videoinput");
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 {
SettingsStore.setValue("webrtc_audiooutput", null, SettingLevel.DEVICE, deviceId);
this.emit(MediaDeviceHandlerEvent.AudioOutputChanged, deviceId);
}
/**
* This will not change the device that a potential call uses. The call will
* need to be ended and started again for this change to take effect
* @param {string} deviceId
*/
public async setAudioInput(deviceId: string): Promise<void> {
SettingsStore.setValue("webrtc_audioinput", null, SettingLevel.DEVICE, deviceId);
return MatrixClientPeg.safeGet().getMediaHandler().setAudioInput(deviceId);
}
/**
* This will not change the device that a potential call uses. The call will
* need to be ended and started again for this change to take effect
* @param {string} deviceId
*/
public async setVideoInput(deviceId: string): Promise<void> {
SettingsStore.setValue("webrtc_videoinput", null, SettingLevel.DEVICE, 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;
}
}
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");
}
public static getAudioInput(): string {
return SettingsStore.getValueAt(SettingLevel.DEVICE, "webrtc_audioinput");
}
public static getVideoInput(): string {
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
* @returns {string} the deviceId
*/
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();
}
}
public static get startWithAudioMuted(): boolean {
return SettingsStore.getValue("audioInputMuted");
}
public static set startWithAudioMuted(value: boolean) {
SettingsStore.setValue("audioInputMuted", null, SettingLevel.DEVICE, value);
}
public static get startWithVideoMuted(): boolean {
return SettingsStore.getValue("videoInputMuted");
}
public static set startWithVideoMuted(value: boolean) {
SettingsStore.setValue("videoInputMuted", null, SettingLevel.DEVICE, value);
}
}

471
src/Modal.tsx Normal file
View File

@@ -0,0 +1,471 @@
/*
Copyright 2024 New Vector Ltd.
Copyright 2020 The Matrix.org Foundation C.I.C.
Copyright 2015, 2016 OpenMarket Ltd
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only
Please see LICENSE files in the repository root for full details.
*/
import React from "react";
import ReactDOM from "react-dom";
import classNames from "classnames";
import { IDeferred, 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, { defaultDispatcher } from "./dispatcher/dispatcher";
import AsyncWrapper from "./AsyncWrapper";
import { Defaultize } from "./@types/common";
import { ActionPayload } from "./dispatcher/payloads";
const DIALOG_CONTAINER_ID = "mx_Dialog_Container";
const STATIC_DIALOG_CONTAINER_ID = "mx_Dialog_StaticContainer";
// 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?: ModalCloseReason;
onBeforeClose?(reason?: ModalCloseReason): Promise<boolean>;
onFinished: ComponentProps<C>["onFinished"];
close(...args: Parameters<ComponentProps<C>["onFinished"]>): void;
hidden?: boolean;
deferred?: IDeferred<Parameters<ComponentProps<C>["onFinished"]>>;
}
export interface IHandle<C extends ComponentType> {
finished: Promise<Parameters<ComponentProps<C>["onFinished"]>>;
close(...args: Parameters<ComponentProps<C>["onFinished"]>): void;
}
interface IOptions<C extends ComponentType> {
onBeforeClose?: IModal<C>["onBeforeClose"];
}
export enum ModalManagerEvent {
Opened = "opened",
Closed = "closed",
}
type HandlerMap = {
[ModalManagerEvent.Opened]: () => void;
[ModalManagerEvent.Closed]: () => void;
};
type ModalCloseReason = "backgroundClick";
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 = 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 = 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(): HTMLElement {
let container = document.getElementById(DIALOG_CONTAINER_ID);
if (!container) {
container = document.createElement("div");
container.id = DIALOG_CONTAINER_ID;
document.body.appendChild(container);
}
return container;
}
private static getOrCreateStaticContainer(): HTMLElement {
let container = document.getElementById(STATIC_DIALOG_CONTAINER_ID);
if (!container) {
container = document.createElement("div");
container.id = STATIC_DIALOG_CONTAINER_ID;
document.body.appendChild(container);
}
return container;
}
public constructor() {
super();
// We never unregister this, but the Modal class is a singleton so there would
// never be an opportunity to do so anyway, except in the entirely theoretical
// scenario of instantiating a non-singleton instance of the Modal class.
defaultDispatcher.register(this.onAction);
}
private onAction = (payload: ActionPayload): void => {
if (payload.action === "logout") {
this.forceCloseAllModals();
}
};
public toggleCurrentDialogVisibility(): void {
const modal = this.getCurrentModal();
if (!modal) return;
modal.hidden = !modal.hidden;
}
public hasDialogs(): boolean {
return !!this.priorityModal || !!this.staticModal || this.modals.length > 0;
}
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<C extends ComponentType>(
Element: C,
props?: ComponentProps<C>,
className?: string,
): IHandle<C> {
return this.appendDialogAsync<C>(Promise.resolve(Element), props, className);
}
/**
* DEPRECATED.
* This is used only for tests. They should be using forceCloseAllModals but that
* caused a chunk of tests to fail, so for now they continue to use this.
*
* @param reason either "backgroundClick" or undefined
* @return whether a modal was closed
*/
public closeCurrentModal(reason?: ModalCloseReason): boolean {
const modal = this.getCurrentModal();
if (!modal) {
return false;
}
modal.closeReason = reason;
modal.close();
return true;
}
/**
* Forces closes all open modals. The modals onBeforeClose function will not be
* run and the modal will not have a chance to prevent closing. Intended for
* situations like the user logging out of the app.
*/
public forceCloseAllModals(): void {
for (const modal of this.modals) {
modal.deferred?.resolve([]);
if (modal.onFinished) modal.onFinished.apply(null);
this.emitClosed();
}
this.modals = [];
this.reRender();
}
private buildModal<C extends ComponentType>(
prom: Promise<C>,
props?: ComponentProps<C>,
className?: string,
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,
} as IModal<C>;
// never call this from onFinished() otherwise it will loop
const [closeDialog, onFinishedProm] = this.getCloseFn<C>(modal, props);
// don't attempt to reuse the same AsyncWrapper for different dialogs,
// otherwise we'll get confused.
const modalCount = this.counter++;
// FIXME: If a dialog uses getDefaultProps it clobbers the onFinished
// property set here so you can't close the dialog from a button click!
modal.elem = <AsyncWrapper key={modalCount} prom={prom} {...props} onFinished={closeDialog} />;
modal.close = closeDialog;
return { modal, closeDialog, onFinishedProm };
}
private getCloseFn<C extends ComponentType>(
modal: IModal<C>,
props?: ComponentProps<C>,
): [IHandle<C>["close"], IHandle<C>["finished"]] {
modal.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;
}
}
modal.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);
}
if (this.priorityModal === modal) {
this.priorityModal = null;
// XXX: This is destructive
this.modals = [];
}
if (this.staticModal === modal) {
this.staticModal = null;
// XXX: This is destructive
this.modals = [];
}
this.reRender();
this.emitClosed();
},
modal.deferred.promise,
];
}
/**
* @callback onBeforeClose
* @param {string?} reason either "backgroundClick" or null
* @return {Promise<bool>} whether the dialog should close
*/
/**
* Open a modal view.
*
* This can be used to display a react component which is loaded as an asynchronous
* webpack component. To do this, set 'loader' as:
*
* (cb) => {
* require(['<module>'], cb);
* }
*
* @param {Promise} prom a promise which resolves with a React component
* which will be displayed as the modal view.
*
* @param {Object} props properties to pass to the displayed
* component. (We will also pass an 'onFinished' property.)
*
* @param {String} className CSS class to apply to the modal wrapper
*
* @param {boolean} isPriorityModal if true, this modal will be displayed regardless
* of other modals that are currently in the stack.
* Also, when closed, all modals will be removed
* from the stack.
* @param {boolean} isStaticModal if true, this modal will be displayed under other
* modals in the stack. When closed, all modals will
* also be removed from the stack. This is not compatible
* with being a priority modal. Only one modal can be
* static at a time.
* @param {Object} options? extra options for the dialog
* @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<C extends ComponentType>(
prom: Promise<C>,
props?: ComponentProps<C>,
className?: string,
isPriorityModal = false,
isStaticModal = false,
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;
} else if (isStaticModal) {
// This is intentionally destructive
this.staticModal = modal;
} else {
this.modals.unshift(modal);
}
this.reRender();
this.emitIfChanged(beforeModal);
return {
close: closeDialog,
finished: onFinishedProm,
};
}
private appendDialogAsync<C extends ComponentType>(
prom: Promise<C>,
props?: ComponentProps<C>,
className?: string,
): 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 emitIfChanged(beforeModal?: IModal<any>): void {
if (beforeModal !== this.getCurrentModal()) {
this.emit(ModalManagerEvent.Opened);
}
}
/**
* Emit the closed event
* @private
*/
private emitClosed(): void {
this.emit(ModalManagerEvent.Closed);
}
private onBackgroundClick = (): void => {
const modal = this.getCurrentModal();
if (!modal) {
return;
}
// we want to pass a reason to the onBeforeClose
// callback, but close is currently defined to
// pass all number of arguments to the onFinished callback
// so, pass the reason to close through a member variable
modal.closeReason = "backgroundClick";
modal.close();
modal.closeReason = undefined;
};
private getCurrentModal(): IModal<any> {
return this.priorityModal ? this.priorityModal : this.modals[0] || this.staticModal;
}
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);
if (this.modals.length === 0 && !this.priorityModal && !this.staticModal) {
// If there is no modal to render, make all of Element available
// to screen reader users again
dis.dispatch({
action: "aria_unhide_main_app",
});
ReactDOM.unmountComponentAtNode(ModalManager.getOrCreateContainer());
ReactDOM.unmountComponentAtNode(ModalManager.getOrCreateStaticContainer());
return;
}
// Hide the content outside the modal to screen reader users
// 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",
});
if (this.staticModal) {
const classes = classNames("mx_Dialog_wrapper mx_Dialog_staticWrapper", this.staticModal.className);
const staticDialog = (
<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>
</TooltipProvider>
);
ReactDOM.render(staticDialog, ModalManager.getOrCreateStaticContainer());
} else {
// This is safe to call repeatedly if we happen to do that
ReactDOM.unmountComponentAtNode(ModalManager.getOrCreateStaticContainer());
}
const modal = this.getCurrentModal();
if (modal !== this.staticModal && !modal.hidden) {
const classes = classNames("mx_Dialog_wrapper", modal.className, {
mx_Dialog_wrapperWithStaticUnder: this.staticModal,
});
const dialog = (
<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>
</TooltipProvider>
);
setTimeout(() => ReactDOM.render(dialog, ModalManager.getOrCreateContainer()), 0);
} else {
// This is safe to call repeatedly if we happen to do that
ReactDOM.unmountComponentAtNode(ModalManager.getOrCreateContainer());
}
}
}
if (!window.singletonModalManager) {
window.singletonModalManager = new ModalManager();
}
export default window.singletonModalManager;

129
src/NodeAnimator.tsx Normal file
View File

@@ -0,0 +1,129 @@
/*
Copyright 2024 New Vector Ltd.
Copyright 2022 The Matrix.org Foundation C.I.C.
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only
Please see LICENSE files in the repository root for full details.
*/
import React, { Key, MutableRefObject, ReactElement, ReactFragment, ReactInstance, ReactPortal } from "react";
import ReactDom from "react-dom";
interface IChildProps {
style: React.CSSProperties;
ref: (node: React.ReactInstance) => void;
}
interface IProps {
// either a list of child nodes, or a single child.
children: React.ReactNode;
// optional transition information for changing existing children
transition?: object;
// 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;
}
/**
* The NodeAnimator contains components and animates transitions.
* It will only pick up direct changes to properties ('left', currently), and so
* will not work for animating positional changes where the position is implicit
* from DOM order. This makes it a lot simpler and lighter: if you need fully
* automatic positional animation, look at react-shuffle or similar libraries.
*/
export default class NodeAnimator extends React.Component<IProps> {
private nodes: Record<string, ReactInstance> = {};
private children: { [key: string]: ReactElement } = {};
public static defaultProps: Partial<IProps> = {
startStyles: [],
};
public constructor(props: IProps) {
super(props);
this.updateChildren(this.props.children);
}
public componentDidUpdate(): void {
this.updateChildren(this.props.children);
}
/**
*
* @param {HTMLElement} node element to apply styles to
* @param {React.CSSProperties} styles a key/value pair of CSS properties
* @returns {void}
*/
private applyStyles(node: HTMLElement, styles: React.CSSProperties): void {
Object.entries(styles).forEach(([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) => {
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 });
}
// 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);
} else {
// new element. If we have a startStyle, use that as the style and go through
// the enter animations
const newProps: Partial<IChildProps> = {};
const restingStyle = c.props.style;
const startStyles = this.props.startStyles;
if (startStyles.length > 0) {
const startStyle = startStyles[0];
newProps.style = startStyle;
}
newProps.ref = (n) => this.collectNode(c.key!, n, restingStyle);
this.children[c.key!] = React.cloneElement(c, newProps);
}
});
}
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]);
}
// and then we animate to the resting state
window.setTimeout(() => {
this.applyStyles(domNode as HTMLElement, restingStyle);
}, 0);
}
this.nodes[k] = node;
if (this.props.innerRef) {
this.props.innerRef.current = node;
}
}
public render(): React.ReactNode {
return <>{Object.values(this.children)}</>;
}
}

543
src/Notifier.ts Normal file
View File

@@ -0,0 +1,543 @@
/*
Copyright 2024 New Vector Ltd.
Copyright 2020 The Matrix.org Foundation C.I.C.
Copyright 2017 Vector Creations Ltd
Copyright 2017 New Vector Ltd
Copyright 2015, 2016 OpenMarket Ltd
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only
Please see LICENSE files in the repository root for full details.
*/
import {
MatrixEvent,
MatrixEventEvent,
Room,
RoomEvent,
ClientEvent,
MsgType,
SyncState,
SyncStateData,
IRoomTimelineData,
M_LOCATION,
EventType,
TypedEventEmitter,
} 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 { MatrixRTCSession } from "matrix-js-sdk/src/matrixrtc";
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 SettingsStore from "./settings/SettingsStore";
import { hideToast as hideNotificationsToast } from "./toasts/DesktopNotificationsToast";
import { SettingLevel } from "./settings/SettingLevel";
import { isPushNotifyDisabled } from "./settings/controllers/NotificationControllers";
import UserActivity from "./UserActivity";
import { mediaFromMxc } from "./customisations/Media";
import ErrorDialog from "./components/views/dialogs/ErrorDialog";
import LegacyCallHandler from "./LegacyCallHandler";
import VoipUserMapper from "./VoipUserMapper";
import { SdkContextClass } from "./contexts/SDKContext";
import { localNotificationsAreSilenced, createLocalNotificationSettingsIfNeeded } from "./utils/notifications";
import { getIncomingCallToastKey, IncomingCallToast } from "./toasts/IncomingCallToast";
import ToastStore from "./stores/ToastStore";
import { VoiceBroadcastChunkEventType, VoiceBroadcastInfoEventType } from "./voice-broadcast";
import { getSenderName } from "./utils/event/getSenderName";
import { stripPlainReply } from "./utils/Reply";
import { BackgroundAudio } from "./audio/BackgroundAudio";
/*
* Dispatches:
* {
* action: "notifier_enabled",
* value: boolean
* }
*/
const MAX_PENDING_ENCRYPTED = 20;
/*
Override both the content body and the TextForEvent handler for specific msgtypes, in notifications.
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: Record<string, (event: MatrixEvent) => string | null> = {
[MsgType.KeyVerificationRequest]: (event: MatrixEvent) => {
const name = (event.sender || {}).name;
return _t("notifier|m.key.verification.request", { name });
},
[M_LOCATION.name]: (event: MatrixEvent) => {
return TextForEvent.textForLocationEvent(event)();
},
[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 enum NotifierEvent {
NotificationHiddenChange = "notification_hidden_change",
}
interface EmittedEvents {
[NotifierEvent.NotificationHiddenChange]: (hidden: boolean) => void;
}
class NotifierClass extends TypedEventEmitter<keyof EmittedEvents, EmittedEvents> {
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
private pendingEncryptedEventIds: string[] = [];
private toolbarHidden?: boolean;
private isSyncing?: boolean;
private backgroundAudio = new BackgroundAudio();
public notificationMessageForEvent(ev: MatrixEvent): string | null {
const msgType = ev.getContent().msgtype;
if (msgType && msgTypeHandlers.hasOwnProperty(msgType)) {
return msgTypeHandlers[msgType](ev);
}
return TextForEvent.textForEvent(ev, MatrixClientPeg.safeGet());
}
// XXX: exported for tests
public displayPopupNotification(ev: MatrixEvent, room: Room): void {
const plaf = PlatformPeg.get();
const cli = MatrixClientPeg.safeGet();
if (!plaf) {
return;
}
if (!plaf.supportsNotifications() || !plaf.maySendNotifications()) {
return;
}
if (localNotificationsAreSilenced(cli)) {
return;
}
let msg = this.notificationMessageForEvent(ev);
if (!msg) return;
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
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") {
// 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
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 = "";
}
let avatarUrl: string | null = null;
if (ev.sender && !SettingsStore.getValue("lowBandwidth")) {
avatarUrl = Avatar.avatarUrlForMember(ev.sender, 40, 40, "crop");
}
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);
}
}
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);
if (!content) {
return null;
}
if (typeof content.url !== "string") {
logger.warn(`${roomId} has custom notification sound event, but no url string`);
return null;
}
if (!content.url.startsWith("mxc://")) {
logger.warn(`${roomId} has custom notification sound event, but url is not a mxc url`);
return null;
}
// 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,
name: content.name,
type: content.type,
size: content.size,
};
}
// XXX: Exported for tests
public async playAudioNotification(ev: MatrixEvent, room: Room): Promise<void> {
const cli = MatrixClientPeg.safeGet();
if (localNotificationsAreSilenced(cli)) {
return;
}
// Play notification sound here
const sound = this.getSoundForRoom(room.roomId);
logger.log(`Got sound ${(sound && sound.name) || "default"} for ${room.roomId}`);
if (sound) {
await this.backgroundAudio.play(sound.url);
} else {
await this.backgroundAudio.pickFormatAndPlay("media/message", ["mp3", "ogg"]);
}
}
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;
}
public stop(): void {
if (MatrixClientPeg.get()) {
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;
}
public supportsDesktopNotifications(): boolean {
return PlatformPeg.get()?.supportsNotifications() ?? false;
}
public setEnabled(enable: boolean, callback?: () => void): void {
const plaf = PlatformPeg.get();
if (!plaf) return;
// Dev note: We don't set the "notificationsEnabled" setting to true here because it is a
// calculated value. It is determined based upon whether or not the master rule is enabled
// and other flags. Setting it here would cause a circular reference.
// make sure that we persist the current setting audio_enabled setting
// before changing anything
if (SettingsStore.isLevelSupported(SettingLevel.DEVICE)) {
SettingsStore.setValue("audioNotificationsEnabled", null, SettingLevel.DEVICE, this.isEnabled());
}
if (enable) {
// Attempt to get permission from user
plaf.requestNotificationPermission().then((result) => {
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("settings|notifications|error_permissions_denied", { brand })
: _t("settings|notifications|error_permissions_missing", {
brand,
});
Modal.createDialog(ErrorDialog, {
title: _t("settings|notifications|error_title"),
description,
});
return;
}
if (callback) callback();
PosthogAnalytics.instance.trackEvent<PermissionChangedEvent>({
eventName: "PermissionChanged",
permission: "Notification",
granted: true,
});
dis.dispatch({
action: "notifier_enabled",
value: true,
});
});
} else {
PosthogAnalytics.instance.trackEvent<PermissionChangedEvent>({
eventName: "PermissionChanged",
permission: "Notification",
granted: false,
});
dis.dispatch({
action: "notifier_enabled",
value: false,
});
}
// set the notifications_hidden flag, as the user has knowingly interacted
// with the setting we shouldn't nag them any further
this.setPromptHidden(true);
}
public isEnabled(): boolean {
return this.isPossible() && SettingsStore.getValue("notificationsEnabled");
}
public isPossible(): boolean {
const plaf = PlatformPeg.get();
if (!plaf?.supportsNotifications()) return false;
if (!plaf.maySendNotifications()) return false;
return true; // possible, but not necessarily enabled
}
public isBodyEnabled(): boolean {
return this.isEnabled() && SettingsStore.getValue("notificationBodyEnabled");
}
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");
}
public setPromptHidden(hidden: boolean, persistent = true): void {
this.toolbarHidden = hidden;
hideNotificationsToast();
// update the info to localStorage for persistent settings
if (persistent && global.localStorage) {
global.localStorage.setItem("notifications_hidden", String(hidden));
}
this.emit(NotifierEvent.NotificationHiddenChange, hidden);
}
public shouldShowPrompt(): boolean {
const client = MatrixClientPeg.get();
if (!client) {
return false;
}
const isGuest = client.isGuest();
return (
!isGuest &&
this.supportsDesktopNotifications() &&
!isPushNotifyDisabled() &&
!this.isEnabled() &&
!this.isPromptHidden()
);
}
private isPromptHidden(): boolean {
// Check localStorage for any such meta data
if (global.localStorage) {
return global.localStorage.getItem("notifications_hidden") === "true";
}
return !!this.toolbarHidden;
}
// 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) {
this.isSyncing = false;
}
// wait for first non-cached sync to complete
if (![SyncState.Stopped, SyncState.Error].includes(state) && !data?.fromCache) {
createLocalNotificationSettingsIfNeeded(MatrixClientPeg.safeGet());
}
};
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.safeGet().getUserId()) return;
if (data.timeline.getTimelineSet().threadListType !== null) return; // Ignore events on the thread list generated timelines
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()!);
// don't let the list fill up indefinitely
while (this.pendingEncryptedEventIds.length > MAX_PENDING_ENCRYPTED) {
this.pendingEncryptedEventIds.shift();
}
return;
}
this.evaluateEvent(ev);
};
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()!);
if (idx === -1) return;
this.pendingEncryptedEventIds.splice(idx, 1);
this.evaluateEvent(ev);
};
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
// the receipt comes before or after an event, so we can't
// do this. Instead, clear all notifications for a room once
// there are no notifs left in that room., which is not quite
// as good but it's something.
const plaf = PlatformPeg.get();
if (!plaf) return;
if (this.notifsByRoom[room.roomId] === undefined) return;
for (const notif of this.notifsByRoom[room.roomId]) {
plaf.clearNotification(notif);
}
delete this.notifsByRoom[room.roomId];
}
};
// 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);
if (nativeRoomId) {
roomId = nativeRoomId;
}
}
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.safeGet().getPushActionsForEvent(ev);
if (actions?.notify) {
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 isViewingThread = store.getThreadId() === threadId;
const isViewingEventTimeline = isViewingRoom && (!threadId || isViewingThread);
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);
}
if (actions.tweaks.sound && this.isAudioEnabled()) {
PlatformPeg.get()?.loudNotification(ev, room);
this.playAudioNotification(ev, room);
}
}
}
/**
* Some events require special handling such as showing in-app toasts
*/
private performCustomEventHandling(ev: MatrixEvent): void {
const cli = MatrixClientPeg.safeGet();
const room = cli.getRoom(ev.getRoomId());
const thisUserHasConnectedDevice =
room && MatrixRTCSession.callMembershipsForRoom(room).some((m) => m.sender === cli.getUserId());
if (EventType.CallNotify === ev.getType() && (ev.getAge() ?? 0) < 10000 && !thisUserHasConnectedDevice) {
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(content.call_id, roomId),
priority: 100,
component: IncomingCallToast,
bodyClassName: "mx_IncomingCallToast",
props: { notifyEvent: ev },
});
}
}
}
if (!window.mxNotifier) {
window.mxNotifier = new NotifierClass();
}
export default window.mxNotifier;
export const Notifier: NotifierClass = window.mxNotifier;

17
src/PageTypes.ts Normal file
View File

@@ -0,0 +1,17 @@
/*
Copyright 2024 New Vector Ltd.
Copyright 2017 Vector Creations Ltd
Copyright 2015, 2016 OpenMarket Ltd
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only
Please see LICENSE files in the repository root for full details.
*/
/** The types of page which can be shown by the LoggedInView */
enum PageType {
HomePage = "home_page",
RoomView = "room_view",
UserView = "user_view",
}
export default PageType;

108
src/PasswordReset.ts Normal file
View File

@@ -0,0 +1,108 @@
/*
Copyright 2024 New Vector Ltd.
Copyright 2019-2022 The Matrix.org Foundation C.I.C.
Copyright 2015, 2016 OpenMarket Ltd
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only
Please see LICENSE files in the repository root for full details.
*/
import { createClient, IRequestTokenResponse, MatrixClient } from "matrix-js-sdk/src/matrix";
import { _t } from "./languageHandler";
/**
* Allows a user to reset their password on a homeserver.
*
* This involves getting an email token from the identity server to "prove" that
* the client owns the given email address, which is then passed to the password
* API on the homeserver in question with the new password.
*/
export default class PasswordReset {
private client: MatrixClient;
private clientSecret: string;
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.
*/
public constructor(homeserverUrl: string, identityUrl: string) {
this.client = createClient({
baseUrl: homeserverUrl,
idBaseUrl: identityUrl,
});
this.clientSecret = this.client.generateClientSecret();
}
/**
* Request a password reset token.
* This will trigger a side-effect of sending an email to the provided email address.
*/
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;
}
public async setNewPassword(password: string): Promise<void> {
this.password = password;
await this.checkEmailLinkClicked();
}
/**
* Checks if the email link has been clicked by attempting to change the password
* for the mxid linked to the email.
* @return {Promise} Resolves if the password was reset. Rejects with an object
* with a "message" property which contains a human-readable message detailing why
* the reset failed, e.g. "There is no mapped matrix user ID for the given email address".
*/
public async checkEmailLinkClicked(): Promise<void> {
const creds = {
sid: this.sessionId,
client_secret: this.clientSecret,
};
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",
threepid_creds: creds,
},
this.password,
this.logoutDevices,
);
} catch (err: any) {
if (err.httpStatus === 401) {
err.message = _t("settings|general|add_email_failed_verification");
} else if (err.httpStatus === 404) {
err.message = _t("auth|reset_password_email_not_associated");
} else if (err.httpStatus) {
err.message += ` (Status ${err.httpStatus})`;
}
throw err;
}
}
}

53
src/PlatformPeg.ts Normal file
View File

@@ -0,0 +1,53 @@
/*
Copyright 2024 New Vector Ltd.
Copyright 2020 The Matrix.org Foundation C.I.C.
Copyright 2016 OpenMarket Ltd
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only
Please see LICENSE files in the repository root for full details.
*/
import BasePlatform from "./BasePlatform";
import defaultDispatcher from "./dispatcher/dispatcher";
import { Action } from "./dispatcher/actions";
import { PlatformSetPayload } from "./dispatcher/payloads/PlatformSetPayload";
/*
* Holds the current instance of the `Platform` to use across the codebase.
* Looking for an `Platform`? Just look for the `PlatformPeg` on the peg board.
* "Peg" is the literal meaning of something you hang something on. So you'll
* find a `Platform` hanging on the `PlatformPeg`.
*
* Used by the code to do anything specific to the platform we're running on
* (eg. web, electron). Platforms are provided by the app layer. This allows the
* app layer to set a Platform without necessarily having to have a MatrixChat
* object.
*/
export class PlatformPeg {
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(): BasePlatform | null {
return this.platform;
}
/**
* 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): void {
this.platform = platform;
defaultDispatcher.dispatch<PlatformSetPayload>({
action: Action.PlatformSet,
platform,
});
}
}
if (!window.mxPlatformPeg) {
window.mxPlatformPeg = new PlatformPeg();
}
export default window.mxPlatformPeg;

26
src/PlaybackEncoder.ts Normal file
View File

@@ -0,0 +1,26 @@
/*
Copyright 2024 New Vector Ltd.
Copyright 2024 The Matrix.org Foundation C.I.C.
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only
Please see LICENSE files in the repository root for full details.
*/
// @ts-ignore - `.ts` is needed here to make TS happy
import { Request, Response } from "./workers/playback.worker";
import { WorkerManager } from "./WorkerManager";
import playbackWorkerFactory from "./workers/playbackWorkerFactory";
export class PlaybackEncoder {
private static internalInstance = new PlaybackEncoder();
public static get instance(): PlaybackEncoder {
return PlaybackEncoder.internalInstance;
}
private readonly worker = new WorkerManager<Request, Response>(playbackWorkerFactory());
public getPlaybackWaveform(input: Float32Array): Promise<number[]> {
return this.worker.call({ data: Array.from(input) }).then((resp) => resp.waveform);
}
}

447
src/PosthogAnalytics.ts Normal file
View File

@@ -0,0 +1,447 @@
/*
Copyright 2024 New Vector Ltd.
Copyright 2021 The Matrix.org Foundation C.I.C.
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only
Please see LICENSE files in the repository root for full details.
*/
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 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.
*
* Anonymity behaviour is as follows:
*
* - If Posthog isn't configured in `config.json`, events are not sent.
* - If [Do Not Track](https://developer.mozilla.org/en-US/docs/Web/API/Navigator/doNotTrack) is
* enabled, events are not sent (this detection is built into posthog and turned on via the
* `respect_dnt` flag being passed to `posthog.init`).
* - If the `feature_pseudonymous_analytics_opt_in` labs flag is `true`, track pseudonomously by maintaining
* a randomised analytics ID in account_data for that user (shared between devices) and sending it to posthog to
identify the user.
* - Otherwise, if the existing `analyticsOptIn` flag is `true`, track anonymously, i.e. do not identify the user
using any identifier that would be consistent across devices.
* - If both flags are false or not set, events are not sent.
*/
export interface IPosthogEvent {
// The event name that will be used by PostHog. Event names should use camelCase.
eventName: string;
// do not allow these to be sent manually, we enqueue them all for caching purposes
$set?: void;
$set_once?: void;
}
export enum Anonymity {
Disabled,
Anonymous,
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",
]);
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://")) {
pathname = "/<redacted_file_scheme_url>/";
}
let hashStr;
if (hash == "") {
hashStr = "";
} else {
let [beforeFirstSlash, screen] = hash.split("/");
if (!whitelistedScreens.has(screen)) {
screen = "<redacted_screen_name>";
}
hashStr = `${beforeFirstSlash}/${screen}/<redacted>`;
}
return origin + pathname + hashStr;
}
interface PlatformProperties {
appVersion: string;
appPlatform: string;
}
export class PosthogAnalytics {
/* Wrapper for Posthog analytics.
* 3 modes of anonymity are supported, governed by this.anonymity
* - Anonymity.Disabled means *no data* is passed to posthog
* - Anonymity.Anonymous means no identifier is passed to posthog
* - Anonymity.Pseudonymous means an analytics ID stored in account_data and shared between devices
* is passed to posthog.
*
* To update anonymity, call updateAnonymityFromSettings() or you can set it directly via setAnonymity().
*
* To pass an event to Posthog:
*
* 1. Declare a type for the event, extending IAnonymousEvent or IPseudonymousEvent.
* 2. Call the appropriate track*() method. Pseudonymous events will be dropped when anonymity is
* Anonymous or Disabled; Anonymous events will be dropped when anonymity is Disabled.
*/
private anonymity = Anonymity.Disabled;
// set true during the constructor if posthog config is present, otherwise false
private readonly enabled: boolean = false;
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) {
this._instance = new PosthogAnalytics(posthog);
}
return this._instance;
}
public constructor(private readonly posthog: PostHog) {
const posthogConfig = SdkConfig.getObject("posthog");
if (posthogConfig) {
this.posthog.init(posthogConfig.get("project_api_key"), {
api_host: posthogConfig.get("api_host"),
autocapture: false,
mask_all_text: true,
mask_all_element_attributes: true,
// This only triggers on page load, which for our SPA isn't particularly useful.
// Plus, the .capture call originating from somewhere in posthog makes it hard
// to redact URLs, which requires async code.
//
// To raise this manually, just call .capture("$pageview") or posthog.capture_pageview.
capture_pageview: false,
sanitize_properties: this.sanitizeProperties,
respect_dnt: true,
advanced_disable_decide: true,
});
this.enabled = true;
} 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: 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.
// See utils.js _.info.properties in posthog-js.
if (eventName === "$pageview") {
this.lastScreen = properties["$current_url"];
}
// We inject a screen identifier in $current_url as per https://posthog.com/tutorials/spa
properties["$current_url"] = this.lastScreen;
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;
// drop device ID, which is a UUID persisted in local storage
properties["$device_id"] = null;
}
return properties;
};
private registerSuperProperties(properties: Properties): void {
if (this.enabled) {
this.posthog.register(properties);
}
}
private static async getPlatformProperties(): Promise<Partial<PlatformProperties>> {
const platform = PlatformPeg.get();
let appVersion: string | undefined;
try {
appVersion = await platform?.getAppVersion();
} catch (e) {
// this happens if no version is set i.e. in dev
appVersion = "unknown";
}
return {
appVersion,
appPlatform: platform?.getHumanReadableName(),
};
}
// 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 }, options);
this.propertiesForNextEvent = {};
}
public isEnabled(): boolean {
return this.enabled;
}
public setAnonymity(anonymity: Anonymity): void {
// Update this.anonymity.
// This is public for testing purposes, typically you want to call updateAnonymityFromSettings
// to ensure this value is in step with the user's settings.
if (this.enabled && (anonymity == Anonymity.Disabled || anonymity == Anonymity.Anonymous)) {
// when transitioning to Disabled or Anonymous ensure we clear out any prior state
// set in posthog e.g. distinct ID
this.posthog.reset();
// Restore any previously set platform super properties
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("");
}
public async identifyUser(client: MatrixClient, analyticsIdGenerator: () => string): Promise<void> {
if (this.anonymity == Anonymity.Pseudonymous) {
// Check the user's account_data for an analytics ID to use. Storing the ID in account_data allows
// different devices to send the same ID.
try {
const accountData = await client.getAccountDataFromServer(PosthogAnalytics.ANALYTICS_EVENT_TYPE);
let analyticsID = accountData?.id;
if (!analyticsID) {
// Couldn't retrieve an analytics ID from user settings, so create one and set it on the server.
// Note there's a race condition here - if two devices do these steps at the same time, last write
// wins, and the first writer will send tracking with an ID that doesn't match the one on the server
// 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),
);
}
if (this.posthog.get_distinct_id() === analyticsID) {
// No point identifying again
return;
}
if (this.posthog.persistence?.get_property("$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);
}
}
}
public getAnonymity(): Anonymity {
return this.anonymity;
}
public logout(): void {
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?: CaptureOptions): void {
if (this.anonymity == Anonymity.Disabled || this.anonymity == Anonymity.Anonymous) return;
this.capture(eventName, properties, options);
}
public setProperty<K extends keyof UserProperties>(key: K, value: UserProperties[K]): void {
if (this.userPropertyCache[key] === value) return; // nothing to do
this.userPropertyCache[key] = value;
if (!this.propertiesForNextEvent["$set"]) {
this.propertiesForNextEvent["$set"] = {};
}
this.propertiesForNextEvent["$set"][key] = value;
}
public setPropertyOnce<K extends keyof UserProperties>(key: K, value: UserProperties[K]): void {
if (this.userPropertyCache[key]) return; // nothing to do
this.userPropertyCache[key] = value;
if (!this.propertiesForNextEvent["$set_once"]) {
this.propertiesForNextEvent["$set_once"] = {};
}
this.propertiesForNextEvent["$set_once"][key] = value;
}
public async updatePlatformSuperProperties(): Promise<void> {
// Update super properties in posthog with our platform (app version, platform).
// These properties will be subsequently passed in every event.
//
// This only needs to be done once per page lifetime. Note that getPlatformProperties
// is async and can involve a network request if we are running in a browser.
this.platformSuperProperties = await PosthogAnalytics.getPlatformProperties();
this.registerSuperProperties(this.platformSuperProperties);
}
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(client, PosthogAnalytics.getRandomAnalyticsId);
if (MatrixClientPeg.currentUserIsJustRegistered()) {
this.trackNewUserEvent();
}
}
if (anonymity !== Anonymity.Disabled) {
await this.updatePlatformSuperProperties();
this.updateCryptoSuperProperty();
}
}
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
// * On login
// * When another device changes account data
// * 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)
this.watchSettingRef = SettingsStore.watchSetting(
"pseudonymousAnalyticsOptIn",
null,
(originalSettingName, changedInRoomId, atLevel, newValueAtLevel, newValue) => {
this.updateAnonymityFromSettings(client, !!newValue);
},
);
}
public setAuthenticationType(authenticationType: Signup["authenticationType"]): void {
this.authenticationType = authenticationType;
}
private trackNewUserEvent(): void {
// This is the only event that could have occured before analytics opt-in
// 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: 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,
);
}
}

134
src/PosthogTrackers.ts Normal file
View File

@@ -0,0 +1,134 @@
/*
Copyright 2024 New Vector Ltd.
Copyright 2022 The Matrix.org Foundation C.I.C.
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only
Please see LICENSE files in the repository root for full details.
*/
import { PureComponent, SyntheticEvent } from "react";
import { WebScreen as ScreenEvent } from "@matrix-org/analytics-events/types/typescript/WebScreen";
import { Interaction as InteractionEvent } from "@matrix-org/analytics-events/types/typescript/Interaction";
import { PinUnpinAction } from "@matrix-org/analytics-events/types/typescript/PinUnpinAction";
import PageType from "./PageTypes";
import Views from "./Views";
import { PosthogAnalytics } from "./PosthogAnalytics";
export type ScreenName = ScreenEvent["$current_url"];
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",
[Views.USE_CASE_SELECTION]: "UseCaseSelection",
[Views.FORGOT_PASSWORD]: "ForgotPassword",
[Views.COMPLETE_SECURITY]: "CompleteSecurity",
[Views.E2E_SETUP]: "E2ESetup",
[Views.SOFT_LOGOUT]: "SoftLogout",
[Views.LOCK_STOLEN]: "SessionLockStolen",
};
const loggedInPageTypeMap: Record<PageType, ScreenName> = {
[PageType.HomePage]: "Home",
[PageType.RoomView]: "Room",
[PageType.UserView]: "User",
};
export default class PosthogTrackers {
private static internalInstance: PosthogTrackers;
public static get instance(): PosthogTrackers {
if (!PosthogTrackers.internalInstance) {
PosthogTrackers.internalInstance = new PosthogTrackers();
}
return PosthogTrackers.internalInstance;
}
private view: Views = Views.LOADING;
private pageType?: PageType;
private override?: ScreenName;
public trackPageChange(view: Views, pageType: PageType | undefined, durationMs: number): void {
this.view = view;
this.pageType = pageType;
if (this.override) return;
this.trackPage(durationMs);
}
private trackPage(durationMs?: number): void {
const screenName =
this.view === Views.LOGGED_IN ? loggedInPageTypeMap[this.pageType!] : notLoggedInMap[this.view];
PosthogAnalytics.instance.trackEvent<ScreenEvent>({
eventName: "$pageview",
$current_url: screenName,
durationMs,
});
}
public trackOverride(screenName: ScreenName): void {
if (!screenName) return;
this.override = screenName;
PosthogAnalytics.instance.trackEvent<ScreenEvent>({
eventName: "$pageview",
$current_url: screenName,
});
}
public clearOverride(screenName: ScreenName): void {
if (screenName !== this.override) return;
this.override = undefined;
this.trackPage();
}
public static trackInteraction(name: InteractionName, ev?: SyntheticEvent | Event, index?: number): void {
let interactionType: InteractionEvent["interactionType"];
if (ev?.type === "click") {
interactionType = "Pointer";
} else if (ev?.type.startsWith("key")) {
interactionType = "Keyboard";
}
PosthogAnalytics.instance.trackEvent<InteractionEvent>({
eventName: "Interaction",
interactionType,
index,
name,
});
}
/**
* Track a pin or unpin action on a message.
* @param kind - Is pin or unpin.
* @param from - From where the action is triggered.
*/
public static trackPinUnpinMessage(kind: PinUnpinAction["kind"], from: PinUnpinAction["from"]): void {
PosthogAnalytics.instance.trackEvent<PinUnpinAction>({
eventName: "PinUnpinAction",
kind,
from,
});
}
}
export class PosthogScreenTracker extends PureComponent<{ screenName: ScreenName }> {
public componentDidMount(): void {
PosthogTrackers.instance.trackOverride(this.props.screenName);
}
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);
}
public componentWillUnmount(): void {
PosthogTrackers.instance.clearOverride(this.props.screenName);
}
public render(): React.ReactNode {
return null; // no need to render anything, we just need to hook into the React lifecycle
}
}

101
src/Presence.ts Normal file
View File

@@ -0,0 +1,101 @@
/*
Copyright 2024 New Vector Ltd.
Copyright 2019 The Matrix.org Foundation C.I.C.
Copyright 2018 New Vector Ltd
Copyright 2015, 2016 OpenMarket Ltd
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only
Please see LICENSE files in the repository root for full details.
*/
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 { 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
class Presence {
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(): 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(SetPresence.Unavailable);
} catch (e) {
/* aborted, stop got called */
}
}
}
/**
* Stop tracking user activity
*/
public stop(): void {
if (this.dispatcherRef) {
dis.unregister(this.dispatcherRef);
this.dispatcherRef = null;
}
if (this.unavailableTimer) {
this.unavailableTimer.abort();
this.unavailableTimer = null;
}
}
/**
* Get the current presence state.
* @returns {string} the presence state (see PRESENCE enum)
*/
public getState(): SetPresence | null {
return this.state;
}
private onAction = (payload: ActionPayload): void => {
if (payload.action === "user_activity") {
this.setState(SetPresence.Online);
this.unavailableTimer?.restart();
}
};
/**
* Set the presence state.
* If the state has changed, the homeserver will be notified.
* @param {string} newState the new presence state (see PRESENCE enum)
*/
private async setState(newState: SetPresence): Promise<void> {
if (newState === this.state) {
return;
}
const oldState = this.state;
this.state = newState;
if (MatrixClientPeg.safeGet().isGuest()) {
return; // don't try to set presence when a guest; it won't work.
}
try {
await MatrixClientPeg.safeGet().setSyncPresence(this.state);
logger.debug("Presence:", newState);
} catch (err) {
logger.error("Failed to set presence:", err);
this.state = oldState;
}
}
}
export default new Presence();

75
src/Registration.tsx Normal file
View File

@@ -0,0 +1,75 @@
/*
Copyright 2018-2024 New Vector Ltd.
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only
Please see LICENSE files in the repository root for full details.
*/
/**
* Utility code for registering with a homeserver
* Note that this is currently *not* used by the actual
* registration code.
*/
import React from "react";
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
export const SAFE_LOCALPART_REGEX = /^[a-z0-9=_\-./]+$/;
/**
* Starts either the ILAG or full registration flow, depending
* on what the HS supports
*
* @param {object} options
* @param {bool} options.go_home_on_cancel
* If true, goes to the home page if the user cancels the action
* @param {bool} options.go_welcome_on_cancel
* If true, goes to the welcome page if the user cancels the action
* @param {bool} options.screen_after
* If present the screen to redirect to after a successful login or register.
*/
export async function startAnyRegistrationFlow(
// eslint-disable-next-line camelcase
options: { go_home_on_cancel?: boolean; go_welcome_on_cancel?: boolean; screen_after?: boolean } = {},
): Promise<void> {
const modal = Modal.createDialog(QuestionDialog, {
hasCancelButton: true,
quitOnly: true,
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_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" });
}
},
});
}

58
src/Resend.ts Normal file
View File

@@ -0,0 +1,58 @@
/*
Copyright 2024 New Vector Ltd.
Copyright 2015-2021 The Matrix.org Foundation C.I.C.
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only
Please see LICENSE files in the repository root for full details.
*/
import { MatrixEvent, EventStatus, Room, MatrixClient } from "matrix-js-sdk/src/matrix";
import { logger } from "matrix-js-sdk/src/logger";
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(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(room.client, 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);
}
}

28
src/Roles.ts Normal file
View File

@@ -0,0 +1,28 @@
/*
Copyright 2024 New Vector Ltd.
Copyright 2017 Vector Creations Ltd
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only
Please see LICENSE files in the repository root for full details.
*/
import { _t } from "./languageHandler";
export function levelRoleMap(usersDefault: number): Record<number | "undefined", string> {
return {
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"),
};
}
export function textualPowerLevel(level: number, usersDefault: number): string {
const LEVEL_ROLE_MAP = levelRoleMap(usersDefault);
if (LEVEL_ROLE_MAP[level]) {
return LEVEL_ROLE_MAP[level];
} else {
return _t("power_level|custom", { level });
}
}

27
src/RoomAliasCache.ts Normal file
View File

@@ -0,0 +1,27 @@
/*
Copyright 2024 New Vector Ltd.
Copyright 2019-2021 The Matrix.org Foundation C.I.C.
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only
Please see LICENSE files in the repository root for full details.
*/
/**
* This is meant to be a cache of room alias to room ID so that moving between
* rooms happens smoothly (for example using browser back / forward buttons).
*
* For the moment, it's in memory only and so only applies for the current
* session for simplicity, but could be extended further in the future.
*
* A similar thing could also be achieved via `pushState` with a state object,
* but keeping it separate like this seems easier in case we do want to extend.
*/
const aliasToIDMap = new Map<string, string>();
export function storeRoomAliasInCache(alias: string, id: string): void {
aliasToIDMap.set(alias, id);
}
export function getCachedRoomIDForAlias(alias: string): string | undefined {
return aliasToIDMap.get(alias);
}

193
src/RoomInvite.tsx Normal file
View File

@@ -0,0 +1,193 @@
/*
Copyright 2024 New Vector Ltd.
Copyright 2016-2021 The Matrix.org Foundation C.I.C.
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only
Please see LICENSE files in the repository root for full details.
*/
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 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 { InviteKind } from "./components/views/dialogs/InviteDialogTypes";
import { Member } from "./utils/direct-messages";
export interface IInviteResult {
states: CompletionStates;
inviter: MultiInviter;
}
/**
* Invites multiple addresses to a room
* Simpler interface to utils/MultiInviter but with
* no option to cancel.
*
* @param {string} roomId The ID of the room to invite to
* @param {string[]} addresses Array of strings of addresses to invite. May be matrix IDs or 3pids.
* @param {function} progressCallback optional callback, fired after each invite.
* @returns {Promise} Promise
*/
export function inviteMultipleToRoom(
client: MatrixClient,
roomId: string,
addresses: string[],
progressCallback?: () => void,
): Promise<IInviteResult> {
const inviter = new MultiInviter(client, roomId, progressCallback);
return inviter.invite(addresses, undefined).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: 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: InviteKind.Invite,
initialText,
roomId,
} as Omit<ComponentProps<typeof InviteDialog>, "onFinished">,
/*className=*/ "mx_InviteDialog_flexWrapper",
/*isPriority=*/ false,
/*isStatic=*/ true,
);
}
/**
* Checks if the given MatrixEvent is a valid 3rd party user invite.
* @param {MatrixEvent} event The event to check
* @returns {boolean} True if valid, false otherwise
*/
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])) {
return false;
}
// Valid enough by our standards
return true;
}
export function inviteUsersToRoom(
client: MatrixClient,
roomId: string,
userIds: string[],
progressCallback?: () => void,
): Promise<void> {
return inviteMultipleToRoom(client, roomId, userIds, 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(
states: CompletionStates,
room: Room,
inviter: MultiInviter,
userMap?: Map<string, Member>,
): boolean {
// Show user any errors
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("invite|room_failed_title", { roomName: room.name }),
description: inviter.getErrorText(failedUsers[0]),
});
return false;
} else {
const errorList: string[] = [];
for (const addr of failedUsers) {
if (states[addr] === "error") {
const reason = inviter.getErrorText(addr);
errorList.push(addr + ": " + reason);
}
}
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(
"invite|room_failed_partial",
{},
{
RoomName: () => <strong>{room.name}</strong>,
},
)}
</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>
);
Modal.createDialog(ErrorDialog, {
title: _t("invite|room_failed_partial_title"),
description,
});
return false;
}
}
return true;
}

297
src/RoomNotifs.ts Normal file
View File

@@ -0,0 +1,297 @@
/*
Copyright 2024 New Vector Ltd.
Copyright 2016-2019 , 2023 The Matrix.org Foundation C.I.C.
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only
Please see LICENSE files in the repository root for full details.
*/
import { PushProcessor } from "matrix-js-sdk/src/pushprocessor";
import {
NotificationCountType,
ConditionKind,
PushRuleActionName,
PushRuleKind,
TweakName,
} from "matrix-js-sdk/src/matrix";
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",
}
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(client, roomId);
if (muteRule) {
return RoomNotifState.Mute;
}
// for everything else, look at the room rule.
let roomRule: IPushRule | undefined;
try {
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.
return null;
}
// XXX: We have to assume the default is to notify for all messages
// (in particular this will be 'wrong' for one to one rooms because
// they will notify loudly for all messages)
if (!roomRule?.enabled) return RoomNotifState.AllMessages;
// a mute at the room level will still allow mentions
// to notify
if (isMuteRule(roomRule)) return RoomNotifState.MentionsOnly;
const actionsObject = PushProcessor.actionListToActionsObject(roomRule.actions);
if (actionsObject.tweaks.sound) return RoomNotifState.AllMessagesLoud;
return null;
}
export function setRoomNotifsState(client: MatrixClient, roomId: string, newState: RoomNotifState): Promise<void> {
if (newState === RoomNotifState.Mute) {
return setRoomNotifsStateMuted(client, roomId);
} else {
return setRoomNotifsStateUnmuted(client, roomId, newState);
}
}
export function getUnreadNotificationCount(
room: Room,
type: NotificationCountType,
includeThreads: boolean,
threadId?: string,
): number {
const getCountShownForRoom = (r: Room, type: NotificationCountType): number => {
return includeThreads ? r.getUnreadNotificationCount(type) : r.getRoomUnreadNotificationCount(type);
};
let notificationCount = !!threadId
? room.getThreadUnreadNotificationCount(threadId, 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 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?.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 += getCountShownForRoom(oldRoom, NotificationCountType.Highlight);
}
}
return notificationCount;
}
function setRoomNotifsStateMuted(cli: MatrixClient, roomId: string): Promise<any> {
const promises: Promise<unknown>[] = [];
// delete the room rule
const roomRule = cli.getRoomPushRule("global", roomId);
if (roomRule) {
promises.push(cli.deletePushRule("global", PushRuleKind.RoomSpecific, roomRule.rule_id));
}
// add/replace an override rule to squelch everything in this room
// NB. We use the room ID as the name of this rule too, although this
// 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],
}),
);
return Promise.all(promises);
}
function setRoomNotifsStateUnmuted(cli: MatrixClient, roomId: string, newState: RoomNotifState): Promise<any> {
const promises: Promise<unknown>[] = [];
const overrideMuteRule = findOverrideMuteRule(cli, roomId);
if (overrideMuteRule) {
promises.push(cli.deletePushRule("global", PushRuleKind.Override, overrideMuteRule.rule_id));
}
if (newState === RoomNotifState.AllMessages) {
const roomRule = cli.getRoomPushRule("global", roomId);
if (roomRule) {
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],
}),
);
} else if (newState === RoomNotifState.AllMessagesLoud) {
promises.push(
cli.addPushRule("global", PushRuleKind.RoomSpecific, roomId, {
actions: [
PushRuleActionName.Notify,
{
set_tweak: TweakName.Sound,
value: "default",
},
],
}),
);
}
return Promise.all(promises);
}
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 && isRuleRoomMuteRuleForRoomId(roomId, rule)) {
return rule;
}
}
return null;
}
/**
* 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;
}
// isRuleMaybeRoomMuteRule checks this condition exists
const cond = rule.conditions![0]!;
return cond.pattern === roomId;
}
function isMuteRule(rule: IPushRule): boolean {
// 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,
};
}

129
src/Rooms.ts Normal file
View File

@@ -0,0 +1,129 @@
/*
Copyright 2024 New Vector Ltd.
Copyright 2015-2021 The Matrix.org Foundation C.I.C.
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only
Please see LICENSE files in the repository root for full details.
*/
import { Room, EventType, RoomMember, MatrixClient } from "matrix-js-sdk/src/matrix";
import AliasCustomisations from "./customisations/Alias";
/**
* Given a room object, return the alias we should use for it,
* if any. This could be the canonical alias if one exists, otherwise
* an alias selected arbitrarily but deterministically from the list
* of aliases. Otherwise return null;
*
* @param {Object} room The room object
* @returns {string} A display alias for the given room
*/
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 | null, altAliases: string[]): string | null {
if (AliasCustomisations.getDisplayAliasForAliasSet) {
return AliasCustomisations.getDisplayAliasForAliasSet(canonicalAlias, altAliases);
}
return (canonicalAlias || altAliases?.[0]) ?? "";
}
export function guessAndSetDMRoom(room: Room, isDirect: boolean): Promise<void> {
let newTarget;
if (isDirect) {
const guessedUserId = guessDMRoomTargetId(room, room.client.getSafeUserId());
newTarget = guessedUserId;
} else {
newTarget = null;
}
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 | 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(client: MatrixClient, roomId: string, userId: string | null): Promise<void> {
if (client.isGuest()) return;
const mDirectEvent = client.getAccountData(EventType.Direct);
const currentContent = mDirectEvent?.getContent() || {};
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 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.get(userId) || [];
if (roomList.indexOf(roomId) == -1) {
roomList.push(roomId);
modified = true;
}
dmRoomMap.set(userId, roomList);
}
// prevent unnecessary calls to setAccountData
if (!modified) return;
await client.setAccountData(EventType.Direct, Object.fromEntries(dmRoomMap));
}
/**
* Given a room, estimate which of its members is likely to
* be the target if the room were a DM room and return that user.
*
* @param {Object} room Target room
* @param {string} myUserId User ID of the current user
* @returns {string} User ID of the user that the room is probably a DM with
*/
function guessDMRoomTargetId(room: Room, myUserId: string): string {
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()) {
if (user.userId == myUserId) continue;
if (oldestTs === undefined || (user.events.member && user.events.member.getTs() < oldestTs)) {
oldestUser = user;
oldestTs = user.events.member?.getTs();
}
}
if (oldestUser) return oldestUser.userId;
// if there are no joined members other than us, use the oldest member
for (const user of room.currentState.getMembers()) {
if (user.userId == myUserId) continue;
if (oldestTs === undefined || (user.events.member && user.events.member.getTs() < oldestTs)) {
oldestUser = user;
oldestTs = user.events.member?.getTs();
}
}
if (oldestUser === undefined) return myUserId;
return oldestUser.userId;
}

273
src/ScalarAuthClient.ts Normal file
View File

@@ -0,0 +1,273 @@
/*
Copyright 2024 New Vector Ltd.
Copyright 2016-2019 , 2021 The Matrix.org Foundation C.I.C.
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only
Please see LICENSE files in the repository root for full details.
*/
import { logger } from "matrix-js-sdk/src/logger";
import { SERVICE_TYPES, Room, IOpenIDToken } from "matrix-js-sdk/src/matrix";
import SettingsStore from "./settings/SettingsStore";
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";
// TODO: Generify the name of this class and all components within - it's not just for Scalar.
export default class ScalarAuthClient {
private scalarToken: string | null;
private termsInteractionCallback?: TermsInteractionCallback;
private isDefaultManager: boolean;
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.
this.termsInteractionCallback = undefined;
// We try and store the token on a per-manager basis, but need a fallback
// for the default manager.
const configApiUrl = SdkConfig.get("integrations_rest_url");
const configUiUrl = SdkConfig.get("integrations_ui_url");
this.isDefaultManager = apiUrl === configApiUrl && configUiUrl === uiUrl;
}
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
// they'll at worst register for a new token.
window.localStorage.removeItem("mx_scalar_token"); // no-op when not present
}
}
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");
}
return token;
}
private readToken(): string | null {
if (this.scalarToken) return this.scalarToken;
return this.readTokenFromStore();
}
public setTermsInteractionCallback(callback: TermsInteractionCallback): void {
this.termsInteractionCallback = callback;
}
public connect(): Promise<void> {
return this.getScalarToken().then((tok) => {
this.scalarToken = tok;
});
}
public hasCredentials(): boolean {
return this.scalarToken != null; // undef or null
}
// Returns a promise that resolves to a scalar_token string
public getScalarToken(): Promise<string> {
const token = this.readToken();
if (!token) {
return this.registerForToken();
} else {
return this.checkToken(token).catch((e) => {
if (e instanceof TermsNotSignedError) {
// retrying won't help this
throw e;
}
return this.registerForToken();
});
}
}
private async getAccountName(token: string): Promise<string> {
const url = new URL(this.apiUrl + "/account");
url.searchParams.set("scalar_token", token);
url.searchParams.set("v", imApiVersion);
const res = await fetch(url, {
method: "GET",
});
const body = await res.json();
if (body?.errcode === "M_TERMS_NOT_SIGNED") {
throw new TermsNotSignedError();
}
if (!res.ok) {
throw body;
}
if (!body?.user_id) {
throw new Error("Missing user_id in response");
}
return body.user_id;
}
private checkToken(token: string): Promise<string> {
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.
// 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;
}
});
}
public registerForToken(): Promise<string> {
// Get openid bearer token from the HS as the first part of our dance
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> {
const scalarRestUrl = new URL(this.apiUrl + "/register");
scalarRestUrl.searchParams.set("v", imApiVersion);
const res = await fetch(scalarRestUrl, {
method: "POST",
body: JSON.stringify(openidTokenObject),
headers: {
"Content-Type": "application/json",
},
});
if (!res.ok) {
throw new Error(`Scalar request failed: ${res.status}`);
}
const body = await res.json();
if (!body?.scalar_token) {
throw new Error("Missing scalar_token in response");
}
return body.scalar_token;
}
public async getScalarPageTitle(url: string): Promise<string> {
const scalarPageLookupUrl = new URL(this.getStarterLink(this.apiUrl + "/widgets/title_lookup"));
scalarPageLookupUrl.searchParams.set("curl", encodeURIComponent(url));
const res = await fetch(scalarPageLookupUrl, {
method: "GET",
});
if (!res.ok) {
throw new Error(`Scalar request failed: ${res.status}`);
}
const body = await res.json();
return body?.page_title_cache_item?.cached_title;
}
/**
* Mark all assets associated with the specified widget as "disabled" in the
* integration manager database.
* This can be useful to temporarily prevent purchased assets from being displayed.
* @param {WidgetType} widgetType The Widget Type to disable assets for
* @param {string} widgetId The widget ID to disable assets for
* @return {Promise} Resolves on completion
*/
public async disableWidgetAssets(widgetType: WidgetType, widgetId: string): Promise<void> {
const url = new URL(this.getStarterLink(this.apiUrl + "/widgets/set_assets_state"));
url.searchParams.set("widget_type", widgetType.preferred);
url.searchParams.set("widget_id", widgetId);
url.searchParams.set("state", "disable");
const res = await fetch(url, {
method: "GET", // XXX: Actions shouldn't be GET requests
});
if (!res.ok) {
throw new Error(`Scalar request failed: ${res.status}`);
}
const body = await res.text();
if (!body) {
throw new Error("Failed to set widget assets state");
}
}
public getScalarInterfaceUrlForRoom(room: Room, screen?: string, id?: string): string {
const roomId = room.roomId;
const roomName = room.name;
let url = this.uiUrl;
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);
}
if (screen) {
url += "&screen=" + encodeURIComponent(screen);
}
return url;
}
public getStarterLink(starterLinkUrl: string): string {
if (!this.scalarToken) return starterLinkUrl;
return starterLinkUrl + "?scalar_token=" + encodeURIComponent(this.scalarToken);
}
}

993
src/ScalarMessaging.ts Normal file
View File

@@ -0,0 +1,993 @@
/*
Copyright 2018-2024 New Vector Ltd.
Copyright 2017 Vector Creations Ltd
Copyright 2016 OpenMarket Ltd
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only
Please see LICENSE files in the repository root for full details.
*/
// TODO: Generify the name of this and all components within - it's not just for scalar.
/*
Listens for incoming postMessage requests from the integrations UI URL. The following API is exposed:
{
action: "invite" | "membership_state" | "bot_options" | "set_bot_options" | etc... ,
room_id: $ROOM_ID,
user_id: $USER_ID
// additional request fields
}
The complete request object is returned to the caller with an additional "response" key like so:
{
action: "invite" | "membership_state" | "bot_options" | "set_bot_options",
room_id: $ROOM_ID,
user_id: $USER_ID,
// additional request fields
response: { ... }
}
The "action" determines the format of the request and response. All actions can return an error response.
An error response is a "response" object which consists of a sole "error" key to indicate an error.
They look like:
{
error: {
message: "Unable to invite user into room.",
_error: <Original Error Object>
}
}
The "message" key should be a human-friendly string.
ACTIONS
=======
All actions can return an error response instead of the response outlined below.
invite
------
Invites a user into a room. The request will no-op if the user is already joined OR invited to the room.
Request:
- room_id is the room to invite the user into.
- user_id is the user ID to invite.
- No additional fields.
Response:
{
success: true
}
Example:
{
action: "invite",
room_id: "!foo:bar",
user_id: "@invitee:bar",
response: {
success: true
}
}
kick
------
Kicks a user from a room. The request will no-op if the user is not in the room.
Request:
- room_id is the room to kick the user from.
- user_id is the user ID to kick.
- reason is an optional string for the kick reason
Response:
{
success: true
}
Example:
{
action: "kick",
room_id: "!foo:bar",
user_id: "@target:example.org",
reason: "Removed from room",
response: {
success: true
}
}
set_bot_options
---------------
Set the m.room.bot.options state event for a bot user.
Request:
- room_id is the room to send the state event into.
- user_id is the user ID of the bot who you're setting options for.
- "content" is an object consisting of the content you wish to set.
Response:
{
success: true
}
Example:
{
action: "set_bot_options",
room_id: "!foo:bar",
user_id: "@bot:bar",
content: {
default_option: "alpha"
},
response: {
success: true
}
}
get_membership_count
--------------------
Get the number of joined users in the room.
Request:
- room_id is the room to get the count in.
Response:
78
Example:
{
action: "get_membership_count",
room_id: "!foo:bar",
response: 78
}
can_send_event
--------------
Check if the client can send the given event into the given room. If the client
is unable to do this, an error response is returned instead of 'response: false'.
Request:
- room_id is the room to do the check in.
- event_type is the event type which will be sent.
- is_state is true if the event to be sent is a state event.
Response:
true
Example:
{
action: "can_send_event",
is_state: false,
event_type: "m.room.message",
room_id: "!foo:bar",
response: true
}
set_widget
----------
Set a new widget in the room. Clobbers based on the ID.
Request:
- `room_id` (String) is the room to set the widget in.
- `widget_id` (String) is the ID of the widget to add (or replace if it already exists).
It can be an arbitrary UTF8 string and is purely for distinguishing between widgets.
- `url` (String) is the URL that clients should load in an iframe to run the widget.
All widgets must have a valid URL. If the URL is `null` (not `undefined`), the
widget will be removed from the room.
- `type` (String) is the type of widget, which is provided as a hint for matrix clients so they
can configure/lay out the widget in different ways. All widgets must have a type.
- `name` (String) is an optional human-readable string about the widget.
- `data` (Object) is some optional data about the widget, and can contain arbitrary key/value pairs.
- `avatar_url` (String) is some optional mxc: URI pointing to the avatar of the widget.
Response:
{
success: true
}
Example:
{
action: "set_widget",
room_id: "!foo:bar",
widget_id: "abc123",
url: "http://widget.url",
type: "example",
response: {
success: true
}
}
get_widgets
-----------
Get a list of all widgets in the room. The response is an array
of state events.
Request:
- `room_id` (String) is the room to get the widgets in.
Response:
[
{
// TODO: Enable support for m.widget event type (https://github.com/vector-im/element-web/issues/13111)
type: "im.vector.modular.widgets",
state_key: "wid1",
content: {
type: "grafana",
url: "https://grafanaurl",
name: "dashboard",
data: {key: "val"}
}
room_id: "!foo:bar",
sender: "@alice:localhost"
}
]
Example:
{
action: "get_widgets",
room_id: "!foo:bar",
response: [
{
// TODO: Enable support for m.widget event type (https://github.com/vector-im/element-web/issues/13111)
type: "im.vector.modular.widgets",
state_key: "wid1",
content: {
type: "grafana",
url: "https://grafanaurl",
name: "dashboard",
data: {key: "val"}
}
room_id: "!foo:bar",
sender: "@alice:localhost"
}
]
}
membership_state AND bot_options
--------------------------------
Get the content of the "m.room.member" or "m.room.bot.options" state event respectively.
NB: Whilst this API is basically equivalent to getStateEvent, we specifically do not
want external entities to be able to query any state event for any room, hence the
restrictive API outlined here.
Request:
- room_id is the room which has the state event.
- user_id is the state_key parameter which in both cases is a user ID (the member or the bot).
- No additional fields.
Response:
- The event content. If there is no state event, the "response" key should be null.
Example:
{
action: "membership_state",
room_id: "!foo:bar",
user_id: "@somemember:bar",
response: {
membership: "join",
displayname: "Bob",
avatar_url: null
}
}
get_open_id_token
-----------------
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 { IContent, MatrixEvent, IEvent, StateEvents } from "matrix-js-sdk/src/matrix";
import { KnownMembership } from "matrix-js-sdk/src/types";
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 { 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";
enum Action {
CloseScalar = "close_scalar",
GetWidgets = "get_widgets",
SetWidget = "set_widget",
JoinRulesState = "join_rules_state",
SetPlumbingState = "set_plumbing_state",
GetMembershipCount = "get_membership_count",
GetRoomEncryptionState = "get_room_enc_state",
CanSendEvent = "can_send_event",
MembershipState = "membership_state",
invite = "invite",
Kick = "kick",
BotOptions = "bot_options",
SetBotOptions = "set_bot_options",
SetBotPower = "set_bot_power",
GetOpenIdToken = "get_open_id_token",
SendEvent = "send_event",
ReadEvents = "read_events",
}
function sendResponse(event: MessageEvent<any>, res: any): void {
const data = objectClone(event.data);
data.response = res;
// @ts-ignore
event.source.postMessage(data, event.origin);
}
function sendError(event: MessageEvent<any>, msg: string, nestedError?: Error): void {
logger.error("Action:" + event.data.action + " failed with message: " + msg);
const data = objectClone(event.data);
data.response = {
error: {
message: msg,
},
};
if (nestedError) {
data.response.error._error = nestedError;
}
// @ts-ignore
event.source.postMessage(data, event.origin);
}
function inviteUser(event: MessageEvent<any>, roomId: string, userId: string): void {
logger.log(`Received request to invite ${userId} into room ${roomId}`);
const client = MatrixClientPeg.get();
if (!client) {
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 &&
([KnownMembership.Join, KnownMembership.Invite] as Array<string | undefined>).includes(member.membership)
) {
sendResponse(event, {
success: true,
});
return;
}
}
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("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) {
sendResponse(event, {
success: true,
});
return;
}
}
const reason = event.data.reason;
client
.kick(roomId, userId, reason)
.then(() => {
sendResponse(event, {
success: true,
});
})
.catch((err) => {
sendError(event, _t("widget|error_need_kick_permission"), err);
});
}
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;
const widgetName = event.data.name; // optional
const widgetData = event.data.data; // optional
const widgetAvatarUrl = event.data.avatar_url; // optional
const userWidget = event.data.userWidget;
// both adding/removing widgets need these checks
if (!widgetId || widgetUrl === undefined) {
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
// check types of fields
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("scalar|error_create"), new Error("Optional field 'data' must be an Object."));
return;
}
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("scalar|error_create"), new Error("Field 'type' must be a string."));
return;
}
if (typeof widgetUrl !== "string") {
sendError(event, _t("scalar|error_create"), new Error("Field 'url' must be a string or null."));
return;
}
}
// convert the widget type to a known widget type
widgetType = WidgetType.fromString(widgetType);
if (userWidget) {
WidgetUtils.setUserWidget(client, widgetId, widgetType, widgetUrl, widgetName, widgetData)
.then(() => {
sendResponse(event, {
success: true,
});
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 | null): void {
const client = MatrixClientPeg.get();
if (!client) {
sendError(event, _t("widget|error_need_to_be_logged_in"));
return;
}
let widgetStateEvents: Partial<IEvent>[] = [];
if (roomId) {
const room = client.getRoom(roomId);
if (!room) {
sendError(event, _t("scalar|error_room_unknown"));
return;
}
// XXX: This gets the raw event object (I think because we can't
// send the MatrixEvent over postMessage?)
widgetStateEvents = WidgetUtils.getRoomWidgets(room).map((ev) => ev.event);
}
// Add user widgets (not linked to a specific room)
const userWidgets = WidgetUtils.getUserWidgetsArray(client);
widgetStateEvents = widgetStateEvents.concat(userWidgets);
sendResponse(event, widgetStateEvents);
}
function getRoomEncState(event: MessageEvent<any>, roomId: string): void {
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;
}
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");
}
logger.log(`Received request to set plumbing state to status "${status}" in room ${roomId}`);
const client = MatrixClientPeg.get();
if (!client) {
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("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("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("scalar|error_send_request"), err);
},
);
}
async function setBotPower(
event: MessageEvent<any>,
roomId: string,
userId: string,
level: number,
ignoreIfGreater?: boolean,
): Promise<void> {
if (!(Number.isInteger(level) && level >= 0)) {
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("widget|error_need_to_be_logged_in"));
return;
}
try {
const powerLevels = await client.getStateEvent(roomId, "m.room.power_levels", "");
// If the PL is equal to or greater than the requested PL, ignore.
if (ignoreIfGreater === true) {
// As per https://matrix.org/docs/spec/client_server/r0.6.0#m-room-power-levels
const currentPl = powerLevels.users?.[userId] ?? powerLevels.users_default ?? 0;
if (currentPl >= level) {
return sendResponse(event, {
success: true,
});
}
}
await client.setPowerLevel(roomId, userId, level);
return sendResponse(event, {
success: true,
});
} catch (err) {
const error = err instanceof Error ? err : undefined;
sendError(event, error?.message ?? _t("scalar|error_send_request"), error);
}
}
function getMembershipState(event: MessageEvent<any>, roomId: string, userId: string): void {
logger.log(`membership_state of ${userId} in room ${roomId} requested.`);
returnStateEvent(event, roomId, "m.room.member", userId);
}
function getJoinRules(event: MessageEvent<any>, roomId: string): void {
logger.log(`join_rules of ${roomId} requested.`);
returnStateEvent(event, roomId, "m.room.join_rules", "");
}
function botOptions(event: MessageEvent<any>, roomId: string, userId: string): void {
logger.log(`bot_options of ${userId} in room ${roomId} requested.`);
returnStateEvent(event, roomId, "m.room.bot.options", "_" + userId);
}
function getMembershipCount(event: MessageEvent<any>, roomId: string): void {
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;
}
const count = room.getJoinedMemberCount();
sendResponse(event, count);
}
function canSendEvent(event: MessageEvent<any>, roomId: string): void {
const evType = "" + event.data.event_type; // force stringify
const isState = Boolean(event.data.is_state);
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 (room.getMyMembership() !== KnownMembership.Join) {
sendError(event, _t("scalar|error_membership"));
return;
}
const me = client.credentials.userId!;
let canSend: boolean;
if (isState) {
canSend = room.currentState.maySendStateEvent(evType, me);
} else {
canSend = room.currentState.maySendEvent(evType, me);
}
if (!canSend) {
sendError(event, _t("scalar|error_permission"));
return;
}
sendResponse(event, true);
}
function returnStateEvent(event: MessageEvent<any>, roomId: string, eventType: string, stateKey: string): void {
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;
}
const stateEvent = room.currentState.getStateEvents(eventType, stateKey);
if (!stateEvent) {
sendResponse(event, null);
return;
}
sendResponse(event, stateEvent.getContent());
}
async function getOpenIdToken(event: MessageEvent<any>): Promise<void> {
try {
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.");
}
}
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;
}
// Check that the integrations UI URL starts with the origin of the event
// 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: URL | undefined;
try {
if (!openManagerUrl) openManagerUrl = IntegrationManagers.sharedInstance().getPrimaryManager()?.uiUrl;
configUrl = new URL(openManagerUrl!);
} catch (e) {
// No integrations UI URL, ignore silently.
return;
}
let eventOriginUrl: URL;
try {
eventOriginUrl = new URL(event.origin);
} catch (e) {
return;
}
// TODO -- Scalar postMessage API should be namespaced with event.data.api field
// Fix following "if" statement to respond only to specific API messages.
if (
configUrl.origin !== eventOriginUrl.origin ||
!event.data.action ||
event.data.api // Ignore messages with specific API set
) {
// don't log this - debugging APIs and browser add-ons like to spam
// postMessage which floods the log otherwise
return;
}
if (event.data.action === Action.CloseScalar) {
dis.dispatch({ action: Action.CloseScalar });
sendResponse(event, null);
return;
}
const roomId = event.data.room_id;
const userId = event.data.user_id;
if (!roomId) {
// These APIs don't require roomId
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("scalar|error_missing_room_id_request"));
return;
}
}
if (roomId !== SdkContextClass.instance.roomViewStore.getRoomId()) {
sendError(event, _t("scalar|error_room_not_visible", { roomId: roomId }));
return;
}
// Get and set room-based widgets
if (event.data.action === Action.GetWidgets) {
getWidgets(event, roomId);
return;
} else if (event.data.action === Action.SetWidget) {
setWidget(event, roomId);
return;
}
// These APIs don't require userId
if (event.data.action === Action.JoinRulesState) {
getJoinRules(event, roomId);
return;
} else if (event.data.action === Action.SetPlumbingState) {
setPlumbingState(event, roomId, event.data.status);
return;
} else if (event.data.action === Action.GetMembershipCount) {
getMembershipCount(event, roomId);
return;
} else if (event.data.action === Action.GetRoomEncryptionState) {
getRoomEncState(event, roomId);
return;
} 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("scalar|error_missing_user_id_request"));
return;
}
switch (event.data.action) {
case Action.MembershipState:
getMembershipState(event, roomId, userId);
break;
case Action.invite:
inviteUser(event, roomId, userId);
break;
case Action.Kick:
kickUser(event, roomId, userId);
break;
case Action.BotOptions:
botOptions(event, roomId, userId);
break;
case Action.SetBotOptions:
setBotOptions(event, roomId, userId);
break;
case Action.SetBotPower:
setBotPower(event, roomId, userId, event.data.level, event.data.ignoreIfGreater);
break;
default:
logger.warn("Unhandled postMessage event with action '" + event.data.action + "'");
break;
}
};
let listenerCount = 0;
let openManagerUrl: string | undefined;
export function startListening(): void {
if (listenerCount === 0) {
window.addEventListener("message", onMessage, false);
}
listenerCount += 1;
}
export function stopListening(): void {
listenerCount -= 1;
if (listenerCount === 0) {
window.removeEventListener("message", onMessage);
}
if (listenerCount < 0) {
// Make an error so we get a stack trace
const e = new Error("ScalarMessaging: mismatched startListening / stopListening detected." + " Negative count");
logger.error(e);
}
}

159
src/SdkConfig.ts Normal file
View File

@@ -0,0 +1,159 @@
/*
Copyright 2024 New Vector Ltd.
Copyright 2019-2022 The Matrix.org Foundation C.I.C.
Copyright 2016 OpenMarket Ltd
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only
Please see LICENSE files in the repository root for full details.
*/
import { Optional } from "matrix-events-sdk";
import { mergeWith } from "lodash";
import { SnakedObject } from "./utils/SnakedObject";
import { IConfigOptions, ISsoRedirectOptions } from "./IConfigOptions";
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: 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",
uisi_autorageshake_app: "element-auto-uisi",
show_labs_settings: false,
force_verification: false,
jitsi: {
preferred_domain: "meet.element.io",
},
element_call: {
url: "https://call.element.io",
use_exclusively: false,
participant_limit: 8,
brand: "Element Call",
},
// @ts-ignore - we deliberately use the camelCase version here so we trigger
// the fallback behaviour. If we used the snake_case version then we'd break
// everyone's config which has the camelCase property because our default would
// be preferred over their config.
desktopBuilds: {
available: true,
logo: require("../res/img/element-desktop-logo.svg").default,
url: "https://element.io/get-started",
},
voice_broadcast: {
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",
url_macos: "https://packages.element.io/desktop/install/macos/Element.dmg",
url_win64: "https://packages.element.io/desktop/install/win32/x64/Element%20Setup.exe",
url_win32: "https://packages.element.io/desktop/install/win32/ia32/Element%20Setup.exe",
url_linux: "https://element.io/download#linux",
},
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 type ConfigOptions = Defaultize<IConfigOptions, typeof DEFAULTS>;
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);
// For debugging purposes
window.mxReactSdkConfig = i;
}
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,
): 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>{};
}
return SdkConfig.fallback.get(key, altCaseName);
}
public static getObject<K extends keyof IConfigOptions>(key: K, altCaseName?: string): ObjectType<K> {
const val = SdkConfig.get(key, altCaseName);
if (isObject(val)) {
return new SnakedObject(val);
}
// return the same type for sensitive callers (some want `undefined` specifically)
return (val === undefined ? undefined : null) as ObjectType<K>;
}
public static put(cfg: DeepReadonly<ConfigOptions>): void {
SdkConfig.setInstance(mergeConfig(DEFAULTS, cfg));
}
/**
* Resets the config.
*/
public static reset(): void {
SdkConfig.setInstance(mergeConfig(DEFAULTS, {})); // safe to cast - defaults will be applied
}
public static add(cfg: Partial<ConfigOptions>): void {
SdkConfig.put(mergeConfig(SdkConfig.get(), cfg));
}
}
export function parseSsoRedirectOptions(config: IConfigOptions): ISsoRedirectOptions {
// Ignore deprecated options if the config is using new ones
if (config.sso_redirect_options) return config.sso_redirect_options;
// We can cheat here because the default is false anyways
if (config.sso_immediate_redirect) return { immediate: true };
// Default: do nothing
return {};
}

721
src/Searching.ts Normal file
View File

@@ -0,0 +1,721 @@
/*
Copyright 2024 New Vector Ltd.
Copyright 2019-2021 The Matrix.org Foundation C.I.C.
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only
Please see LICENSE files in the repository root for full details.
*/
import {
IResultRoomEvents,
ISearchRequestBody,
ISearchResponse,
ISearchResult,
ISearchResults,
SearchOrderBy,
IRoomEventFilter,
EventType,
MatrixClient,
SearchResult,
} from "matrix-js-sdk/src/matrix";
import { ISearchArgs } from "./indexing/BaseEventIndexManager";
import EventIndexPeg from "./indexing/EventIndexPeg";
import { isNotUndefined } from "./Typeguards";
const SEARCH_LIMIT = 10;
async function serverSideSearch(
client: MatrixClient,
term: string,
roomId?: string,
abortSignal?: AbortSignal,
): Promise<{ response: ISearchResponse; query: ISearchRequestBody }> {
const filter: IRoomEventFilter = {
limit: SEARCH_LIMIT,
};
if (roomId !== undefined) filter.rooms = [roomId];
const body: ISearchRequestBody = {
search_categories: {
room_events: {
search_term: term,
filter: filter,
order_by: SearchOrderBy.Recent,
event_context: {
before_limit: 1,
after_limit: 1,
include_profile: true,
},
},
},
};
const response = await client.search({ body: body }, abortSignal);
return { response, query: body };
}
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: [],
};
return client.processRoomEventsSearch(searchResults, result.response);
}
function compareEvents(a: ISearchResult, b: ISearchResult): number {
const aEvent = a.result;
const bEvent = b.result;
if (aEvent.origin_server_ts > bEvent.origin_server_ts) return -1;
if (aEvent.origin_server_ts < bEvent.origin_server_ts) return 1;
return 0;
}
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(client, searchTerm, undefined, abortSignal);
const localPromise = localSearch(searchTerm);
// Wait for both promises to resolve.
await Promise.all([serverSidePromise, localPromise]);
// Get both search results.
const localResult = await localPromise;
const serverSideResult = await serverSidePromise;
const serverQuery = serverSideResult.query;
const serverResponse = serverSideResult.response;
const localQuery = localResult.query;
const localResponse = localResult.response;
// Store our queries for later on so we can support pagination.
//
// We're reusing _query here again to not introduce separate code paths and
// concepts for our different pagination methods. We're storing the
// server-side next batch separately since the query is the json body of
// the request and next_batch needs to be a query parameter.
//
// We can't put it in the final result that _processRoomEventsSearch()
// returns since that one can be either a server-side one, a local one or a
// fake one to fetch the remaining cached events. See the docs for
// combineEvents() for an explanation why we need to cache events.
const emptyResult: ISeshatSearchResults = {
seshatQuery: localQuery,
_query: serverQuery,
serverSideNextBatch: serverResponse.search_categories.room_events.next_batch,
cachedEvents: [],
oldestEventFrom: "server",
results: [],
highlights: [],
};
// Combine our results.
const combinedResult = combineResponses(emptyResult, localResponse, serverResponse.search_categories.room_events);
// Let the client process the combined result.
const response: ISearchResponse = {
search_categories: {
room_events: combinedResult,
},
};
const result = client.processRoomEventsSearch(emptyResult, response);
// Restore our encryption info so we can properly re-verify the events.
restoreEncryptionInfo(result.results);
return result;
}
async function localSearch(
searchTerm: string,
roomId?: string,
processResult = true,
): Promise<{ response: IResultRoomEvents; query: ISearchArgs }> {
const eventIndex = EventIndexPeg.get();
const searchArgs: ISearchArgs = {
search_term: searchTerm,
before_limit: 1,
after_limit: 1,
limit: SEARCH_LIMIT,
order_by_recency: true,
room_id: undefined,
};
if (roomId !== undefined) {
searchArgs.room_id = roomId;
}
const localResult = await eventIndex!.search(searchArgs);
if (!localResult) {
throw new Error("Local search failed");
}
searchArgs.next_batch = localResult.next_batch;
const result = {
response: localResult,
query: searchArgs,
};
return result;
}
export interface ISeshatSearchResults extends ISearchResults {
seshatQuery?: ISearchArgs;
cachedEvents?: ISearchResult[];
oldestEventFrom?: "local" | "server";
serverSideNextBatch?: string;
}
async function localSearchProcess(
client: MatrixClient,
searchTerm: string,
roomId?: string,
): Promise<ISeshatSearchResults> {
const emptyResult = {
results: [],
highlights: [],
} as ISeshatSearchResults;
if (searchTerm === "") return emptyResult;
const result = await localSearch(searchTerm, roomId);
emptyResult.seshatQuery = result.query;
const response: ISearchResponse = {
search_categories: {
room_events: result.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(
client: MatrixClient,
searchResult: ISeshatSearchResults,
): Promise<ISeshatSearchResults> {
const eventIndex = EventIndexPeg.get();
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");
}
searchResult.seshatQuery.next_batch = localResult.next_batch;
// We only need to restore the encryption state for the new results, so
// remember how many of them we got.
const newResultCount = localResult.results?.length ?? 0;
const response = {
search_categories: {
room_events: localResult,
},
};
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 = undefined;
return result;
}
function compareOldestEvents(firstResults: ISearchResult[], secondResults: ISearchResult[]): number {
try {
const oldestFirstEvent = firstResults[firstResults.length - 1].result;
const oldestSecondEvent = secondResults[secondResults.length - 1].result;
if (oldestFirstEvent.origin_server_ts <= oldestSecondEvent.origin_server_ts) {
return -1;
} else {
return 1;
}
} catch {
return 0;
}
}
function combineEventSources(
previousSearchResult: ISeshatSearchResults,
response: IResultRoomEvents,
a: ISearchResult[],
b: ISearchResult[],
): void {
// Merge event sources and sort the events.
const combinedEvents = a.concat(b).sort(compareEvents);
// Put half of the events in the response, and cache the other half.
response.results = combinedEvents.slice(0, SEARCH_LIMIT);
previousSearchResult.cachedEvents = combinedEvents.slice(SEARCH_LIMIT);
}
/**
* Combine the events from our event sources into a sorted result
*
* This method will first be called from the combinedSearch() method. In this
* case we will fetch SEARCH_LIMIT events from the server and the local index.
*
* The method will put the SEARCH_LIMIT newest events from the server and the
* local index in the results part of the response, the rest will be put in the
* cachedEvents field of the previousSearchResult (in this case an empty search
* result).
*
* Every subsequent call will be made from the combinedPagination() method, in
* this case we will combine the cachedEvents and the next SEARCH_LIMIT events
* from either the server or the local index.
*
* Since we have two event sources and we need to sort the results by date we
* need keep on looking for the oldest event. We are implementing a variation of
* a sliding window.
*
* The event sources are here represented as two sorted lists where the smallest
* number represents the newest event. The two lists need to be merged in a way
* that preserves the sorted property so they can be shown as one search result.
* We first fetch SEARCH_LIMIT events from both sources.
*
* If we set SEARCH_LIMIT to 3:
*
* Server events [01, 02, 04, 06, 07, 08, 11, 13]
* |01, 02, 04|
* Local events [03, 05, 09, 10, 12, 14, 15, 16]
* |03, 05, 09|
*
* We note that the oldest event is from the local index, and we combine the
* results:
*
* Server window [01, 02, 04]
* Local window [03, 05, 09]
*
* Combined events [01, 02, 03, 04, 05, 09]
*
* We split the combined result in the part that we want to present and a part
* that will be cached.
*
* Presented events [01, 02, 03]
* Cached events [04, 05, 09]
*
* We slide the window for the server since the oldest event is from the local
* index.
*
* Server events [01, 02, 04, 06, 07, 08, 11, 13]
* |06, 07, 08|
* Local events [03, 05, 09, 10, 12, 14, 15, 16]
* |XX, XX, XX|
* Cached events [04, 05, 09]
*
* We note that the oldest event is from the server and we combine the new
* server events with the cached ones.
*
* Cached events [04, 05, 09]
* Server events [06, 07, 08]
*
* Combined events [04, 05, 06, 07, 08, 09]
*
* We split again.
*
* Presented events [04, 05, 06]
* Cached events [07, 08, 09]
*
* We slide the local window, the oldest event is on the server.
*
* Server events [01, 02, 04, 06, 07, 08, 11, 13]
* |XX, XX, XX|
* Local events [03, 05, 09, 10, 12, 14, 15, 16]
* |10, 12, 14|
*
* Cached events [07, 08, 09]
* Local events [10, 12, 14]
* Combined events [07, 08, 09, 10, 12, 14]
*
* Presented events [07, 08, 09]
* Cached events [10, 12, 14]
*
* Next up we slide the server window again.
*
* Server events [01, 02, 04, 06, 07, 08, 11, 13]
* |11, 13|
* Local events [03, 05, 09, 10, 12, 14, 15, 16]
* |XX, XX, XX|
*
* Cached events [10, 12, 14]
* Server events [11, 13]
* Combined events [10, 11, 12, 13, 14]
*
* Presented events [10, 11, 12]
* Cached events [13, 14]
*
* We have one source exhausted, we fetch the rest of our events from the other
* source and combine it with our cached events.
*
*
* @param {object} previousSearchResult A search result from a previous search
* call.
* @param {object} localEvents An unprocessed search result from the event
* index.
* @param {object} serverEvents An unprocessed search result from the server.
*
* @return {object} A response object that combines the events from the
* different event sources.
*
*/
function combineEvents(
previousSearchResult: ISeshatSearchResults,
localEvents?: IResultRoomEvents,
serverEvents?: IResultRoomEvents,
): IResultRoomEvents {
const response = {} as IResultRoomEvents;
const cachedEvents = previousSearchResult.cachedEvents ?? [];
let oldestEventFrom = previousSearchResult.oldestEventFrom;
response.highlights = previousSearchResult.highlights;
if (localEvents && serverEvents && serverEvents.results) {
// This is a first search call, combine the events from the server and
// the local index. Note where our oldest event came from, we shall
// fetch the next batch of events from the other source.
if (compareOldestEvents(localEvents.results ?? [], serverEvents.results) < 0) {
oldestEventFrom = "local";
}
combineEventSources(previousSearchResult, response, localEvents.results ?? [], serverEvents.results);
response.highlights = (localEvents.highlights ?? []).concat(serverEvents.highlights ?? []);
} else if (localEvents) {
// This is a pagination call fetching more events from the local index,
// meaning that our oldest event was on the server.
// Change the source of the oldest event if our local event is older
// than the cached one.
if (compareOldestEvents(localEvents.results ?? [], cachedEvents) < 0) {
oldestEventFrom = "local";
}
combineEventSources(previousSearchResult, response, localEvents.results ?? [], cachedEvents);
} else if (serverEvents && serverEvents.results) {
// This is a pagination call fetching more events from the server,
// meaning that our oldest event was in the local index.
// Change the source of the oldest event if our server event is older
// than the cached one.
if (compareOldestEvents(serverEvents.results, cachedEvents) < 0) {
oldestEventFrom = "server";
}
combineEventSources(previousSearchResult, response, serverEvents.results, cachedEvents);
} else {
// This is a pagination call where we exhausted both of our event
// sources, let's push the remaining cached events.
response.results = cachedEvents;
previousSearchResult.cachedEvents = [];
}
previousSearchResult.oldestEventFrom = oldestEventFrom;
return response;
}
/**
* Combine the local and server search responses
*
* @param {object} previousSearchResult A search result from a previous search
* call.
* @param {object} localEvents An unprocessed search result from the event
* index.
* @param {object} serverEvents An unprocessed search result from the server.
*
* @return {object} A response object that combines the events from the
* different event sources.
*/
function combineResponses(
previousSearchResult: ISeshatSearchResults,
localEvents?: IResultRoomEvents,
serverEvents?: IResultRoomEvents,
): IResultRoomEvents {
// Combine our events first.
const response = combineEvents(previousSearchResult, localEvents, serverEvents);
// Our first search will contain counts from both sources, subsequent
// pagination requests will fetch responses only from one of the sources, so
// reuse the first count when we're paginating.
if (previousSearchResult.count) {
response.count = previousSearchResult.count;
} else {
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 && isNotUndefined(previousSearchResult.seshatQuery)) {
previousSearchResult.seshatQuery.next_batch = localEvents.next_batch;
}
if (serverEvents) {
previousSearchResult.serverSideNextBatch = serverEvents.next_batch;
}
// 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) {
response.next_batch = previousSearchResult.seshatQuery.next_batch;
} else if (previousSearchResult.serverSideNextBatch) {
response.next_batch = previousSearchResult.serverSideNextBatch;
}
// We collected all search results from the server as well as from Seshat,
// we still have some events cached that we'll want to display on the next
// pagination request.
//
// Provide a fake next batch token for that case.
if (
!response.next_batch &&
isNotUndefined(previousSearchResult.cachedEvents) &&
previousSearchResult.cachedEvents.length > 0
) {
response.next_batch = "cached";
}
return response;
}
interface IEncryptedSeshatEvent {
curve25519Key?: string;
ed25519Key?: string;
algorithm?: string;
forwardingCurve25519KeyChain?: string[];
}
function restoreEncryptionInfo(searchResultSlice: SearchResult[] = []): void {
for (const result of searchResultSlice) {
const timeline = result.context.getTimeline();
for (const mxEv of timeline) {
const ev = mxEv.event as IEncryptedSeshatEvent;
if (ev.curve25519Key) {
mxEv.makeEncrypted(
EventType.RoomMessageEncrypted,
{ algorithm: ev.algorithm },
ev.curve25519Key,
ev.ed25519Key!,
);
// @ts-ignore
mxEv.forwardingCurve25519KeyChain = ev.forwardingCurve25519KeyChain;
delete ev.curve25519Key;
delete ev.ed25519Key;
delete ev.algorithm;
delete ev.forwardingCurve25519KeyChain;
}
}
}
}
async function combinedPagination(
client: MatrixClient,
searchResult: ISeshatSearchResults,
): Promise<ISeshatSearchResults> {
const eventIndex = EventIndexPeg.get();
const searchArgs = searchResult.seshatQuery;
const oldestEventFrom = searchResult.oldestEventFrom;
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);
}
// 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 };
serverSideResult = await client.search(body);
}
const serverEvents: IResultRoomEvents | undefined = serverSideResult?.search_categories.room_events;
// Combine our events.
const combinedResult = combineResponses(searchResult, localResult, serverEvents);
const response = {
search_categories: {
room_events: combinedResult,
},
};
const oldResultCount = searchResult.results ? searchResult.results.length : 0;
// Let the client process the combined result.
const result = client.processRoomEventsSearch(searchResult, response);
// Restore our encryption info so we can properly re-verify the events.
const newResultCount = result.results.length - oldResultCount;
const newSlice = result.results.slice(Math.max(result.results.length - newResultCount, 0));
restoreEncryptionInfo(newSlice);
searchResult.pendingRequest = undefined;
return result;
}
function eventIndexSearch(
client: MatrixClient,
term: string,
roomId?: string,
abortSignal?: AbortSignal,
): Promise<ISearchResults> {
let searchPromise: Promise<ISearchResults>;
if (roomId !== undefined) {
if (client.isRoomEncrypted(roomId)) {
// The search is for a single encrypted room, use our local
// search method.
searchPromise = localSearchProcess(client, term, roomId);
} else {
// The search is for a single non-encrypted room, use the
// server-side search.
searchPromise = serverSideSearchProcess(client, term, roomId, abortSignal);
}
} else {
// Search across all rooms, combine a server side search and a
// local search.
searchPromise = combinedSearch(client, term, abortSignal);
}
return searchPromise;
}
function eventIndexSearchPagination(
client: MatrixClient,
searchResult: ISeshatSearchResults,
): Promise<ISeshatSearchResults> {
const seshatQuery = searchResult.seshatQuery;
const serverQuery = searchResult._query;
if (!seshatQuery) {
// This is a search in a non-encrypted room. Do the normal server-side
// pagination.
return client.backPaginateRoomEventsSearch(searchResult);
} else if (!serverQuery) {
// This is a search in a encrypted room. Do a local pagination.
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(client, searchResult);
searchResult.pendingRequest = promise;
return promise;
}
}
export function searchPagination(client: MatrixClient, searchResult: ISearchResults): Promise<ISearchResults> {
const eventIndex = EventIndexPeg.get();
if (searchResult.pendingRequest) return searchResult.pendingRequest;
if (eventIndex === null) return client.backPaginateRoomEventsSearch(searchResult);
else return eventIndexSearchPagination(client, searchResult);
}
export default function eventSearch(
client: MatrixClient,
term: string,
roomId?: string,
abortSignal?: AbortSignal,
): Promise<ISearchResults> {
const eventIndex = EventIndexPeg.get();
if (eventIndex === null) {
return serverSideSearchProcess(client, term, roomId, abortSignal);
} else {
return eventIndexSearch(client, term, roomId, abortSignal);
}
}
/**
* The scope for a message search, either in the current room or across all rooms.
*/
export enum SearchScope {
Room = "Room",
All = "All",
}
/**
* Information about a message search in progress.
*/
export interface SearchInfo {
/**
* Opaque ID for this search.
*/
searchId: number;
/**
* The room ID being searched, or undefined if searching all rooms.
*/
roomId?: string;
/**
* The search term.
*/
term: string;
/**
* The scope of the search.
*/
scope: SearchScope;
/**
* The promise for the search results.
*/
promise: Promise<ISearchResults>;
/**
* Controller for aborting the search.
*/
abortController?: AbortController;
/**
* Whether the search is currently awaiting data from the backend.
*/
inProgress?: boolean;
/**
* The total count of matching results as returned by the backend.
*/
count?: number;
}

291
src/SecurityManager.ts Normal file
View File

@@ -0,0 +1,291 @@
/*
Copyright 2024 New Vector Ltd.
Copyright 2019, 2020 The Matrix.org Foundation C.I.C.
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only
Please see LICENSE files in the repository root for full details.
*/
import { ICryptoCallbacks, SecretStorage } from "matrix-js-sdk/src/matrix";
import { deriveRecoveryKeyFromPassphrase, decodeRecoveryKey } from "matrix-js-sdk/src/crypto-api";
import { logger } from "matrix-js-sdk/src/logger";
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 { ModuleRunner } from "./modules/ModuleRunner";
import QuestionDialog from "./components/views/dialogs/QuestionDialog";
import InteractiveAuthDialog from "./components/views/dialogs/InteractiveAuthDialog";
// This stores the secret storage private keys in memory for the JS SDK. This is
// only meant to act as a cache to avoid prompting the user multiple times
// during the same single operation. Use `accessSecretStorage` below to scope a
// single secret storage operation, as it will clear the cached keys once the
// operation ends.
let secretStorageKeys: Record<string, Uint8Array> = {};
let secretStorageKeyInfo: Record<string, SecretStorage.SecretStorageKeyDescription> = {};
let secretStorageBeingAccessed = false;
/**
* This can be used by other components to check if secret storage access is in
* progress, so that we can e.g. avoid intermittently showing toasts during
* secret storage setup.
*
* @returns {bool}
*/
export function isSecretStorageBeingAccessed(): boolean {
return secretStorageBeingAccessed;
}
export class AccessCancelledError extends Error {
public constructor() {
super("Secret storage access canceled");
}
}
async function confirmToDismiss(): Promise<boolean> {
const [sure] = await Modal.createDialog(QuestionDialog, {
title: _t("encryption|cancel_entering_passphrase_title"),
description: _t("encryption|cancel_entering_passphrase_description"),
danger: false,
button: _t("action|go_back"),
cancelButton: _t("action|cancel"),
}).finished;
return !sure;
}
function makeInputToKey(
keyInfo: SecretStorage.SecretStorageKeyDescription,
): (keyParams: KeyParams) => Promise<Uint8Array> {
return async ({ passphrase, recoveryKey }): Promise<Uint8Array> => {
if (passphrase) {
return deriveRecoveryKeyFromPassphrase(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, SecretStorage.SecretStorageKeyDescription>;
}): Promise<[string, Uint8Array]> {
const cli = MatrixClientPeg.safeGet();
let keyId = await cli.getDefaultSecretStorageKeyId();
let keyInfo!: SecretStorage.SecretStorageKeyDescription;
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 = null;
}
}
if (!keyId) {
// if no default SSSS key is set, fall back to a heuristic of using the
// only available key, if only one key is set
const keyInfoEntries = Object.entries(keyInfos);
if (keyInfoEntries.length > 1) {
throw new Error("Multiple storage key requests not implemented");
}
[keyId, keyInfo] = keyInfoEntries[0];
}
logger.debug(`getSecretStorageKey: request for 4S keys [${Object.keys(keyInfos)}]: looking for key ${keyId}`);
// Check the in-memory cache
if (secretStorageBeingAccessed && secretStorageKeys[keyId]) {
logger.debug(`getSecretStorageKey: returning key ${keyId} from cache`);
return [keyId, secretStorageKeys[keyId]];
}
const keyFromCustomisations = ModuleRunner.instance.extensions.cryptoSetup.getSecretStorageKey();
if (keyFromCustomisations) {
logger.log("getSecretStorageKey: Using secret storage key from CryptoSetupExtension");
cacheSecretStorageKey(keyId, keyInfo, keyFromCustomisations);
return [keyId, keyFromCustomisations];
}
logger.debug("getSecretStorageKey: prompting user for key");
const inputToKey = makeInputToKey(keyInfo);
const { finished } = Modal.createDialog(
AccessSecretStorageDialog,
/* props= */
{
keyInfo,
checkPrivateKey: async (input: KeyParams): Promise<boolean> => {
const key = await inputToKey(input);
return MatrixClientPeg.safeGet().secretStorage.checkKey(key, keyInfo);
},
},
/* className= */ undefined,
/* isPriorityModal= */ false,
/* isStaticModal= */ false,
/* options= */ {
onBeforeClose: async (reason): Promise<boolean> => {
if (reason === "backgroundClick") {
return confirmToDismiss();
}
return true;
},
},
);
const [keyParams] = await finished;
if (!keyParams) {
throw new AccessCancelledError();
}
logger.debug("getSecretStorageKey: got key from user");
const key = await inputToKey(keyParams);
// Save to cache to avoid future prompts in the current session
cacheSecretStorageKey(keyId, keyInfo, key);
return [keyId, key];
}
function cacheSecretStorageKey(
keyId: string,
keyInfo: SecretStorage.SecretStorageKeyDescription,
key: Uint8Array,
): void {
if (secretStorageBeingAccessed) {
secretStorageKeys[keyId] = key;
secretStorageKeyInfo[keyId] = keyInfo;
}
}
export const crossSigningCallbacks: ICryptoCallbacks = {
getSecretStorageKey,
cacheSecretStorageKey,
};
/**
* 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> {
logger.debug("SecurityManager: enabling 4S key cache");
secretStorageBeingAccessed = true;
try {
return await func();
} finally {
// Clear secret storage key cache now that work is complete
logger.debug("SecurityManager: disabling 4S key cache");
secretStorageBeingAccessed = false;
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
* each other in a cycle of sorts) have been bootstrapped before running the
* provided function.
*
* Bootstrapping secret storage may take one of these paths:
* 1. Create secret storage from a passphrase and store cross-signing keys
* in secret storage.
* 2. Access existing secret storage by requesting passphrase and accessing
* cross-signing keys as needed.
* 3. All keys are loaded and there's nothing to do.
*
* Additionally, the secret storage keys are cached during the scope of this function
* to ensure the user is prompted only once for their secret storage
* passphrase. The cache is then cleared once the provided function completes.
*
* @param {Function} [func] An operation to perform once secret storage has been
* bootstrapped. Optional.
* @param {bool} [forceReset] Reset secret storage even if it's already set up
*/
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();
const crypto = cli.getCrypto();
if (!crypto) {
throw new Error("End-to-end encryption is disabled - unable to access secret storage.");
}
let createNew = false;
if (forceReset) {
logger.debug("accessSecretStorage: resetting 4S");
createNew = true;
} else if (!(await cli.secretStorage.hasKey())) {
logger.debug("accessSecretStorage: no 4S key configured, creating a new one");
createNew = true;
}
if (createNew) {
// 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<
typeof CreateSecretStorageDialog
>,
{
forceReset,
},
undefined,
/* priority = */ false,
/* static = */ true,
/* options = */ {
onBeforeClose: async (reason): Promise<boolean> => {
// If Secure Backup is required, you cannot leave the modal.
if (reason === "backgroundClick") {
return !isSecureBackupRequired(cli);
}
return true;
},
},
);
const [confirmed] = await finished;
if (!confirmed) {
throw new Error("Secret storage creation canceled");
}
} else {
logger.debug("accessSecretStorage: bootstrapCrossSigning");
await crypto.bootstrapCrossSigning({
authUploadDeviceSigningKeys: async (makeRequest): Promise<void> => {
logger.debug("accessSecretStorage: performing UIA to upload cross-signing keys");
const { finished } = Modal.createDialog(InteractiveAuthDialog, {
title: _t("encryption|bootstrap_title"),
matrixClient: cli,
makeRequest,
});
const [confirmed] = await finished;
if (!confirmed) {
throw new Error("Cross-signing key upload auth canceled");
}
logger.debug("accessSecretStorage: Cross-signing key upload successful");
},
});
logger.debug("accessSecretStorage: bootstrapSecretStorage");
await crypto.bootstrapSecretStorage({});
}
logger.debug("accessSecretStorage: 4S now ready");
// `return await` needed here to ensure `finally` block runs after the
// inner operation completes.
await func();
logger.debug("accessSecretStorage: operation complete");
} catch (e) {
ModuleRunner.instance.extensions.cryptoSetup.catchAccessSecretStorageError(e as Error);
logger.error("accessSecretStorage: error during operation", e);
// Re-throw so that higher level logic can abort as needed
throw e;
}
}

67
src/SendHistoryManager.ts Normal file
View File

@@ -0,0 +1,67 @@
/*
Copyright 2024 New Vector Ltd.
Copyright 2017 Aviral Dasgupta
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only
Please see LICENSE files in the repository root for full details.
*/
import { clamp } from "lodash";
import { MatrixEvent } from "matrix-js-sdk/src/matrix";
import { logger } from "matrix-js-sdk/src/logger";
import { SerializedPart } from "./editor/parts";
import EditorModel from "./editor/model";
interface IHistoryItem {
parts: SerializedPart[];
replyEventId?: string;
}
export default class SendHistoryManager {
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
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}]`))) {
try {
this.history.push(JSON.parse(itemJSON));
} catch (e) {
logger.warn("Throwing away unserialisable history", e);
break;
}
++index;
}
this.lastIndex = this.history.length - 1;
// reset currentIndex to account for any unserialisable history
this.currentIndex = this.lastIndex + 1;
}
public static createItem(model: EditorModel, replyEvent?: MatrixEvent): IHistoryItem {
return {
parts: model.serializeParts(),
replyEventId: replyEvent ? replyEvent.getId() : undefined,
};
}
public save(editorModel: EditorModel, replyEvent?: MatrixEvent): void {
const item = SendHistoryManager.createItem(editorModel, replyEvent);
this.history.push(item);
this.currentIndex = this.history.length;
this.lastIndex += 1;
sessionStorage.setItem(`${this.prefix}[${this.lastIndex}]`, JSON.stringify(item));
}
public getItem(offset: number): IHistoryItem {
this.currentIndex = clamp(this.currentIndex + offset, 0, this.history.length - 1);
return this.history[this.currentIndex];
}
}

1061
src/SlashCommands.tsx Normal file

File diff suppressed because it is too large Load Diff

410
src/SlidingSyncManager.ts Normal file
View File

@@ -0,0 +1,410 @@
/*
Copyright 2024 New Vector Ltd.
Copyright 2022 The Matrix.org Foundation C.I.C.
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only
Please see LICENSE files in the repository root for full details.
*/
/*
* Sliding Sync Architecture - MSC https://github.com/matrix-org/matrix-spec-proposals/pull/3575
*
* This is a holistic summary of the changes made to Element-Web / React SDK / JS SDK to enable sliding sync.
* This summary will hopefully signpost where developers need to look if they want to make changes to this code.
*
* At the lowest level, the JS SDK contains an HTTP API wrapper function in client.ts. This is used by
* a SlidingSync class in JS SDK, which contains code to handle list operations (INSERT/DELETE/SYNC/etc)
* and contains the main request API bodies, but has no code to control updating JS SDK structures: it just
* exposes an EventEmitter to listen for updates. When MatrixClient.startClient is called, callers need to
* provide a SlidingSync instance as this contains the main request API params (timeline limit, required state,
* how many lists, etc).
*
* The SlidingSyncSdk INTERNAL class in JS SDK attaches listeners to SlidingSync to update JS SDK Room objects,
* and it conveniently exposes an identical public API to SyncApi (to allow it to be a drop-in replacement).
*
* At the highest level, SlidingSyncManager contains mechanisms to tell UI lists which rooms to show,
* and contains the core request API params used in Element-Web. It does this by listening for events
* emitted by the SlidingSync class and by modifying the request API params on the SlidingSync class.
*
* (entry point) (updates JS SDK)
* SlidingSyncManager SlidingSyncSdk
* | |
* +------------------.------------------+
* listens | listens
* SlidingSync
* (sync loop,
* list ops)
*/
import { MatrixClient, EventType, AutoDiscovery, Method, timeoutSignal } 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";
import { logger } from "matrix-js-sdk/src/logger";
import { defer, sleep } from "matrix-js-sdk/src/utils";
import SettingsStore from "./settings/SettingsStore";
import SlidingSyncController from "./settings/controllers/SlidingSyncController";
// how long to long poll for
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,
// 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
[EventType.RoomCreate, ""],
[EventType.RoomTombstone, ""],
[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;
sort?: string[];
ranges?: [startIndex: number, endIndex: number][];
};
/**
* This class manages the entirety of sliding sync at a high UI/UX level. It controls the placement
* of placeholders in lists, controls updating sliding window ranges, and controls which events
* are pulled down when. The intention behind this manager is be the single place to look for sliding
* sync options and code.
*/
export class SlidingSyncManager {
public static readonly ListSpaces = "space_list";
public static readonly ListSearch = "search_list";
private static readonly internalInstance = new SlidingSyncManager();
public slidingSync?: SlidingSync;
private client?: MatrixClient;
private configureDefer = defer<void>();
public static get instance(): SlidingSyncManager {
return SlidingSyncManager.internalInstance;
}
public configure(client: MatrixClient, proxyUrl: string): SlidingSync {
this.client = client;
// 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,
new Map(),
ENCRYPTED_SUBSCRIPTION,
client,
SLIDING_SYNC_TIMEOUT_MS,
);
this.slidingSync.addCustomSubscription(UNENCRYPTED_SUBSCRIPTION_NAME, UNENCRYPTED_SUBSCRIPTION);
// set the space list
this.slidingSync.setList(SlidingSyncManager.ListSpaces, {
ranges: [[0, 20]],
sort: ["by_name"],
slow_get_all_rooms: true,
timeline_limit: 0,
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.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, 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: {
room_types: ["m.space"],
},
});
this.configureDefer.resolve();
return this.slidingSync;
}
/**
* Ensure that this list is registered.
* @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(listKey: string, updateArgs: PartialSlidingSyncRequest): Promise<MSC3575List> {
logger.debug("ensureListRegistered:::", listKey, updateArgs);
await this.configureDefer.promise;
let list = this.slidingSync!.getListParams(listKey);
if (!list) {
list = {
ranges: [[0, 20]],
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
[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
],
include_old_rooms: {
timeline_limit: 0,
required_state: [
[EventType.RoomCreate, ""],
[EventType.RoomTombstone, ""], // lets JS SDK hide rooms which are dead
[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
],
},
};
list = Object.assign(list, updateArgs);
} else {
const updatedList = Object.assign({}, list, updateArgs);
// cannot use objectHasDiff as we need to do deep diff checking
if (JSON.stringify(list) === JSON.stringify(updatedList)) {
logger.debug("list matches, not sending, update => ", updateArgs);
return list;
}
list = updatedList;
}
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(listKey, updateArgs.ranges);
} else {
await this.slidingSync!.setList(listKey, list);
}
} catch (err) {
logger.debug("ensureListRegistered: update failed txn_id=", err);
}
return this.slidingSync!.getListParams(listKey)!;
}
public async setRoomVisible(roomId: string, visible: boolean): Promise<string> {
await this.configureDefer.promise;
const subscriptions = this.slidingSync!.getRoomSubscriptions();
if (visible) {
subscriptions.add(roomId);
} else {
subscriptions.delete(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 {
// wait until the next sync before returning as RoomView may need to know the current state
await p;
} catch (err) {
logger.warn("SlidingSync setRoomVisible:", roomId, visible, "failed to confirm transaction");
}
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;
}
}
/**
* Set up the Sliding Sync instance; configures the end point and starts spidering.
* The sliding sync endpoint is derived the following way:
* 1. The user-defined sliding sync proxy URL (legacy, for backwards compatibility)
* 2. The client `well-known` sliding sync proxy URL [declared at the unstable prefix](https://github.com/matrix-org/matrix-spec-proposals/blob/kegan/sync-v3/proposals/3575-sync.md#unstable-prefix)
* 3. The homeserver base url (for native server support)
* @param client The MatrixClient to use
* @returns A working Sliding Sync or undefined
*/
public async setup(client: MatrixClient): Promise<SlidingSync | undefined> {
const baseUrl = client.baseUrl;
const proxyUrl = SettingsStore.getValue("feature_sliding_sync_proxy_url");
const wellKnownProxyUrl = await this.getProxyFromWellKnown(client);
const slidingSyncEndpoint = proxyUrl || wellKnownProxyUrl || baseUrl;
this.configure(client, slidingSyncEndpoint);
logger.info("Sliding sync activated at", slidingSyncEndpoint);
this.startSpidering(100, 50); // 100 rooms at a time, 50ms apart
return this.slidingSync;
}
/**
* Get the sliding sync proxy URL from the client well known
* @param client The MatrixClient to use
* @return The proxy url
*/
public async getProxyFromWellKnown(client: MatrixClient): Promise<string | undefined> {
let proxyUrl: string | undefined;
try {
const clientDomain = await client.getDomain();
if (clientDomain === null) {
throw new RangeError("Homeserver domain is null");
}
const clientWellKnown = await AutoDiscovery.findClientConfig(clientDomain);
proxyUrl = clientWellKnown?.["org.matrix.msc3575.proxy"]?.url;
} catch (e) {
// Either client.getDomain() is null so we've shorted out, or is invalid so `AutoDiscovery.findClientConfig` has thrown
}
if (proxyUrl != undefined) {
logger.log("getProxyFromWellKnown: client well-known declares sliding sync proxy at", proxyUrl);
}
return proxyUrl;
}
/**
* Check if the server "natively" supports sliding sync (with an unstable endpoint).
* @param client The MatrixClient to use
* @return Whether the "native" (unstable) endpoint is supported
*/
public async nativeSlidingSyncSupport(client: MatrixClient): Promise<boolean> {
// Per https://github.com/matrix-org/matrix-spec-proposals/pull/3575/files#r1589542561
// `client` can be undefined/null in tests for some reason.
const support = await client?.doesServerSupportUnstableFeature("org.matrix.msc3575");
if (support) {
logger.log("nativeSlidingSyncSupport: sliding sync advertised as unstable");
}
return support;
}
/**
* Check whether our homeserver has sliding sync support, that the endpoint is up, and
* is a sliding sync endpoint.
*
* Sets static member `SlidingSyncController.serverSupportsSlidingSync`
* @param client The MatrixClient to use
*/
public async checkSupport(client: MatrixClient): Promise<void> {
if (await this.nativeSlidingSyncSupport(client)) {
SlidingSyncController.serverSupportsSlidingSync = true;
return;
}
const proxyUrl = await this.getProxyFromWellKnown(client);
if (proxyUrl != undefined) {
const response = await fetch(new URL("/client/server.json", proxyUrl), {
method: Method.Get,
signal: timeoutSignal(10 * 1000), // 10s
});
if (response.status === 200) {
logger.log("checkSupport: well-known sliding sync proxy is up at", proxyUrl);
SlidingSyncController.serverSupportsSlidingSync = true;
}
}
}
}

114
src/SupportedBrowser.ts Normal file
View File

@@ -0,0 +1,114 @@
/*
Copyright 2024 New Vector Ltd.
Copyright 2024 The Matrix.org Foundation C.I.C.
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only
Please see LICENSE files in the repository root for full details.
*/
import { logger } from "matrix-js-sdk/src/logger";
import browserlist from "browserslist";
import PopOutIcon from "@vector-im/compound-design-tokens/assets/web/icons/pop-out";
import { DeviceType, parseUserAgent } from "./utils/device/parseUserAgent";
import ToastStore from "./stores/ToastStore";
import GenericToast from "./components/views/toasts/GenericToast";
import { _t } from "./languageHandler";
import SdkConfig from "./SdkConfig";
export const LOCAL_STORAGE_KEY = "mx_accepts_unsupported_browser";
const TOAST_KEY = "unsupportedbrowser";
const SUPPORTED_DEVICE_TYPES = [DeviceType.Web, DeviceType.Desktop];
const SUPPORTED_BROWSER_QUERY =
"last 2 Chrome versions, last 2 Firefox versions, last 2 Safari versions, last 2 Edge versions";
const LEARN_MORE_URL = "https://github.com/element-hq/element-web#supported-environments";
function onLearnMoreClick(): void {
onDismissClick();
window.open(LEARN_MORE_URL, "_blank", "noopener,noreferrer");
}
function onDismissClick(): void {
localStorage.setItem(LOCAL_STORAGE_KEY, String(true));
ToastStore.sharedInstance().dismissToast(TOAST_KEY);
}
function getBrowserNameVersion(browser: string): [name: string, version: number] {
const [browserName, browserVersion] = browser.split(" ");
const browserNameLc = browserName.toLowerCase();
return [browserNameLc, parseInt(browserVersion, 10)];
}
/**
* Function to check if the current browser is considered supported by our support policy.
* Based on user agent parsing so may be inaccurate if the user has fingerprint prevention turned up to 11.
*/
export function getBrowserSupport(): boolean {
const browsers = browserlist(SUPPORTED_BROWSER_QUERY).sort();
const minimumBrowserVersions = new Map<string, number>();
for (const browser of browsers) {
const [browserName, browserVersion] = getBrowserNameVersion(browser);
// We sorted the browsers so will encounter the minimum version first
if (minimumBrowserVersions.has(browserName)) continue;
minimumBrowserVersions.set(browserName, browserVersion);
}
const details = parseUserAgent(navigator.userAgent);
let supported = true;
if (!SUPPORTED_DEVICE_TYPES.includes(details.deviceType)) {
logger.warn("Browser unsupported, unsupported device type", details.deviceType);
supported = false;
}
if (details.client) {
// We don't care about the browser version for desktop devices
// We ship our own browser (electron) for desktop devices
if (details.deviceType === DeviceType.Desktop) {
return supported;
}
const [browserName, browserVersion] = getBrowserNameVersion(details.client);
const minimumVersion = minimumBrowserVersions.get(browserName);
// Check both with the sub-version cut off and without as some browsers have less granular versioning e.g. Safari
if (!minimumVersion || browserVersion < minimumVersion) {
logger.warn("Browser unsupported, unsupported user agent", details.client);
supported = false;
}
} else {
logger.warn("Browser unsupported, unknown client", navigator.userAgent);
supported = false;
}
return supported;
}
/**
* Shows a user warning toast if the user's browser is not supported.
*/
export function checkBrowserSupport(): void {
const supported = getBrowserSupport();
if (supported) return;
if (localStorage.getItem(LOCAL_STORAGE_KEY)) {
logger.warn("Browser unsupported, but user has previously accepted");
return;
}
const brand = SdkConfig.get().brand;
ToastStore.sharedInstance().addOrReplaceToast({
key: TOAST_KEY,
title: _t("unsupported_browser|title", { brand }),
props: {
description: _t("unsupported_browser|description", { brand }),
secondaryLabel: _t("action|learn_more"),
SecondaryIcon: PopOutIcon,
onSecondaryClick: onLearnMoreClick,
primaryLabel: _t("action|dismiss"),
onPrimaryClick: onDismissClick,
},
component: GenericToast,
priority: 40,
});
}

205
src/Terms.ts Normal file
View File

@@ -0,0 +1,205 @@
/*
Copyright 2024 New Vector Ltd.
Copyright 2019-2021 The Matrix.org Foundation C.I.C.
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only
Please see LICENSE files in the repository root for full details.
*/
import classNames from "classnames";
import { SERVICE_TYPES, MatrixClient } from "matrix-js-sdk/src/matrix";
import { logger } from "matrix-js-sdk/src/logger";
import Modal from "./Modal";
import TermsDialog from "./components/views/dialogs/TermsDialog";
export class TermsNotSignedError extends Error {}
/**
* Class representing a service that may have terms & conditions that
* require agreement from the user before the user can use that service.
*/
export class Service {
/**
* @param {MatrixClient.SERVICE_TYPES} serviceType The type of service
* @param {string} baseUrl The Base URL of the service (ie. before '/_matrix')
* @param {string} accessToken The user's access token for the service
*/
public constructor(
public serviceType: SERVICE_TYPES,
public baseUrl: string,
public accessToken: string,
) {}
}
export interface LocalisedPolicy {
name: string;
url: string;
}
export interface Policy {
// @ts-ignore: No great way to express indexed types together with other keys
version: string;
[lang: string]: LocalisedPolicy;
}
export type Policies = {
[policy: string]: Policy;
};
export type ServicePolicyPair = {
policies: Policies;
service: Service;
};
export type TermsInteractionCallback = (
policiesAndServicePairs: ServicePolicyPair[],
agreedUrls: string[],
extraClassNames?: string,
) => Promise<string[]>;
/**
* 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} }
* * an array of URLs the user has already agreed to
* Must return a Promise which resolves with a list of URLs of documents agreed to
* @returns {Promise} resolves when the user agreed to all necessary terms or rejects
* if they cancel.
*/
export async function startTermsFlow(
client: MatrixClient,
services: Service[],
interactionCallback: TermsInteractionCallback = dialogTermsInteractionCallback,
): Promise<void> {
const termsPromises = services.map((s) => client.getTerms(s.serviceType, s.baseUrl));
/*
* a /terms response looks like:
* {
* "policies": {
* "terms_of_service": {
* "version": "2.0",
* "en": {
* "name": "Terms of Service",
* "url": "https://example.org/somewhere/terms-2.0-en.html"
* },
* "fr": {
* "name": "Conditions d'utilisation",
* "url": "https://example.org/somewhere/terms-2.0-fr.html"
* }
* }
* }
* }
*/
const terms: { policies: Policies }[] = await Promise.all(termsPromises);
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 client.getAccountData("m.accepted_terms");
let agreedUrlSet: Set<string>;
if (!currentAcceptedTerms || !currentAcceptedTerms.getContent() || !currentAcceptedTerms.getContent().accepted) {
agreedUrlSet = new Set();
} else {
agreedUrlSet = new Set(currentAcceptedTerms.getContent().accepted);
}
// remove any policies the user has already agreed to and any services where
// they've already agreed to all the policies
// NB. it could be nicer to show the user stuff they've already agreed to,
// 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: ServicePolicyPair[] = [];
for (const { service, policies } of policiesAndServicePairs) {
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 (agreedUrlSet.has(policy[lang].url)) {
policyAgreed = true;
break;
}
}
if (!policyAgreed) unagreedPolicies[policyName] = policy;
}
if (Object.keys(unagreedPolicies).length > 0) {
unagreedPoliciesAndServicePairs.push({ service, policies: unagreedPolicies });
}
}
// if there's anything left to agree to, prompt the user
const numAcceptedBeforeAgreement = agreedUrlSet.size;
if (unagreedPoliciesAndServicePairs.length > 0) {
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));
} else {
logger.log("User has already agreed to all required policies");
}
// 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 client.setAccountData("m.accepted_terms", newAcceptedTerms);
}
const agreePromises = policiesAndServicePairs.map((policiesAndService) => {
// filter the agreed URL list for ones that are actually for this service
// (one URL may be used for multiple services)
// Not a particularly efficient loop but probably fine given the numbers involved
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 (policy[lang].url === url) return true;
}
}
return false;
});
if (urlsForService.length === 0) return Promise.resolve();
return client.agreeToTerms(
policiesAndService.service.serviceType,
policiesAndService.service.baseUrl,
policiesAndService.service.accessToken,
urlsForService,
);
});
await Promise.all(agreePromises);
}
export async function dialogTermsInteractionCallback(
policiesAndServicePairs: {
service: Service;
policies: { [policy: string]: Policy };
}[],
agreedUrls: string[],
extraClassNames?: string,
): Promise<string[]> {
logger.log("Terms that need agreement", policiesAndServicePairs);
const { finished } = Modal.createDialog(
TermsDialog,
{
policiesAndServicePairs,
agreedUrls,
},
classNames("mx_TermsDialog", extraClassNames),
);
const [done, _agreedUrls] = await finished;
if (!done || !_agreedUrls) {
throw new TermsNotSignedError();
}
return _agreedUrls;
}

959
src/TextForEvent.tsx Normal file
View File

@@ -0,0 +1,959 @@
/*
Copyright 2024 New Vector Ltd.
Copyright 2015-2022 The Matrix.org Foundation C.I.C.
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only
Please see LICENSE files in the repository root for full details.
*/
import React from "react";
import {
MatrixEvent,
MatrixClient,
GuestAccess,
HistoryVisibility,
JoinRule,
EventType,
MsgType,
M_POLL_START,
M_POLL_END,
} from "matrix-js-sdk/src/matrix";
import { KnownMembership } from "matrix-js-sdk/src/types";
import { logger } from "matrix-js-sdk/src/logger";
import { removeDirectionOverrideChars } from "matrix-js-sdk/src/utils";
import { PollStartEvent } from "matrix-js-sdk/src/extensible_events_v1/PollStartEvent";
import { _t } from "./languageHandler";
import * as Roles from "./Roles";
import { isValid3pidInvite } from "./RoomInvite";
import SettingsStore from "./settings/SettingsStore";
import { ALL_RULE_TYPES, ROOM_RULE_TYPES, SERVER_RULE_TYPES, USER_RULE_TYPES } from "./mjolnir/BanList";
import { WIDGET_LAYOUT_EVENT_TYPE } from "./stores/widgets/WidgetLayoutStore";
import { RightPanelPhases } from "./stores/right-panel/RightPanelStorePhases";
import defaultDispatcher from "./dispatcher/dispatcher";
import { RoomSettingsTab } from "./components/views/dialogs/RoomSettingsDialog";
import AccessibleButton from "./components/views/elements/AccessibleButton";
import RightPanelStore from "./stores/right-panel/RightPanelStore";
import { highlightEvent, isLocationEvent } from "./utils/EventUtils";
import { ElementCall } from "./models/Call";
import { textForVoiceBroadcastStoppedEvent, VoiceBroadcastInfoEventType } from "./voice-broadcast";
import { getSenderName } from "./utils/event/getSenderName";
import PosthogTrackers from "./PosthogTrackers.ts";
function getRoomMemberDisplayname(client: MatrixClient, event: MatrixEvent, userId = event.getSender()): string {
const roomId = event.getRoomId();
const member = client.getRoom(roomId)?.getMember(userId!);
return member?.name || member?.rawDisplayName || userId || _t("common|someone");
}
function textForCallEvent(event: MatrixEvent, client: MatrixClient): () => string {
const roomName = client.getRoom(event.getRoomId()!)?.name;
const isSupported = client.supportsVoip();
return isSupported
? () => _t("timeline|m.call|video_call_started", { roomName })
: () => _t("timeline|m.call|video_call_started_unsupported", { roomName });
}
// These functions are frequently used just to check whether an event has
// any text to display at all. For this reason they return deferred values
// to avoid the expense of looking up translations when they're not needed.
function textForCallInviteEvent(event: MatrixEvent, client: MatrixClient): (() => string) | null {
const senderName = getSenderName(event);
// FIXME: Find a better way to determine this from the event?
const isVoice = !event.getContent().offer?.sdp?.includes("m=video");
const isSupported = client.supportsVoip();
// This ladder could be reduced down to a couple string variables, however other languages
// can have a hard time translating those strings. In an effort to make translations easier
// and more accurate, we break out the string-based variables to a couple booleans.
if (isVoice && isSupported) {
return () => _t("timeline|m.call.invite|voice_call", { senderName });
} else if (isVoice && !isSupported) {
return () => _t("timeline|m.call.invite|voice_call_unsupported", { senderName });
} else if (!isVoice && isSupported) {
return () => _t("timeline|m.call.invite|video_call", { senderName });
} else if (!isVoice && !isSupported) {
return () => _t("timeline|m.call.invite|video_call_unsupported", { senderName });
}
return null;
}
enum Modification {
None,
Unset,
Set,
Changed,
}
function getModification(prev?: string, value?: string): Modification {
if (prev && value && prev !== value) {
return Modification.Changed;
}
if (prev && !value) {
return Modification.Unset;
}
if (!prev && value) {
return Modification.Set;
}
return Modification.None;
}
function textForMemberEvent(
ev: MatrixEvent,
client: MatrixClient,
allowJSX: boolean,
showHiddenEvents?: boolean,
): (() => string) | null {
// XXX: SYJS-16 "sender is sometimes null for join messages"
const senderName = ev.sender?.name || getRoomMemberDisplayname(client, ev);
const targetName = ev.target?.name || getRoomMemberDisplayname(client, ev, ev.getStateKey());
const prevContent = ev.getPrevContent();
const content = ev.getContent();
const reason = content.reason;
switch (content.membership) {
case KnownMembership.Invite: {
const threePidContent = content.third_party_invite;
if (threePidContent) {
if (threePidContent.display_name) {
return () =>
_t("timeline|m.room.member|accepted_3pid_invite", {
targetName,
displayName: threePidContent.display_name,
});
} else {
return () => _t("timeline|m.room.member|accepted_invite", { targetName });
}
} else {
return () => _t("timeline|m.room.member|invite", { senderName, targetName });
}
}
case KnownMembership.Ban:
return () =>
reason
? _t("timeline|m.room.member|ban_reason", { senderName, targetName, reason })
: _t("timeline|m.room.member|ban", { senderName, targetName });
case KnownMembership.Join:
if (prevContent && prevContent.membership === KnownMembership.Join) {
const modDisplayname = getModification(prevContent.displayname, content.displayname);
const modAvatarUrl = getModification(prevContent.avatar_url, content.avatar_url);
if (modDisplayname !== Modification.None && modAvatarUrl !== Modification.None) {
// Compromise to provide the user with more context without needing 16 translations
return () =>
_t("timeline|m.room.member|change_name_avatar", {
// We're taking the display namke directly from the event content here so we need
// to strip direction override chars which the js-sdk would normally do when
// calculating the display name
oldDisplayName: removeDirectionOverrideChars(prevContent.displayname!),
});
} else if (modDisplayname === Modification.Changed) {
return () =>
_t("timeline|m.room.member|change_name", {
// We're taking the display name directly from the event content here so we need
// to strip direction override chars which the js-sdk would normally do when
// calculating the display name
oldDisplayName: removeDirectionOverrideChars(prevContent.displayname!),
displayName: removeDirectionOverrideChars(content.displayname!),
});
} else if (modDisplayname === Modification.Set) {
return () =>
_t("timeline|m.room.member|set_name", {
senderName: ev.getSender(),
displayName: removeDirectionOverrideChars(content.displayname!),
});
} else if (modDisplayname === Modification.Unset) {
return () =>
_t("timeline|m.room.member|remove_name", {
senderName,
oldDisplayName: removeDirectionOverrideChars(prevContent.displayname!),
});
} else if (modAvatarUrl === Modification.Unset) {
return () => _t("timeline|m.room.member|remove_avatar", { senderName });
} else if (modAvatarUrl === Modification.Changed) {
return () => _t("timeline|m.room.member|change_avatar", { senderName });
} else if (modAvatarUrl === Modification.Set) {
return () => _t("timeline|m.room.member|set_avatar", { senderName });
} else if (showHiddenEvents ?? SettingsStore.getValue("showHiddenEventsInTimeline")) {
// This is a null rejoin, it will only be visible if using 'show hidden events' (labs)
return () => _t("timeline|m.room.member|no_change", { senderName });
} else {
return null;
}
} else {
if (!ev.target) logger.warn("Join message has no target! -- " + ev.getContent().state_key);
return () => _t("timeline|m.room.member|join", { targetName });
}
case KnownMembership.Leave:
if (ev.getSender() === ev.getStateKey()) {
if (prevContent.membership === KnownMembership.Invite) {
return () => _t("timeline|m.room.member|reject_invite", { targetName });
} else {
return () =>
reason
? _t("timeline|m.room.member|left_reason", { targetName, reason })
: _t("timeline|m.room.member|left", { targetName });
}
} else if (prevContent.membership === KnownMembership.Ban) {
return () => _t("timeline|m.room.member|unban", { senderName, targetName });
} else if (prevContent.membership === KnownMembership.Invite) {
return () =>
reason
? _t("timeline|m.room.member|withdrew_invite_reason", {
senderName,
targetName,
reason,
})
: _t("timeline|m.room.member|withdrew_invite", { senderName, targetName });
} else if (prevContent.membership === KnownMembership.Join) {
return () =>
reason
? _t("timeline|m.room.member|kick_reason", {
senderName,
targetName,
reason,
})
: _t("timeline|m.room.member|kick", { senderName, targetName });
} else {
return null;
}
}
return null;
}
function textForTopicEvent(ev: MatrixEvent): (() => string) | null {
const senderDisplayName = ev.sender && ev.sender.name ? ev.sender.name : ev.getSender();
return () =>
_t("timeline|m.room.topic", {
senderDisplayName,
topic: ev.getContent().topic,
});
}
function textForRoomAvatarEvent(ev: MatrixEvent): (() => string) | null {
const senderDisplayName = ev?.sender?.name || ev.getSender();
return () => _t("timeline|m.room.avatar|changed", { senderDisplayName });
}
function textForRoomNameEvent(ev: MatrixEvent): (() => string) | null {
const senderDisplayName = ev.sender && ev.sender.name ? ev.sender.name : ev.getSender();
if (!ev.getContent().name || ev.getContent().name.trim().length === 0) {
return () => _t("timeline|m.room.name|remove", { senderDisplayName });
}
if (ev.getPrevContent().name) {
return () =>
_t("timeline|m.room.name|change", {
senderDisplayName,
oldRoomName: ev.getPrevContent().name,
newRoomName: ev.getContent().name,
});
}
return () =>
_t("timeline|m.room.name|set", {
senderDisplayName,
roomName: ev.getContent().name,
});
}
function textForTombstoneEvent(ev: MatrixEvent): (() => string) | null {
const senderDisplayName = ev.sender && ev.sender.name ? ev.sender.name : ev.getSender();
return () => _t("timeline|m.room.tombstone", { senderDisplayName });
}
const onViewJoinRuleSettingsClick = (): void => {
defaultDispatcher.dispatch({
action: "open_room_settings",
initial_tab_id: RoomSettingsTab.Security,
});
};
function textForJoinRulesEvent(ev: MatrixEvent, client: MatrixClient, allowJSX: boolean): () => Renderable {
const senderDisplayName = ev.sender && ev.sender.name ? ev.sender.name : ev.getSender();
switch (ev.getContent().join_rule) {
case JoinRule.Public:
return () =>
_t("timeline|m.room.join_rules|public", {
senderDisplayName,
});
case JoinRule.Invite:
return () =>
_t("timeline|m.room.join_rules|invite", {
senderDisplayName,
});
case JoinRule.Knock:
return () => _t("timeline|m.room.join_rules|knock", { senderDisplayName });
case JoinRule.Restricted:
if (allowJSX) {
return () => (
<span>
{_t(
"timeline|m.room.join_rules|restricted_settings",
{
senderDisplayName,
},
{
a: (sub) => (
<AccessibleButton kind="link_inline" onClick={onViewJoinRuleSettingsClick}>
{sub}
</AccessibleButton>
),
},
)}
</span>
);
}
return () => _t("timeline|m.room.join_rules|restricted", { senderDisplayName });
default:
// The spec supports "knock" and "private", however nothing implements these.
return () =>
_t("timeline|m.room.join_rules|unknown", {
senderDisplayName,
rule: ev.getContent().join_rule,
});
}
}
function textForGuestAccessEvent(ev: MatrixEvent): (() => string) | null {
const senderDisplayName = ev.sender && ev.sender.name ? ev.sender.name : ev.getSender();
switch (ev.getContent().guest_access) {
case GuestAccess.CanJoin:
return () => _t("timeline|m.room.guest_access|can_join", { senderDisplayName });
case GuestAccess.Forbidden:
return () => _t("timeline|m.room.guest_access|forbidden", { senderDisplayName });
default:
// There's no other options we can expect, however just for safety's sake we'll do this.
return () =>
_t("timeline|m.room.guest_access|unknown", {
senderDisplayName,
rule: ev.getContent().guest_access,
});
}
}
function textForServerACLEvent(ev: MatrixEvent): (() => string) | null {
const senderDisplayName = ev.sender && ev.sender.name ? ev.sender.name : ev.getSender();
const prevContent = ev.getPrevContent();
const current = ev.getContent();
const prev = {
deny: Array.isArray(prevContent.deny) ? prevContent.deny : [],
allow: Array.isArray(prevContent.allow) ? prevContent.allow : [],
allow_ip_literals: prevContent.allow_ip_literals !== false,
};
let getText: () => string;
if (prev.deny.length === 0 && prev.allow.length === 0) {
getText = () => _t("timeline|m.room.server_acl|set", { senderDisplayName });
} else {
getText = () => _t("timeline|m.room.server_acl|changed", { senderDisplayName });
}
if (!Array.isArray(current.allow)) {
current.allow = [];
}
// If we know for sure everyone is banned, mark the room as obliterated
if (current.allow.length === 0) {
return () => getText() + " " + _t("timeline|m.room.server_acl|all_servers_banned");
}
return getText;
}
function textForMessageEvent(ev: MatrixEvent, client: MatrixClient): (() => string) | null {
if (isLocationEvent(ev)) {
return textForLocationEvent(ev);
}
return () => {
const senderDisplayName = ev.sender && ev.sender.name ? ev.sender.name : ev.getSender();
let message = ev.getContent().body;
if (ev.isRedacted()) {
message = textForRedactedPollAndMessageEvent(ev, client);
}
if (ev.getContent().msgtype === MsgType.Emote) {
message = "* " + senderDisplayName + " " + message;
} else if (ev.getContent().msgtype === MsgType.Image) {
message = _t("timeline|m.image|sent", { senderDisplayName });
} else if (ev.getType() == EventType.Sticker) {
message = _t("timeline|m.sticker", { senderDisplayName });
} else {
// in this case, parse it as a plain text message
message = senderDisplayName + ": " + message;
}
return message;
};
}
function textForCanonicalAliasEvent(ev: MatrixEvent): (() => string) | null {
const senderName = getSenderName(ev);
const oldAlias = ev.getPrevContent().alias;
const oldAltAliases = ev.getPrevContent().alt_aliases || [];
const newAlias = ev.getContent().alias;
const newAltAliases = ev.getContent().alt_aliases || [];
const removedAltAliases = oldAltAliases.filter((alias: string) => !newAltAliases.includes(alias));
const addedAltAliases = newAltAliases.filter((alias: string) => !oldAltAliases.includes(alias));
if (!removedAltAliases.length && !addedAltAliases.length) {
if (newAlias) {
return () =>
_t("timeline|m.room.canonical_alias|set", {
senderName,
address: ev.getContent().alias,
});
} else if (oldAlias) {
return () =>
_t("timeline|m.room.canonical_alias|removed", {
senderName,
});
}
} else if (newAlias === oldAlias) {
if (addedAltAliases.length && !removedAltAliases.length) {
return () =>
_t("timeline|m.room.canonical_alias|alt_added", {
senderName,
addresses: addedAltAliases.join(", "),
count: addedAltAliases.length,
});
}
if (removedAltAliases.length && !addedAltAliases.length) {
return () =>
_t("timeline|m.room.canonical_alias|alt_removed", {
senderName,
addresses: removedAltAliases.join(", "),
count: removedAltAliases.length,
});
}
if (removedAltAliases.length && addedAltAliases.length) {
return () =>
_t("timeline|m.room.canonical_alias|changed_alternative", {
senderName,
});
}
} else {
// both alias and alt_aliases where modified
return () =>
_t("timeline|m.room.canonical_alias|changed_main_and_alternative", {
senderName,
});
}
// in case there is no difference between the two events,
// say something as we can't simply hide the tile from here
return () =>
_t("timeline|m.room.canonical_alias|changed", {
senderName,
});
}
function textForThreePidInviteEvent(event: MatrixEvent): (() => string) | null {
const senderName = getSenderName(event);
if (!isValid3pidInvite(event)) {
return () =>
_t("timeline|m.room.third_party_invite|revoked", {
senderName,
targetDisplayName: event.getPrevContent().display_name || _t("common|someone"),
});
}
return () =>
_t("timeline|m.room.third_party_invite|sent", {
senderName,
targetDisplayName: event.getContent().display_name,
});
}
function textForHistoryVisibilityEvent(event: MatrixEvent): (() => string) | null {
const senderName = getSenderName(event);
switch (event.getContent().history_visibility) {
case HistoryVisibility.Invited:
return () => _t("timeline|m.room.history_visibility|invited", { senderName });
case HistoryVisibility.Joined:
return () =>
_t("timeline|m.room.history_visibility|joined", {
senderName,
});
case HistoryVisibility.Shared:
return () => _t("timeline|m.room.history_visibility|shared", { senderName });
case HistoryVisibility.WorldReadable:
return () => _t("timeline|m.room.history_visibility|world_readable", { senderName });
default:
return () =>
_t("timeline|m.room.history_visibility|unknown", {
senderName,
visibility: event.getContent().history_visibility,
});
}
}
// Currently will only display a change if a user's power level is changed
function textForPowerEvent(event: MatrixEvent, client: MatrixClient): (() => string) | null {
const senderName = getSenderName(event);
if (!event.getPrevContent()?.users || !event.getContent()?.users) {
return null;
}
const previousUserDefault: number = event.getPrevContent().users_default || 0;
const currentUserDefault: number = event.getContent().users_default || 0;
// Construct set of userIds
const users: string[] = [];
Object.keys(event.getContent().users).forEach((userId) => {
if (users.indexOf(userId) === -1) users.push(userId);
});
Object.keys(event.getPrevContent().users).forEach((userId) => {
if (users.indexOf(userId) === -1) users.push(userId);
});
const diffs: {
userId: string;
name: string;
from: number;
to: number;
}[] = [];
users.forEach((userId) => {
// Previous power level
let from: number = event.getPrevContent().users[userId];
if (!Number.isInteger(from)) {
from = previousUserDefault;
}
// Current power level
let to = event.getContent().users[userId];
if (!Number.isInteger(to)) {
to = currentUserDefault;
}
if (from === previousUserDefault && to === currentUserDefault) {
return;
}
if (to !== from) {
const name = getRoomMemberDisplayname(client, event, userId);
diffs.push({ userId, name, from, to });
}
});
if (!diffs.length) {
return null;
}
// XXX: This is also surely broken for i18n
return () =>
_t("timeline|m.room.power_levels|changed", {
senderName,
powerLevelDiffText: diffs
.map((diff) =>
_t("timeline|m.room.power_levels|user_from_to", {
userId: diff.name,
fromPowerLevel: Roles.textualPowerLevel(diff.from, previousUserDefault),
toPowerLevel: Roles.textualPowerLevel(diff.to, currentUserDefault),
}),
)
.join(", "),
});
}
const onPinnedMessagesClick = (): void => {
PosthogTrackers.trackInteraction("PinnedMessageStateEventClick");
RightPanelStore.instance.setCard({ phase: RightPanelPhases.PinnedMessages }, false);
};
function textForPinnedEvent(event: MatrixEvent, client: MatrixClient, allowJSX: boolean): (() => Renderable) | null {
const senderName = getSenderName(event);
const roomId = event.getRoomId()!;
const pinned = event.getContent<{ pinned: string[] }>().pinned ?? [];
const previouslyPinned: string[] = event.getPrevContent().pinned ?? [];
const newlyPinned = pinned.filter((item) => previouslyPinned.indexOf(item) < 0);
const newlyUnpinned = previouslyPinned.filter((item) => pinned.indexOf(item) < 0);
if (newlyPinned.length === 1 && newlyUnpinned.length === 0) {
// A single message was pinned, include a link to that message.
if (allowJSX) {
const messageId = newlyPinned.pop()!;
return () => (
<span>
{_t(
"timeline|m.room.pinned_events|pinned_link",
{ senderName },
{
a: (sub) => (
<AccessibleButton
kind="link_inline"
onClick={() => {
PosthogTrackers.trackInteraction("PinnedMessageStateEventClick");
highlightEvent(roomId, messageId);
}}
>
{sub}
</AccessibleButton>
),
b: (sub) => (
<AccessibleButton kind="link_inline" onClick={onPinnedMessagesClick}>
{sub}
</AccessibleButton>
),
},
)}
</span>
);
}
return () => _t("timeline|m.room.pinned_events|pinned", { senderName });
}
if (newlyUnpinned.length === 1 && newlyPinned.length === 0) {
// A single message was unpinned, include a link to that message.
if (allowJSX) {
const messageId = newlyUnpinned.pop()!;
return () => (
<span>
{_t(
"timeline|m.room.pinned_events|unpinned_link",
{ senderName },
{
a: (sub) => (
<AccessibleButton
kind="link_inline"
onClick={() => {
PosthogTrackers.trackInteraction("PinnedMessageStateEventClick");
highlightEvent(roomId, messageId);
}}
>
{sub}
</AccessibleButton>
),
b: (sub) => (
<AccessibleButton kind="link_inline" onClick={onPinnedMessagesClick}>
{sub}
</AccessibleButton>
),
},
)}
</span>
);
}
return () => _t("timeline|m.room.pinned_events|unpinned", { senderName });
}
if (allowJSX) {
return () => (
<span>
{_t(
"timeline|m.room.pinned_events|changed_link",
{ senderName },
{
a: (sub) => (
<AccessibleButton kind="link_inline" onClick={onPinnedMessagesClick}>
{sub}
</AccessibleButton>
),
},
)}
</span>
);
}
return () => _t("timeline|m.room.pinned_events|changed", { senderName });
}
function textForWidgetEvent(event: MatrixEvent): (() => string) | null {
const senderName = getSenderName(event);
const { name: prevName, type: prevType, url: prevUrl } = event.getPrevContent();
const { name, type, url } = event.getContent() || {};
let widgetName = name || prevName || type || prevType || "";
// Apply sentence case to widget name
if (widgetName && widgetName.length > 0) {
widgetName = widgetName[0].toUpperCase() + widgetName.slice(1);
}
// If the widget was removed, its content should be {}, but this is sufficiently
// equivalent to that condition.
if (url) {
if (prevUrl) {
return () =>
_t("timeline|m.widget|modified", {
widgetName,
senderName,
});
} else {
return () =>
_t("timeline|m.widget|added", {
widgetName,
senderName,
});
}
} else {
return () =>
_t("timeline|m.widget|removed", {
widgetName,
senderName,
});
}
}
function textForWidgetLayoutEvent(event: MatrixEvent): (() => string) | null {
const senderName = getSenderName(event);
return () => _t("timeline|io.element.widgets.layout", { senderName });
}
function textForMjolnirEvent(event: MatrixEvent): (() => string) | null {
const senderName = getSenderName(event);
const { entity: prevEntity } = event.getPrevContent();
const { entity, recommendation, reason } = event.getContent();
// Rule removed
if (!entity) {
if (USER_RULE_TYPES.includes(event.getType())) {
return () => _t("timeline|mjolnir|removed_rule_users", { senderName, glob: prevEntity });
} else if (ROOM_RULE_TYPES.includes(event.getType())) {
return () => _t("timeline|mjolnir|removed_rule_rooms", { senderName, glob: prevEntity });
} else if (SERVER_RULE_TYPES.includes(event.getType())) {
return () =>
_t("timeline|mjolnir|removed_rule_servers", {
senderName,
glob: prevEntity,
});
}
// Unknown type. We'll say something, but we shouldn't end up here.
return () => _t("timeline|mjolnir|removed_rule", { senderName, glob: prevEntity });
}
// Invalid rule
if (!recommendation || !reason) return () => _t("timeline|mjolnir|updated_invalid_rule", { senderName });
// Rule updated
if (entity === prevEntity) {
if (USER_RULE_TYPES.includes(event.getType())) {
return () =>
_t("timeline|mjolnir|updated_rule_users", {
senderName,
glob: entity,
reason,
});
} else if (ROOM_RULE_TYPES.includes(event.getType())) {
return () =>
_t("timeline|mjolnir|updated_rule_rooms", {
senderName,
glob: entity,
reason,
});
} else if (SERVER_RULE_TYPES.includes(event.getType())) {
return () =>
_t("timeline|mjolnir|updated_rule_servers", {
senderName,
glob: entity,
reason,
});
}
// Unknown type. We'll say something but we shouldn't end up here.
return () =>
_t("timeline|mjolnir|updated_rule", {
senderName,
glob: entity,
reason,
});
}
// New rule
if (!prevEntity) {
if (USER_RULE_TYPES.includes(event.getType())) {
return () =>
_t("timeline|mjolnir|created_rule_users", {
senderName,
glob: entity,
reason,
});
} else if (ROOM_RULE_TYPES.includes(event.getType())) {
return () =>
_t("timeline|mjolnir|created_rule_rooms", {
senderName,
glob: entity,
reason,
});
} else if (SERVER_RULE_TYPES.includes(event.getType())) {
return () =>
_t("timeline|mjolnir|created_rule_servers", {
senderName,
glob: entity,
reason,
});
}
// Unknown type. We'll say something but we shouldn't end up here.
return () =>
_t("timeline|mjolnir|created_rule", {
senderName,
glob: entity,
reason,
});
}
// else the entity !== prevEntity - count as a removal & add
if (USER_RULE_TYPES.includes(event.getType())) {
return () =>
_t("timeline|mjolnir|changed_rule_users", { senderName, oldGlob: prevEntity, newGlob: entity, reason });
} else if (ROOM_RULE_TYPES.includes(event.getType())) {
return () =>
_t("timeline|mjolnir|changed_rule_rooms", { senderName, oldGlob: prevEntity, newGlob: entity, reason });
} else if (SERVER_RULE_TYPES.includes(event.getType())) {
return () =>
_t("timeline|mjolnir|changed_rule_servers", { senderName, oldGlob: prevEntity, newGlob: entity, reason });
}
// Unknown type. We'll say something but we shouldn't end up here.
return () =>
_t("timeline|mjolnir|changed_rule_glob", {
senderName,
oldGlob: prevEntity,
newGlob: entity,
reason,
});
}
export function textForLocationEvent(event: MatrixEvent): () => string {
return () =>
_t("timeline|m.location|full", {
senderName: getSenderName(event),
});
}
function textForRedactedPollAndMessageEvent(ev: MatrixEvent, client: MatrixClient): string {
let message = _t("timeline|self_redaction");
const unsigned = ev.getUnsigned();
const redactedBecauseUserId = unsigned?.redacted_because?.sender;
if (redactedBecauseUserId && redactedBecauseUserId !== ev.getSender()) {
const room = client.getRoom(ev.getRoomId());
const sender = room?.getMember(redactedBecauseUserId);
message = _t("timeline|redaction", {
name: sender?.name || redactedBecauseUserId,
});
}
return message;
}
function textForPollStartEvent(event: MatrixEvent, client: MatrixClient): (() => string) | null {
return () => {
let message = "";
if (event.isRedacted()) {
message = textForRedactedPollAndMessageEvent(event, client);
const senderDisplayName = event.sender?.name ?? event.getSender();
message = senderDisplayName + ": " + message;
} else {
message = _t("timeline|m.poll.start", {
senderName: getSenderName(event),
pollQuestion: (event.unstableExtensibleEvent as PollStartEvent)?.question?.text,
});
}
return message;
};
}
function textForPollEndEvent(event: MatrixEvent): (() => string) | null {
return () =>
_t("timeline|m.poll.end|sender_ended", {
senderName: getSenderName(event),
});
}
type Renderable = string | React.ReactNode | null;
interface IHandlers {
[type: string]: (
ev: MatrixEvent,
client: MatrixClient,
allowJSX: boolean,
showHiddenEvents?: boolean,
) => (() => Renderable) | null;
}
const handlers: IHandlers = {
[EventType.RoomMessage]: textForMessageEvent,
[EventType.Sticker]: textForMessageEvent,
[EventType.CallInvite]: textForCallInviteEvent,
[M_POLL_START.name]: textForPollStartEvent,
[M_POLL_END.name]: textForPollEndEvent,
[M_POLL_START.altName]: textForPollStartEvent,
[M_POLL_END.altName]: textForPollEndEvent,
};
const stateHandlers: IHandlers = {
[EventType.RoomCanonicalAlias]: textForCanonicalAliasEvent,
[EventType.RoomName]: textForRoomNameEvent,
[EventType.RoomTopic]: textForTopicEvent,
[EventType.RoomMember]: textForMemberEvent,
[EventType.RoomAvatar]: textForRoomAvatarEvent,
[EventType.RoomThirdPartyInvite]: textForThreePidInviteEvent,
[EventType.RoomHistoryVisibility]: textForHistoryVisibilityEvent,
[EventType.RoomPowerLevels]: textForPowerEvent,
[EventType.RoomPinnedEvents]: textForPinnedEvent,
[EventType.RoomServerAcl]: textForServerACLEvent,
[EventType.RoomTombstone]: textForTombstoneEvent,
[EventType.RoomJoinRules]: textForJoinRulesEvent,
[EventType.RoomGuestAccess]: textForGuestAccessEvent,
// TODO: Enable support for m.widget event type (https://github.com/vector-im/element-web/issues/13111)
"im.vector.modular.widgets": textForWidgetEvent,
[WIDGET_LAYOUT_EVENT_TYPE]: textForWidgetLayoutEvent,
[VoiceBroadcastInfoEventType]: textForVoiceBroadcastStoppedEvent,
};
// Add all the Mjolnir stuff to the renderer
for (const evType of ALL_RULE_TYPES) {
stateHandlers[evType] = textForMjolnirEvent;
}
// Add both stable and unstable m.call events
for (const evType of ElementCall.CALL_EVENT_TYPE.names) {
stateHandlers[evType] = textForCallEvent;
}
/**
* Determines whether the given event has text to display.
*
* @param client The Matrix Client instance for the logged-in user
* @param ev The event
* @param showHiddenEvents An optional cached setting value for showHiddenEventsInTimeline
* to avoid hitting the settings store
*/
export function hasText(ev: MatrixEvent, client: MatrixClient, showHiddenEvents?: boolean): boolean {
const handler = (ev.isState() ? stateHandlers : handlers)[ev.getType()];
return Boolean(handler?.(ev, client, false, showHiddenEvents));
}
/**
* Gets the textual content of the given event.
*
* @param ev The event
* @param client The Matrix Client instance for the logged-in user
* @param allowJSX Whether to output rich JSX content
* @param showHiddenEvents An optional cached setting value for showHiddenEventsInTimeline
* to avoid hitting the settings store
*/
export function textForEvent(ev: MatrixEvent, client: MatrixClient): string;
export function textForEvent(
ev: MatrixEvent,
client: MatrixClient,
allowJSX: true,
showHiddenEvents?: boolean,
): string | React.ReactNode;
export function textForEvent(
ev: MatrixEvent,
client: MatrixClient,
allowJSX = false,
showHiddenEvents?: boolean,
): string | React.ReactNode {
const handler = (ev.isState() ? stateHandlers : handlers)[ev.getType()];
return handler?.(ev, client, allowJSX, showHiddenEvents)?.() || "";
}

47
src/TimezoneHandler.ts Normal file
View File

@@ -0,0 +1,47 @@
/*
Copyright 2024 New Vector Ltd.
Copyright 2024 The Matrix.org Foundation C.I.C.
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only
Please see LICENSE files in the repository root for full details.
*/
import { SettingLevel } from "./settings/SettingLevel";
import SettingsStore from "./settings/SettingsStore";
export const USER_TIMEZONE_KEY = "userTimezone";
/**
* Returning `undefined` ensure that if unset the browser default will be used in `DateTimeFormat`.
* @returns The user specified timezone or `undefined`
*/
export function getUserTimezone(): string | undefined {
const tz = SettingsStore.getValueAt(SettingLevel.DEVICE, USER_TIMEZONE_KEY);
return tz || undefined;
}
/**
* Set in the settings the given timezone
* @timezone
*/
export function setUserTimezone(timezone: string): Promise<void> {
return SettingsStore.setValue(USER_TIMEZONE_KEY, null, SettingLevel.DEVICE, timezone);
}
/**
* Return all the available timezones
*/
export function getAllTimezones(): string[] {
return Intl.supportedValuesOf("timeZone");
}
/**
* Return the current timezone in a short human readable way
*/
export function shortBrowserTimezone(): string {
return (
new Intl.DateTimeFormat(undefined, { timeZoneName: "short" })
.formatToParts(new Date())
.find((x) => x.type === "timeZoneName")?.value ?? "GMT"
);
}

15
src/Typeguards.ts Normal file
View File

@@ -0,0 +1,15 @@
/*
Copyright 2024 New Vector Ltd.
Copyright 2023 The Matrix.org Foundation C.I.C.
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only
Please see LICENSE files in the repository root for full details.
*/
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;
}

152
src/Unread.ts Normal file
View File

@@ -0,0 +1,152 @@
/*
Copyright 2024 New Vector Ltd.
Copyright 2015-2023 The Matrix.org Foundation C.I.C.
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only
Please see LICENSE files in the repository root for full details.
*/
import { M_BEACON, Room, Thread, MatrixEvent, EventType, MatrixClient } from "matrix-js-sdk/src/matrix";
import { logger } from "matrix-js-sdk/src/logger";
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(client: MatrixClient, ev: MatrixEvent): boolean {
if (ev.getSender() === client.getSafeUserId()) {
return false;
}
switch (ev.getType()) {
case EventType.RoomMember:
case EventType.RoomThirdPartyInvite:
case EventType.CallAnswer:
case EventType.CallHangup:
case EventType.RoomCanonicalAlias:
case EventType.RoomServerAcl:
case M_BEACON.name:
case M_BEACON.altName:
return false;
}
if (ev.isRedacted()) return false;
return haveRendererForEvent(ev, client, false /* hidden messages should never trigger unread counts anyways */);
}
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 toCheck: Array<Room | Thread> = [room];
if (includeThreads) {
toCheck.push(...room.getThreads());
}
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 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);
}

225
src/UserActivity.ts Normal file
View File

@@ -0,0 +1,225 @@
/*
Copyright 2019-2024 New Vector Ltd.
Copyright 2015, 2016 OpenMarket Ltd
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only
Please see LICENSE files in the repository root for full details.
*/
import dis from "./dispatcher/dispatcher";
import Timer from "./utils/Timer";
// important these are larger than the timeouts of timers
// used with UserActivity.timeWhileActive*,
// such as READ_MARKER_INVIEW_THRESHOLD_MS (timeWhileActiveRecently),
// READ_MARKER_OUTOFVIEW_THRESHOLD_MS (timeWhileActiveRecently),
// READ_RECEIPT_INTERVAL_MS (timeWhileActiveNow) in TimelinePanel
// 'Under a few seconds'. Must be less than 'RECENTLY_ACTIVE_THRESHOLD_MS'
const CURRENTLY_ACTIVE_THRESHOLD_MS = 700;
// 'Under a few minutes'.
const RECENTLY_ACTIVE_THRESHOLD_MS = 2 * 60 * 1000;
/**
* This class watches for user activity (moving the mouse or pressing a key)
* and starts/stops attached timers while the user is active.
*
* There are two classes of 'active': 'active now' and 'active recently'
* see doc on the userActive* functions for what these mean.
*/
export default class UserActivity {
private readonly activeNowTimeout: Timer;
private readonly activeRecentlyTimeout: Timer;
private attachedActiveNowTimers: Timer[] = [];
private attachedActiveRecentlyTimers: Timer[] = [];
private lastScreenX = 0;
private lastScreenY = 0;
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);
}
public static sharedInstance(): UserActivity {
if (window.mxUserActivity === undefined) {
window.mxUserActivity = new UserActivity(window, document);
}
return window.mxUserActivity;
}
/**
* Runs the given timer while the user is 'active now', aborting when the user is no longer
* considered currently active.
* See userActiveNow() for what it means for a user to be 'active'.
* Can be called multiple times with the same already running timer, which is a NO-OP.
* Can be called before the user becomes active, in which case it is only started
* later on when the user does become active.
* @param {Timer} timer the timer to use
*/
public timeWhileActiveNow(timer: Timer): void {
this.timeWhile(timer, this.attachedActiveNowTimers);
if (this.userActiveNow()) {
timer.start();
}
}
/**
* Runs the given timer while the user is 'active' now or recently,
* aborting when the user becomes inactive.
* See userActiveRecently() for what it means for a user to be 'active recently'.
* Can be called multiple times with the same already running timer, which is a NO-OP.
* Can be called before the user becomes active, in which case it is only started
* later on when the user does become active.
* @param {Timer} timer the timer to use
*/
public timeWhileActiveRecently(timer: Timer): void {
this.timeWhile(timer, this.attachedActiveRecentlyTimers);
if (this.userActiveRecently()) {
timer.start();
}
}
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) => {});
}
}
/**
* Start listening to user activity
*/
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);
// can't use document.scroll here because that's only the document
// 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, {
passive: true,
capture: true,
});
}
/**
* Stop tracking user activity
*/
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);
this.window.removeEventListener("blur", this.onWindowBlurred);
this.window.removeEventListener("focus", this.onUserActivity);
}
/**
* Return true if the user is currently 'active'
* A user is 'active' while they are interacting with the app and for a very short (<1s)
* time after that. This is intended to give a strong indication that the app has the
* user's attention at any given moment.
* @returns {boolean} true if user is currently 'active'
*/
public userActiveNow(): boolean {
return this.activeNowTimeout.isRunning();
}
/**
* Return true if the user is currently active or has been recently
* A user is 'active recently' for a longer period of time (~2 mins) after
* they have been 'active' and while the app still has the focus. This is
* intended to indicate when the app may still have the user's attention
* (or they may have gone to make tea and left the window focused).
* @returns {boolean} true if user has been active recently
*/
public userActiveRecently(): boolean {
return this.activeRecentlyTimeout.isRunning();
}
private onPageVisibilityChanged = (e: Event): void => {
if (this.document.visibilityState === "hidden") {
this.activeNowTimeout.abort();
this.activeRecentlyTimeout.abort();
} else {
this.onUserActivity(e);
}
};
private onWindowBlurred = (): void => {
this.activeNowTimeout.abort();
this.activeRecentlyTimeout.abort();
};
// XXX: exported for tests
public onUserActivity = (event: Event): void => {
// ignore anything if the window isn't focused
if (!this.document.hasFocus()) return;
if (event.type === "mousemove" && this.isMouseEvent(event)) {
if (event.screenX === this.lastScreenX && event.screenY === this.lastScreenY) {
// mouse hasn't actually moved
return;
}
this.lastScreenX = event.screenX;
this.lastScreenY = event.screenY;
}
dis.dispatch({ action: "user_activity" });
if (!this.activeNowTimeout.isRunning()) {
this.activeNowTimeout.start();
dis.dispatch({ action: "user_activity_start" });
UserActivity.runTimersUntilTimeout(this.attachedActiveNowTimers, this.activeNowTimeout);
} else {
this.activeNowTimeout.restart();
}
if (!this.activeRecentlyTimeout.isRunning()) {
this.activeRecentlyTimeout.start();
UserActivity.runTimersUntilTimeout(this.attachedActiveRecentlyTimers, this.activeRecentlyTimeout);
} else {
this.activeRecentlyTimeout.restart();
}
};
private static async runTimersUntilTimeout(attachedTimers: Timer[], timeout: Timer): Promise<void> {
attachedTimers.forEach((t) => t.start());
try {
await timeout.finished();
} catch (_e) {
/* aborted */
}
attachedTimers.forEach((t) => t.abort());
}
private isMouseEvent(event: Event): event is MouseEvent {
return event.type.startsWith("mouse");
}
}

29
src/UserAddress.ts Normal file
View File

@@ -0,0 +1,29 @@
/*
Copyright 2024 New Vector Ltd.
Copyright 2017-2021 The Matrix.org Foundation C.I.C.
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only
Please see LICENSE files in the repository root for full details.
*/
const emailRegex = /^\S+@\S+\.\S+$/;
const mxUserIdRegex = /^@\S+:\S+$/;
const mxRoomIdRegex = /^!\S+:\S+$/;
export enum AddressType {
Email = "email",
MatrixUserId = "mx-user-id",
MatrixRoomId = "mx-room-id",
}
export function getAddressType(inputText: string): AddressType | null {
if (emailRegex.test(inputText)) {
return AddressType.Email;
} else if (mxUserIdRegex.test(inputText)) {
return AddressType.MatrixUserId;
} else if (mxRoomIdRegex.test(inputText)) {
return AddressType.MatrixRoomId;
} else {
return null;
}
}

51
src/Views.ts Normal file
View File

@@ -0,0 +1,51 @@
/*
Copyright 2024 New Vector Ltd.
Copyright 2022 The Matrix.org Foundation C.I.C.
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only
Please see LICENSE files in the repository root for full details.
*/
/** constants for MatrixChat.state.view */
enum Views {
// a special initial state which is only used at startup, while we are
// 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,
// we are showing the login view
LOGIN,
// we are showing the registration view
REGISTER,
// showing the 'forgot password' view
FORGOT_PASSWORD,
// showing flow to trust this new device with cross-signing
COMPLETE_SECURITY,
// flow to setup SSSS / cross-signing on this account
E2E_SETUP,
// screen that allows users to select which use case theyll use matrix for
USE_CASE_SELECTION,
// we are logged in with an active matrix client. The logged_in state also
// includes guests users as they too are logged in at the client level.
LOGGED_IN,
// 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;

150
src/VoipUserMapper.ts Normal file
View File

@@ -0,0 +1,150 @@
/*
Copyright 2024 New Vector Ltd.
Copyright 2021 The Matrix.org Foundation C.I.C.
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only
Please see LICENSE files in the repository root for full details.
*/
import { Room, EventType } from "matrix-js-sdk/src/matrix";
import { KnownMembership } from "matrix-js-sdk/src/types";
import { logger } from "matrix-js-sdk/src/logger";
import { ensureVirtualRoomExists } from "./createRoom";
import { MatrixClientPeg } from "./MatrixClientPeg";
import DMRoomMap from "./utils/DMRoomMap";
import LegacyCallHandler from "./LegacyCallHandler";
import { VIRTUAL_ROOM_EVENT_TYPE } from "./call-types";
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.
export default class VoipUserMapper {
// We store mappings of virtual -> native room IDs here until the local echo for the
// account data arrives.
private virtualToNativeRoomIdCache = new Map<string, string>();
public static sharedInstance(): VoipUserMapper {
if (window.mxVoipUserMapper === undefined) window.mxVoipUserMapper = new VoipUserMapper();
return window.mxVoipUserMapper;
}
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;
}
private async getVirtualUserForRoom(roomId: string): Promise<string | null> {
const userId = DMRoomMap.shared().getUserIdForRoomId(roomId);
if (!userId) return null;
const virtualUser = await this.userToVirtualUser(userId);
if (!virtualUser) return null;
return virtualUser;
}
public async getOrCreateVirtualRoomForRoom(roomId: string): Promise<string | null> {
const virtualUser = await this.getVirtualUserForRoom(roomId);
if (!virtualUser) return null;
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);
return virtualRoomId;
}
/**
* 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 | undefined> {
const virtualUser = await this.getVirtualUserForRoom(roomId);
if (!virtualUser) return undefined;
return findDMForUser(MatrixClientPeg.safeGet(), virtualUser);
}
public nativeRoomForVirtualRoom(roomId: string): string | null {
const cachedNativeRoomId = this.virtualToNativeRoomIdCache.get(roomId);
if (cachedNativeRoomId) {
logger.log(
"Returning native room ID " + cachedNativeRoomId + " for virtual room ID " + roomId + " from cache",
);
return cachedNativeRoomId;
}
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 = cli.getRoom(nativeRoomID);
if (!nativeRoom || nativeRoom.getMyMembership() !== KnownMembership.Join) return null;
return nativeRoomID;
}
public isVirtualRoom(room: Room): boolean {
if (this.nativeRoomForVirtualRoom(room.roomId)) return true;
if (this.virtualToNativeRoomIdCache.has(room.roomId)) return true;
// also look in the create event for the claimed native room ID, which is the only
// way we can recognise a virtual room we've created when it first arrives down
// our stream. We don't trust this in general though, as it could be faked by an
// inviter: our main source of truth is the DM state.
const roomCreateEvent = room.currentState.getStateEvents(EventType.RoomCreate, "");
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.safeGet().getUserId()) return false;
const claimedNativeRoomId = roomCreateEvent.getContent()[VIRTUAL_ROOM_EVENT_TYPE];
return Boolean(claimedNativeRoomId);
}
public async onNewInvitedRoom(invitedRoom: Room): Promise<void> {
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!);
if (result.length === 0) {
return;
}
if (result[0].fields.is_virtual) {
const cli = MatrixClientPeg.safeGet();
const nativeUser = result[0].userid;
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.
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)
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);
}
}
}
}

66
src/WhoIsTyping.ts Normal file
View File

@@ -0,0 +1,66 @@
/*
Copyright 2024 New Vector Ltd.
Copyright 2017 Vector Creations Ltd
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only
Please see LICENSE files in the repository root for full details.
*/
import { Room, RoomMember } from "matrix-js-sdk/src/matrix";
import { _t } from "./languageHandler";
export function usersTypingApartFromMeAndIgnored(room: Room): RoomMember[] {
return usersTyping(room, [room.client.getSafeUserId()].concat(room.client.getIgnoredUsers()));
}
export function usersTypingApartFromMe(room: Room): RoomMember[] {
return usersTyping(room, [room.client.getSafeUserId()]);
}
/**
* Given a Room object and, optionally, a list of userID strings
* to exclude, return a list of user objects who are typing.
* @param {Room} room: room object to get users from.
* @param {string[]} exclude: list of user mxids to exclude.
* @returns {RoomMember[]} list of user objects who are typing.
*/
export function usersTyping(room: Room, exclude: string[] = []): RoomMember[] {
const whoIsTyping: RoomMember[] = [];
const memberKeys = Object.keys(room.currentState.members);
for (const userId of memberKeys) {
if (room.currentState.members[userId].typing) {
if (exclude.indexOf(userId) === -1) {
whoIsTyping.push(room.currentState.members[userId]);
}
}
}
return whoIsTyping;
}
export function whoIsTypingString(whoIsTyping: RoomMember[], limit: number): string {
let othersCount = 0;
if (whoIsTyping.length > limit) {
othersCount = whoIsTyping.length - limit + 1;
}
if (whoIsTyping.length === 0) {
return "";
} else if (whoIsTyping.length === 1) {
return _t("timeline|typing_indicator|one_user", { displayName: whoIsTyping[0].name });
}
const names = whoIsTyping.map((m) => m.name);
if (othersCount >= 1) {
return _t("timeline|typing_indicator|more_users", {
names: names.slice(0, limit - 1).join(", "),
count: othersCount,
});
} else {
const lastPerson = names.pop();
return _t("timeline|typing_indicator|two_users", { names: names.join(", "), lastPerson: lastPerson });
}
}

38
src/WorkerManager.ts Normal file
View File

@@ -0,0 +1,38 @@
/*
Copyright 2024 New Vector Ltd.
Copyright 2022 The Matrix.org Foundation C.I.C.
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only
Please see LICENSE files in the repository root for full details.
*/
import { defer, IDeferred } from "matrix-js-sdk/src/utils";
import { WorkerPayload } from "./workers/worker";
export class WorkerManager<Request extends {}, Response> {
private readonly worker: Worker;
private seq = 0;
private pendingDeferredMap = new Map<number, IDeferred<Response>>();
public constructor(worker: Worker) {
this.worker = worker;
this.worker.onmessage = this.onMessage;
}
private onMessage = (ev: MessageEvent<Response & WorkerPayload>): void => {
const deferred = this.pendingDeferredMap.get(ev.data.seq);
if (deferred) {
this.pendingDeferredMap.delete(ev.data.seq);
deferred.resolve(ev.data);
}
};
public call(request: Request): Promise<Response> {
const seq = this.seq++;
const deferred = defer<Response>();
this.pendingDeferredMap.set(seq, deferred);
this.worker.postMessage({ seq, ...request });
return deferred.promise;
}
}

View File

@@ -0,0 +1,126 @@
/*
Copyright 2024 New Vector Ltd.
Copyright 2022 Šimon Brandner <simon.bra.ag@gmail.com>
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only
Please see LICENSE files in the repository root for full details.
*/
import { KeyCombo } from "../KeyBindingsManager";
import { IS_MAC, Key } from "../Keyboard";
import { _t, _td } from "../languageHandler";
import PlatformPeg from "../PlatformPeg";
import SettingsStore from "../settings/SettingsStore";
import {
DESKTOP_SHORTCUTS,
DIGITS,
IKeyboardShortcuts,
KeyBindingAction,
KEYBOARD_SHORTCUTS,
KeyboardShortcutSetting,
MAC_ONLY_SHORTCUTS,
} from "./KeyboardShortcuts";
/**
* This function gets the keyboard shortcuts that should be presented in the UI
* but they shouldn't be consumed by KeyBindingDefaults. That means that these
* have to be manually mirrored in KeyBindingDefaults.
*/
const getUIOnlyShortcuts = (): IKeyboardShortcuts => {
const ctrlEnterToSend = SettingsStore.getValue("MessageComposerInput.ctrlEnterToSend");
const keyboardShortcuts: IKeyboardShortcuts = {
[KeyBindingAction.SendMessage]: {
default: {
key: Key.ENTER,
ctrlOrCmdKey: ctrlEnterToSend,
},
displayName: _td("composer|send_button_title"),
},
[KeyBindingAction.NewLine]: {
default: {
key: Key.ENTER,
shiftKey: !ctrlEnterToSend,
},
displayName: _td("keyboard|composer_new_line"),
},
[KeyBindingAction.CompleteAutocomplete]: {
default: {
key: Key.ENTER,
},
displayName: _td("action|complete"),
},
[KeyBindingAction.ForceCompleteAutocomplete]: {
default: {
key: Key.TAB,
},
displayName: _td("keyboard|autocomplete_force"),
},
[KeyBindingAction.SearchInRoom]: {
default: {
ctrlOrCmdKey: true,
key: Key.F,
},
displayName: _td("keyboard|search"),
},
};
if (PlatformPeg.get()?.overrideBrowserShortcuts()) {
// XXX: This keyboard shortcut isn't manually added to
// KeyBindingDefaults as it can't be easily handled by the
// KeyBindingManager
keyboardShortcuts[KeyBindingAction.SwitchToSpaceByNumber] = {
default: {
ctrlOrCmdKey: true,
key: DIGITS,
},
displayName: _td("keyboard|switch_to_space"),
};
}
return keyboardShortcuts;
};
/**
* This function gets keyboard shortcuts that can be consumed by the KeyBindingDefaults.
*/
export const getKeyboardShortcuts = (): IKeyboardShortcuts => {
const overrideBrowserShortcuts = PlatformPeg.get()?.overrideBrowserShortcuts();
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 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())] as [
KeyBindingAction,
KeyboardShortcutSetting,
][];
return entries.reduce((acc, [key, value]) => {
acc[key] = value;
return acc;
}, {} as IKeyboardShortcuts);
};
export const getKeyboardShortcutValue = (name: KeyBindingAction): KeyCombo | undefined => {
return getKeyboardShortcutsForUI()[name]?.default;
};
export const getKeyboardShortcutDisplayName = (name: KeyBindingAction): string | undefined => {
const keyboardShortcutDisplayName = getKeyboardShortcutsForUI()[name]?.displayName;
return keyboardShortcutDisplayName && _t(keyboardShortcutDisplayName);
};

View File

@@ -0,0 +1,730 @@
/*
Copyright 2024 New Vector Ltd.
Copyright 2022 The Matrix.org Foundation C.I.C.
Copyright 2021, 2022 Šimon Brandner <simon.bra.ag@gmail.com>
Copyright 2020 The Matrix.org Foundation C.I.C.
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only
Please see LICENSE files in the repository root for full details.
*/
import { _td, TranslationKey } from "../languageHandler";
import { IS_MAC, IS_ELECTRON, Key } from "../Keyboard";
import { IBaseSetting } from "../settings/Settings";
import { KeyCombo } from "../KeyBindingsManager";
export enum KeyBindingAction {
/** Send a message */
SendMessage = "KeyBinding.sendMessageInComposer",
/** Go backwards through the send history and use the message in composer view */
SelectPrevSendHistory = "KeyBinding.previousMessageInComposerHistory",
/** Go forwards through the send history */
SelectNextSendHistory = "KeyBinding.nextMessageInComposerHistory",
/** Start editing the user's last sent message */
EditPrevMessage = "KeyBinding.editPreviousMessage",
/** Start editing the user's next sent message */
EditNextMessage = "KeyBinding.editNextMessage",
/** Cancel editing a message or cancel replying to a message */
CancelReplyOrEdit = "KeyBinding.cancelReplyInComposer",
/** Show the sticker picker */
ShowStickerPicker = "KeyBinding.showStickerPicker",
/** Set bold format the current selection */
FormatBold = "KeyBinding.toggleBoldInComposer",
/** Set italics format the current selection */
FormatItalics = "KeyBinding.toggleItalicsInComposer",
/** Insert link for current selection */
FormatLink = "KeyBinding.FormatLink",
/** Set code format for current selection */
FormatCode = "KeyBinding.FormatCode",
/** Format the current selection as quote */
FormatQuote = "KeyBinding.toggleQuoteInComposer",
/** Undo the last editing */
EditUndo = "KeyBinding.editUndoInComposer",
/** Redo editing */
EditRedo = "KeyBinding.editRedoInComposer",
/** Insert new line */
NewLine = "KeyBinding.newLineInComposer",
/** Move the cursor to the start of the message */
MoveCursorToStart = "KeyBinding.jumpToStartInComposer",
/** Move the cursor to the end of the message */
MoveCursorToEnd = "KeyBinding.jumpToEndInComposer",
/** Accepts chosen autocomplete selection */
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",
/** Move to the previous autocomplete selection */
PrevSelectionInAutocomplete = "KeyBinding.previousOptionInAutoComplete",
/** Move to the next autocomplete selection */
NextSelectionInAutocomplete = "KeyBinding.nextOptionInAutoComplete",
/** Close the autocompletion window */
CancelAutocomplete = "KeyBinding.cancelAutoComplete",
/** Clear room list filter field */
ClearRoomFilter = "KeyBinding.clearRoomFilter",
/** Navigate up/down in the room list */
PrevRoom = "KeyBinding.downerRoom",
/** Navigate down in the room list */
NextRoom = "KeyBinding.upperRoom",
/** Select room from the room list */
SelectRoomInRoomList = "KeyBinding.selectRoomInRoomList",
/** Collapse room list section */
CollapseRoomListSection = "KeyBinding.collapseSectionInRoomList",
/** Expand room list section, if already expanded, jump to first room in the selection */
ExpandRoomListSection = "KeyBinding.expandSectionInRoomList",
/** Scroll up in the timeline */
ScrollUp = "KeyBinding.scrollUpInTimeline",
/** Scroll down in the timeline */
ScrollDown = "KeyBinding.scrollDownInTimeline",
/** Dismiss read marker and jump to bottom */
DismissReadMarker = "KeyBinding.dismissReadMarkerAndJumpToBottom",
/** Jump to oldest unread message */
JumpToOldestUnread = "KeyBinding.jumpToOldestUnreadMessage",
/** Upload a file */
UploadFile = "KeyBinding.uploadFileToRoom",
/** Focus search message in a room (must be enabled) */
SearchInRoom = "KeyBinding.searchInRoom",
/** Jump to the first (downloaded) message in the room */
JumpToFirstMessage = "KeyBinding.jumpToFirstMessageInTimeline",
/** Jump to the latest message in the room */
JumpToLatestMessage = "KeyBinding.jumpToLastMessageInTimeline",
/** Jump to room search (search for a room) */
FilterRooms = "KeyBinding.filterRooms",
/** Toggle the space panel */
ToggleSpacePanel = "KeyBinding.toggleSpacePanel",
/** Toggle the room side panel */
ToggleRoomSidePanel = "KeyBinding.toggleRightPanel",
/** Toggle the user menu */
ToggleUserMenu = "KeyBinding.toggleTopLeftMenu",
/** Toggle the short cut help dialog */
ShowKeyboardSettings = "KeyBinding.showKeyBindingsSettings",
/** Got to the Element home screen */
GoToHome = "KeyBinding.goToHomeView",
/** Select prev room */
SelectPrevRoom = "KeyBinding.previousRoom",
/** Select next room */
SelectNextRoom = "KeyBinding.nextRoom",
/** Select prev room with unread messages */
SelectPrevUnreadRoom = "KeyBinding.previousUnreadRoom",
/** Select next room with unread messages */
SelectNextUnreadRoom = "KeyBinding.nextUnreadRoom",
/** Switches to a space by number */
SwitchToSpaceByNumber = "KeyBinding.switchToSpaceByNumber",
/** Opens user settings */
OpenUserSettings = "KeyBinding.openUserSettings",
/** Navigates backward */
PreviousVisitedRoomOrSpace = "KeyBinding.PreviousVisitedRoomOrSpace",
/** Navigates forward */
NextVisitedRoomOrSpace = "KeyBinding.NextVisitedRoomOrSpace",
/** Navigates to the next Landmark */
NextLandmark = "KeyBinding.nextLandmark",
/** Navigates to the next Landmark */
PreviousLandmark = "KeyBinding.previousLandmark",
/** Toggles microphone while on a call */
ToggleMicInCall = "KeyBinding.toggleMicInCall",
/** Toggles webcam while on a call */
ToggleWebcamInCall = "KeyBinding.toggleWebcamInCall",
/** Accessibility actions */
Escape = "KeyBinding.escape",
Enter = "KeyBinding.enter",
Space = "KeyBinding.space",
Backspace = "KeyBinding.backspace",
Delete = "KeyBinding.delete",
Home = "KeyBinding.home",
End = "KeyBinding.end",
ArrowLeft = "KeyBinding.arrowLeft",
ArrowUp = "KeyBinding.arrowUp",
ArrowRight = "KeyBinding.arrowRight",
ArrowDown = "KeyBinding.arrowDown",
Tab = "KeyBinding.tab",
Comma = "KeyBinding.comma",
/** Toggle visibility of hidden events */
ToggleHiddenEventVisibility = "KeyBinding.toggleHiddenEventVisibility",
}
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?: TranslationKey;
// TODO: We should figure out what to do with the keyboard shortcuts that are not handled by KeybindingManager
settingNames: KeyBindingAction[];
}
export enum CategoryName {
NAVIGATION = "Navigation",
ACCESSIBILITY = "Accessibility",
CALLS = "Calls",
COMPOSER = "Composer",
ROOM_LIST = "Room List",
ROOM = "Room",
AUTOCOMPLETE = "Autocomplete",
LABS = "Labs",
}
// 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, 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]: "↑",
[Key.ARROW_DOWN]: "↓",
[Key.ARROW_LEFT]: "←",
[Key.ARROW_RIGHT]: "→",
};
if (IS_MAC) {
KEY_ICON[Key.META] = "⌘";
KEY_ICON[Key.ALT] = "⌥";
KEY_ICON[Key.SHIFT] = "⇧";
}
export const CATEGORIES: Record<CategoryName, ICategory> = {
[CategoryName.COMPOSER]: {
categoryLabel: _td("settings|preferences|composer_heading"),
settingNames: [
KeyBindingAction.SendMessage,
KeyBindingAction.NewLine,
KeyBindingAction.FormatBold,
KeyBindingAction.FormatItalics,
KeyBindingAction.FormatQuote,
KeyBindingAction.FormatLink,
KeyBindingAction.FormatCode,
KeyBindingAction.EditUndo,
KeyBindingAction.EditRedo,
KeyBindingAction.MoveCursorToStart,
KeyBindingAction.MoveCursorToEnd,
KeyBindingAction.CancelReplyOrEdit,
KeyBindingAction.EditNextMessage,
KeyBindingAction.EditPrevMessage,
KeyBindingAction.SelectNextSendHistory,
KeyBindingAction.SelectPrevSendHistory,
KeyBindingAction.ShowStickerPicker,
],
},
[CategoryName.CALLS]: {
categoryLabel: _td("keyboard|category_calls"),
settingNames: [KeyBindingAction.ToggleMicInCall, KeyBindingAction.ToggleWebcamInCall],
},
[CategoryName.ROOM]: {
categoryLabel: _td("common|room"),
settingNames: [
KeyBindingAction.SearchInRoom,
KeyBindingAction.UploadFile,
KeyBindingAction.DismissReadMarker,
KeyBindingAction.JumpToOldestUnread,
KeyBindingAction.ScrollUp,
KeyBindingAction.ScrollDown,
KeyBindingAction.JumpToFirstMessage,
KeyBindingAction.JumpToLatestMessage,
],
},
[CategoryName.ROOM_LIST]: {
categoryLabel: _td("keyboard|category_room_list"),
settingNames: [
KeyBindingAction.SelectRoomInRoomList,
KeyBindingAction.ClearRoomFilter,
KeyBindingAction.CollapseRoomListSection,
KeyBindingAction.ExpandRoomListSection,
KeyBindingAction.NextRoom,
KeyBindingAction.PrevRoom,
],
},
[CategoryName.ACCESSIBILITY]: {
categoryLabel: _td("common|accessibility"),
settingNames: [
KeyBindingAction.Escape,
KeyBindingAction.Enter,
KeyBindingAction.Space,
KeyBindingAction.Backspace,
KeyBindingAction.Delete,
KeyBindingAction.Home,
KeyBindingAction.End,
KeyBindingAction.ArrowLeft,
KeyBindingAction.ArrowUp,
KeyBindingAction.ArrowRight,
KeyBindingAction.ArrowDown,
KeyBindingAction.Comma,
],
},
[CategoryName.NAVIGATION]: {
categoryLabel: _td("keyboard|category_navigation"),
settingNames: [
KeyBindingAction.ToggleUserMenu,
KeyBindingAction.ToggleRoomSidePanel,
KeyBindingAction.ToggleSpacePanel,
KeyBindingAction.ShowKeyboardSettings,
KeyBindingAction.GoToHome,
KeyBindingAction.FilterRooms,
KeyBindingAction.SelectNextUnreadRoom,
KeyBindingAction.SelectPrevUnreadRoom,
KeyBindingAction.SelectNextRoom,
KeyBindingAction.SelectPrevRoom,
KeyBindingAction.OpenUserSettings,
KeyBindingAction.SwitchToSpaceByNumber,
KeyBindingAction.PreviousVisitedRoomOrSpace,
KeyBindingAction.NextVisitedRoomOrSpace,
KeyBindingAction.NextLandmark,
KeyBindingAction.PreviousLandmark,
],
},
[CategoryName.AUTOCOMPLETE]: {
categoryLabel: _td("keyboard|category_autocomplete"),
settingNames: [
KeyBindingAction.CancelAutocomplete,
KeyBindingAction.NextSelectionInAutocomplete,
KeyBindingAction.PrevSelectionInAutocomplete,
KeyBindingAction.CompleteAutocomplete,
KeyBindingAction.ForceCompleteAutocomplete,
],
},
[CategoryName.LABS]: {
categoryLabel: _td("common|labs"),
settingNames: [KeyBindingAction.ToggleHiddenEventVisibility],
},
};
export const DESKTOP_SHORTCUTS = [
KeyBindingAction.OpenUserSettings,
KeyBindingAction.SwitchToSpaceByNumber,
KeyBindingAction.PreviousVisitedRoomOrSpace,
KeyBindingAction.NextVisitedRoomOrSpace,
];
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
// TODO: TravisR will fix this nightmare when the new version of the SettingsStore becomes a thing
// XXX: Exported for tests
export const KEYBOARD_SHORTCUTS: IKeyboardShortcuts = {
[KeyBindingAction.FormatBold]: {
default: {
ctrlOrCmdKey: true,
key: Key.B,
},
displayName: _td("keyboard|composer_toggle_bold"),
},
[KeyBindingAction.FormatItalics]: {
default: {
ctrlOrCmdKey: true,
key: Key.I,
},
displayName: _td("keyboard|composer_toggle_italics"),
},
[KeyBindingAction.FormatQuote]: {
default: {
ctrlOrCmdKey: true,
shiftKey: true,
key: Key.GREATER_THAN,
},
displayName: _td("keyboard|composer_toggle_quote"),
},
[KeyBindingAction.FormatCode]: {
default: {
ctrlOrCmdKey: true,
key: Key.E,
},
displayName: _td("keyboard|composer_toggle_code_block"),
},
[KeyBindingAction.FormatLink]: {
default: {
ctrlOrCmdKey: true,
shiftKey: true,
key: Key.L,
},
displayName: _td("keyboard|composer_toggle_link"),
},
[KeyBindingAction.CancelReplyOrEdit]: {
default: {
key: Key.ESCAPE,
},
displayName: _td("keyboard|cancel_reply"),
},
[KeyBindingAction.EditNextMessage]: {
default: {
key: Key.ARROW_DOWN,
},
displayName: _td("keyboard|navigate_next_message_edit"),
},
[KeyBindingAction.EditPrevMessage]: {
default: {
key: Key.ARROW_UP,
},
displayName: _td("keyboard|navigate_prev_message_edit"),
},
[KeyBindingAction.MoveCursorToStart]: {
default: {
ctrlOrCmdKey: true,
key: Key.HOME,
},
displayName: _td("keyboard|composer_jump_start"),
},
[KeyBindingAction.MoveCursorToEnd]: {
default: {
ctrlOrCmdKey: true,
key: Key.END,
},
displayName: _td("keyboard|composer_jump_end"),
},
[KeyBindingAction.SelectNextSendHistory]: {
default: {
altKey: true,
ctrlKey: true,
key: Key.ARROW_DOWN,
},
displayName: _td("keyboard|composer_navigate_next_history"),
},
[KeyBindingAction.SelectPrevSendHistory]: {
default: {
altKey: true,
ctrlKey: true,
key: Key.ARROW_UP,
},
displayName: _td("keyboard|composer_navigate_prev_history"),
},
[KeyBindingAction.ShowStickerPicker]: {
default: {
ctrlOrCmdKey: true,
key: Key.SEMICOLON,
},
displayName: _td("keyboard|send_sticker"),
},
[KeyBindingAction.ToggleMicInCall]: {
default: {
ctrlOrCmdKey: true,
key: Key.D,
},
displayName: _td("keyboard|toggle_microphone_mute"),
},
[KeyBindingAction.ToggleWebcamInCall]: {
default: {
ctrlOrCmdKey: true,
key: Key.E,
},
displayName: _td("keyboard|toggle_webcam_mute"),
},
[KeyBindingAction.DismissReadMarker]: {
default: {
key: Key.ESCAPE,
},
displayName: _td("keyboard|dismiss_read_marker_and_jump_bottom"),
},
[KeyBindingAction.JumpToOldestUnread]: {
default: {
shiftKey: true,
key: Key.PAGE_UP,
},
displayName: _td("keyboard|jump_to_read_marker"),
},
[KeyBindingAction.UploadFile]: {
default: {
ctrlOrCmdKey: true,
shiftKey: true,
key: Key.U,
},
displayName: _td("keyboard|upload_file"),
},
[KeyBindingAction.ScrollUp]: {
default: {
key: Key.PAGE_UP,
},
displayName: _td("keyboard|scroll_up_timeline"),
},
[KeyBindingAction.ScrollDown]: {
default: {
key: Key.PAGE_DOWN,
},
displayName: _td("keyboard|scroll_down_timeline"),
},
[KeyBindingAction.FilterRooms]: {
default: {
ctrlOrCmdKey: true,
key: Key.K,
},
displayName: _td("keyboard|jump_room_search"),
},
[KeyBindingAction.SelectRoomInRoomList]: {
default: {
key: Key.ENTER,
},
displayName: _td("keyboard|room_list_select_room"),
},
[KeyBindingAction.CollapseRoomListSection]: {
default: {
key: Key.ARROW_LEFT,
},
displayName: _td("keyboard|room_list_collapse_section"),
},
[KeyBindingAction.ExpandRoomListSection]: {
default: {
key: Key.ARROW_RIGHT,
},
displayName: _td("keyboard|room_list_expand_section"),
},
[KeyBindingAction.NextRoom]: {
default: {
key: Key.ARROW_DOWN,
},
displayName: _td("keyboard|room_list_navigate_down"),
},
[KeyBindingAction.PrevRoom]: {
default: {
key: Key.ARROW_UP,
},
displayName: _td("keyboard|room_list_navigate_up"),
},
[KeyBindingAction.ToggleUserMenu]: {
default: {
ctrlOrCmdKey: true,
key: Key.BACKTICK,
},
displayName: _td("keyboard|toggle_top_left_menu"),
},
[KeyBindingAction.ToggleRoomSidePanel]: {
default: {
ctrlOrCmdKey: true,
key: Key.PERIOD,
},
displayName: _td("keyboard|toggle_right_panel"),
},
[KeyBindingAction.ShowKeyboardSettings]: {
default: {
ctrlOrCmdKey: true,
key: Key.SLASH,
},
displayName: _td("keyboard|keyboard_shortcuts_tab"),
},
[KeyBindingAction.GoToHome]: {
default: {
ctrlOrCmdKey: true,
altKey: !IS_MAC,
shiftKey: IS_MAC,
key: Key.H,
},
displayName: _td("keyboard|go_home_view"),
},
[KeyBindingAction.SelectNextUnreadRoom]: {
default: {
shiftKey: true,
altKey: true,
key: Key.ARROW_DOWN,
},
displayName: _td("keyboard|next_unread_room"),
},
[KeyBindingAction.SelectPrevUnreadRoom]: {
default: {
shiftKey: true,
altKey: true,
key: Key.ARROW_UP,
},
displayName: _td("keyboard|prev_unread_room"),
},
[KeyBindingAction.SelectNextRoom]: {
default: {
altKey: true,
key: Key.ARROW_DOWN,
},
displayName: _td("keyboard|next_room"),
},
[KeyBindingAction.SelectPrevRoom]: {
default: {
altKey: true,
key: Key.ARROW_UP,
},
displayName: _td("keyboard|prev_room"),
},
[KeyBindingAction.CancelAutocomplete]: {
default: {
key: Key.ESCAPE,
},
displayName: _td("keyboard|autocomplete_cancel"),
},
[KeyBindingAction.NextSelectionInAutocomplete]: {
default: {
key: Key.ARROW_DOWN,
},
displayName: _td("keyboard|autocomplete_navigate_next"),
},
[KeyBindingAction.PrevSelectionInAutocomplete]: {
default: {
key: Key.ARROW_UP,
},
displayName: _td("keyboard|autocomplete_navigate_prev"),
},
[KeyBindingAction.ToggleSpacePanel]: {
default: {
ctrlOrCmdKey: true,
shiftKey: true,
key: Key.D,
},
displayName: _td("keyboard|toggle_space_panel"),
},
[KeyBindingAction.ToggleHiddenEventVisibility]: {
default: {
ctrlOrCmdKey: true,
shiftKey: true,
key: Key.H,
},
displayName: _td("keyboard|toggle_hidden_events"),
},
[KeyBindingAction.JumpToFirstMessage]: {
default: {
key: Key.HOME,
ctrlKey: true,
},
displayName: _td("keyboard|jump_first_message"),
},
[KeyBindingAction.JumpToLatestMessage]: {
default: {
key: Key.END,
ctrlKey: true,
},
displayName: _td("keyboard|jump_last_message"),
},
[KeyBindingAction.EditUndo]: {
default: {
key: Key.Z,
ctrlOrCmdKey: true,
},
displayName: _td("keyboard|composer_undo"),
},
[KeyBindingAction.EditRedo]: {
default: {
key: IS_MAC ? Key.Z : Key.Y,
ctrlOrCmdKey: true,
shiftKey: IS_MAC,
},
displayName: _td("keyboard|composer_redo"),
},
[KeyBindingAction.PreviousVisitedRoomOrSpace]: {
default: {
metaKey: IS_MAC,
altKey: !IS_MAC,
key: IS_MAC ? Key.SQUARE_BRACKET_LEFT : Key.ARROW_LEFT,
},
displayName: _td("keyboard|navigate_prev_history"),
},
[KeyBindingAction.NextVisitedRoomOrSpace]: {
default: {
metaKey: IS_MAC,
altKey: !IS_MAC,
key: IS_MAC ? Key.SQUARE_BRACKET_RIGHT : Key.ARROW_RIGHT,
},
displayName: _td("keyboard|navigate_next_history"),
},
[KeyBindingAction.SwitchToSpaceByNumber]: {
default: {
ctrlOrCmdKey: true,
key: DIGITS,
},
displayName: _td("keyboard|switch_to_space"),
},
[KeyBindingAction.OpenUserSettings]: {
default: {
metaKey: true,
key: Key.COMMA,
},
displayName: _td("keyboard|open_user_settings"),
},
[KeyBindingAction.Escape]: {
default: {
key: Key.ESCAPE,
},
displayName: _td("keyboard|close_dialog_menu"),
},
[KeyBindingAction.Enter]: {
default: {
key: Key.ENTER,
},
displayName: _td("keyboard|activate_button"),
},
[KeyBindingAction.Space]: {
default: {
key: Key.SPACE,
},
},
[KeyBindingAction.Backspace]: {
default: {
key: Key.BACKSPACE,
},
},
[KeyBindingAction.Delete]: {
default: {
key: Key.DELETE,
},
},
[KeyBindingAction.Home]: {
default: {
key: Key.HOME,
},
},
[KeyBindingAction.End]: {
default: {
key: Key.END,
},
},
[KeyBindingAction.ArrowLeft]: {
default: {
key: Key.ARROW_LEFT,
},
},
[KeyBindingAction.ArrowUp]: {
default: {
key: Key.ARROW_UP,
},
},
[KeyBindingAction.ArrowRight]: {
default: {
key: Key.ARROW_RIGHT,
},
},
[KeyBindingAction.ArrowDown]: {
default: {
key: Key.ARROW_DOWN,
},
},
[KeyBindingAction.Comma]: {
default: {
key: Key.COMMA,
},
},
[KeyBindingAction.NextLandmark]: {
default: {
ctrlOrCmdKey: !IS_ELECTRON,
key: Key.F6,
},
displayName: _td("keyboard|next_landmark"),
},
[KeyBindingAction.PreviousLandmark]: {
default: {
ctrlOrCmdKey: !IS_ELECTRON,
key: Key.F6,
shiftKey: true,
},
displayName: _td("keyboard|prev_landmark"),
},
};

View File

@@ -0,0 +1,97 @@
/*
* Copyright 2024 New Vector Ltd.
* Copyright 2024 The Matrix.org Foundation C.I.C.
*
* SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only
* Please see LICENSE files in the repository root for full details.
*/
import { TimelineRenderingType } from "../contexts/RoomContext";
import { Action } from "../dispatcher/actions";
import defaultDispatcher from "../dispatcher/dispatcher";
export const enum Landmark {
// This is the space/home button in the left panel.
ACTIVE_SPACE_BUTTON,
// This is the room filter in the left panel.
ROOM_SEARCH,
// This is the currently opened room/first room in the room list in the left panel.
ROOM_LIST,
// This is the message composer within the room if available or it is the welcome screen shown when no room is selected
MESSAGE_COMPOSER_OR_HOME,
}
const ORDERED_LANDMARKS = [
Landmark.ACTIVE_SPACE_BUTTON,
Landmark.ROOM_SEARCH,
Landmark.ROOM_LIST,
Landmark.MESSAGE_COMPOSER_OR_HOME,
];
/**
* The landmarks are cycled through in the following order:
* ACTIVE_SPACE_BUTTON <-> ROOM_SEARCH <-> ROOM_LIST <-> MESSAGE_COMPOSER/HOME <-> ACTIVE_SPACE_BUTTON
*/
export class LandmarkNavigation {
/**
* Get the next/previous landmark that must be focused from a given landmark
* @param currentLandmark The current landmark
* @param backwards If true, the landmark before currentLandmark in ORDERED_LANDMARKS is returned
* @returns The next landmark to focus
*/
private static getLandmark(currentLandmark: Landmark, backwards = false): Landmark {
const currentIndex = ORDERED_LANDMARKS.findIndex((l) => l === currentLandmark);
const offset = backwards ? -1 : 1;
const newLandmark = ORDERED_LANDMARKS.at((currentIndex + offset) % ORDERED_LANDMARKS.length)!;
return newLandmark;
}
/**
* Focus the next landmark from a given landmark.
* This method will skip over any missing landmarks.
* @param currentLandmark The current landmark
* @param backwards If true, search the next landmark to the left in ORDERED_LANDMARKS
*/
public static findAndFocusNextLandmark(currentLandmark: Landmark, backwards = false): void {
let landmark = currentLandmark;
let element: HTMLElement | null | undefined = null;
while (element === null) {
landmark = LandmarkNavigation.getLandmark(landmark, backwards);
element = landmarkToDomElementMap[landmark]();
}
element?.focus({ focusVisible: true });
}
}
/**
* The functions return:
* - The DOM element of the landmark if it exists
* - undefined if the DOM element exists but focus is given through an action
* - null if the landmark does not exist
*/
const landmarkToDomElementMap: Record<Landmark, () => HTMLElement | null | undefined> = {
[Landmark.ACTIVE_SPACE_BUTTON]: () => document.querySelector<HTMLElement>(".mx_SpaceButton_active"),
[Landmark.ROOM_SEARCH]: () => document.querySelector<HTMLElement>(".mx_RoomSearch"),
[Landmark.ROOM_LIST]: () =>
document.querySelector<HTMLElement>(".mx_RoomTile_selected") ||
document.querySelector<HTMLElement>(".mx_RoomTile"),
[Landmark.MESSAGE_COMPOSER_OR_HOME]: () => {
const isComposerOpen = !!document.querySelector(".mx_MessageComposer");
if (isComposerOpen) {
const inThread = !!document.activeElement?.closest(".mx_ThreadView");
defaultDispatcher.dispatch(
{
action: Action.FocusSendMessageComposer,
context: inThread ? TimelineRenderingType.Thread : TimelineRenderingType.Room,
},
true,
);
// Special case where the element does exist but we focus it through an action.
return undefined;
} else {
return document.querySelector<HTMLElement>(".mx_HomePage");
}
},
};

View File

@@ -0,0 +1,384 @@
/*
Copyright 2024 New Vector Ltd.
Copyright 2020 The Matrix.org Foundation C.I.C.
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only
Please see LICENSE files in the repository root for full details.
*/
import React, {
createContext,
useCallback,
useContext,
useEffect,
useMemo,
useRef,
useReducer,
Reducer,
Dispatch,
RefObject,
ReactNode,
} from "react";
import { getKeyBindingsManager } from "../KeyBindingsManager";
import { KeyBindingAction } from "./KeyboardShortcuts";
import { FocusHandler, Ref } from "./roving/types";
/**
* Module to simplify implementing the Roving TabIndex accessibility technique
*
* Wrap the Widget in an RovingTabIndexContextProvider
* and then for all buttons make use of useRovingTabIndex or RovingTabIndexWrapper.
* The code will keep track of which tabIndex was most recently focused and expose that information as `isActive` which
* can then be used to only set the tabIndex to 0 as expected by the roving tabindex technique.
* When the active button gets unmounted the closest button will be chosen as expected.
* Initially the first button to mount will be given active state.
*
* https://developer.mozilla.org/en-US/docs/Web/Accessibility/Keyboard-navigable_JavaScript_widgets#Technique_1_Roving_tabindex
*/
// Check for form elements which utilize the arrow keys for native functions
// like many of the text input varieties.
//
// i.e. it's ok to press the down arrow on a radio button to move to the next
// radio. But it's not ok to press the down arrow on a <input type="text"> to
// move away because the down arrow should move the cursor to the end of the
// input.
export function checkInputableElement(el: HTMLElement): boolean {
return el.matches('input:not([type="radio"]):not([type="checkbox"]), textarea, select, [contenteditable=true]');
}
export interface IState {
activeRef?: Ref;
refs: Ref[];
}
export interface IContext {
state: IState;
dispatch: Dispatch<IAction>;
}
export const RovingTabIndexContext = createContext<IContext>({
state: {
refs: [], // list of refs in DOM order
},
dispatch: () => {},
});
RovingTabIndexContext.displayName = "RovingTabIndexContext";
export enum Type {
Register = "REGISTER",
Unregister = "UNREGISTER",
SetFocus = "SET_FOCUS",
Update = "UPDATE",
}
export interface IAction {
type: Exclude<Type, Type.Update>;
payload: {
ref: Ref;
};
}
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) {
// Our list of refs was empty, set activeRef to this first item
state.activeRef = action.payload.ref;
}
// 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(refSorter);
return { ...state };
}
case Type.Unregister: {
const oldIndex = state.refs.findIndex((r) => r === action.payload.ref);
if (oldIndex === -1) {
return state; // already removed, this should not happen
}
if (state.refs.splice(oldIndex, 1)[0] === state.activeRef) {
// we just removed the active ref, need to replace it
// pick the ref closest to the index the old ref was in
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);
}
if (document.activeElement === document.body) {
// if the focus got reverted to the body then the user was likely focused on the unmounted element
setTimeout(() => state.activeRef?.current?.focus(), 0);
}
}
// update the refs list
return { ...state };
}
case Type.SetFocus: {
// if the ref doesn't change just return the same object reference to skip a re-render
if (state.activeRef === action.payload.ref) return state;
// update active ref
state.activeRef = action.payload.ref;
return { ...state };
}
case Type.Update: {
state.refs.sort(refSorter);
return { ...state };
}
default:
return state;
}
};
interface IProps {
handleLoop?: boolean;
handleHomeEnd?: boolean;
handleUpDown?: boolean;
handleLeftRight?: boolean;
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,
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);
}
}
};
export const RovingTabIndexProvider: React.FC<IProps> = ({
children,
handleHomeEnd,
handleUpDown,
handleLeftRight,
handleLoop,
handleInputFields,
scrollIntoView,
onKeyDown,
}) => {
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, context.dispatch);
if (ev.defaultPrevented) {
return;
}
}
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 + (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)
) {
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 (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,
],
);
const onDragEndHandler = useCallback(() => {
dispatch({
type: Type.Update,
});
}, []);
return (
<RovingTabIndexContext.Provider value={context}>
{children({ onKeyDownHandler, onDragEndHandler })}
</RovingTabIndexContext.Provider>
);
};
// Hook to register a roving tab index
// inputRef parameter specifies the ref to use
// onFocus should be called when the index gained focus in any manner
// isActive should be used to set tabIndex in a manner such as `tabIndex={isActive ? 0 : -1}`
// ref should be passed to a DOM node which will be used for DOM compareDocumentPosition
export const useRovingTabIndex = <T extends HTMLElement>(
inputRef?: RefObject<T>,
): [FocusHandler, boolean, RefObject<T>] => {
const context = useContext(RovingTabIndexContext);
let ref = useRef<T>(null);
if (inputRef) {
// if we are given a ref, use it instead of ours
ref = inputRef;
}
// setup (after refs)
useEffect(() => {
context.dispatch({
type: Type.Register,
payload: { ref },
});
// teardown
return () => {
context.dispatch({
type: Type.Unregister,
payload: { ref },
});
};
}, []); // eslint-disable-line react-hooks/exhaustive-deps
const onFocus = useCallback(() => {
context.dispatch({
type: Type.SetFocus,
payload: { ref },
});
}, []); // eslint-disable-line react-hooks/exhaustive-deps
const isActive = context.state.activeRef === ref;
return [onFocus, isActive, ref];
};
// re-export the semantic helper components for simplicity
export { RovingTabIndexWrapper } from "./roving/RovingTabIndexWrapper";
export { RovingAccessibleButton } from "./roving/RovingAccessibleButton";

View File

@@ -0,0 +1,60 @@
/*
Copyright 2024 New Vector Ltd.
Copyright 2020 The Matrix.org Foundation C.I.C.
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only
Please see LICENSE files in the repository root for full details.
*/
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"> {}
// 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 = 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;
let handled = true;
// HOME and END are handled by RovingTabIndexProvider
const action = getKeyBindingsManager().getAccessibilityAction(ev);
switch (action) {
case KeyBindingAction.ArrowUp:
case KeyBindingAction.ArrowDown:
if (target.hasAttribute("aria-haspopup")) {
target.click();
}
break;
default:
handled = false;
}
if (handled) {
ev.preventDefault();
ev.stopPropagation();
}
};
// We handle both up/down and left/right as is allowed in the above WAI ARIA best practices
return (
<RovingTabIndexProvider handleHomeEnd handleLeftRight handleUpDown onKeyDown={onKeyDown}>
{({ onKeyDownHandler }) => (
<div {...props} onKeyDown={onKeyDownHandler} role="toolbar" ref={ref}>
{children}
</div>
)}
</RovingTabIndexProvider>
);
});
export default Toolbar;

View File

@@ -0,0 +1,40 @@
/*
Copyright 2024 New Vector Ltd.
Copyright 2019 The Matrix.org Foundation C.I.C.
Copyright 2018 New Vector Ltd
Copyright 2015, 2016 OpenMarket Ltd
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only
Please see LICENSE files in the repository root for full details.
*/
import React, { ComponentProps, forwardRef, Ref } from "react";
import AccessibleButton from "../../components/views/elements/AccessibleButton";
type Props<T extends keyof JSX.IntrinsicElements> = ComponentProps<typeof AccessibleButton<T>> & {
label?: string;
// whether the context menu is currently open
isExpanded: boolean;
};
// Semantic component for representing the AccessibleButton which launches a <ContextMenu />
export const ContextMenuButton = forwardRef(function <T extends keyof JSX.IntrinsicElements>(
{ label, isExpanded, children, onClick, onContextMenu, element, ...props }: Props<T>,
ref: Ref<HTMLElement>,
) {
return (
<AccessibleButton
{...props}
element={element as keyof JSX.IntrinsicElements}
onClick={onClick}
onContextMenu={onContextMenu ?? onClick ?? undefined}
aria-label={label}
aria-haspopup={true}
aria-expanded={isExpanded}
ref={ref}
>
{children}
</AccessibleButton>
);
});

View File

@@ -0,0 +1,39 @@
/*
Copyright 2024 New Vector Ltd.
Copyright 2019 The Matrix.org Foundation C.I.C.
Copyright 2018 New Vector Ltd
Copyright 2015, 2016 OpenMarket Ltd
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only
Please see LICENSE files in the repository root for full details.
*/
import React, { ComponentProps, forwardRef, Ref } from "react";
import AccessibleButton from "../../components/views/elements/AccessibleButton";
type Props<T extends keyof JSX.IntrinsicElements> = ComponentProps<typeof AccessibleButton<T>> & {
// whether the context menu is currently open
isExpanded: boolean;
};
// Semantic component for representing the AccessibleButton which launches a <ContextMenu />
export const ContextMenuTooltipButton = forwardRef(function <T extends keyof JSX.IntrinsicElements>(
{ isExpanded, children, onClick, onContextMenu, element, ...props }: Props<T>,
ref: Ref<HTMLElement>,
) {
return (
<AccessibleButton
{...props}
element={element as keyof JSX.IntrinsicElements}
onClick={onClick}
onContextMenu={onContextMenu ?? onClick ?? undefined}
aria-haspopup={true}
aria-expanded={isExpanded}
disableTooltip={isExpanded}
ref={ref}
>
{children}
</AccessibleButton>
);
});

View File

@@ -0,0 +1,28 @@
/*
Copyright 2024 New Vector Ltd.
Copyright 2019 The Matrix.org Foundation C.I.C.
Copyright 2018 New Vector Ltd
Copyright 2015, 2016 OpenMarket Ltd
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only
Please see LICENSE files in the repository root for full details.
*/
import React from "react";
import { RovingAccessibleButton } from "../RovingTabIndex";
interface IProps extends React.ComponentProps<typeof RovingAccessibleButton> {
label?: string;
}
// Semantic component for representing a role=menuitem
export const MenuItem: React.FC<IProps> = ({ children, label, ...props }) => {
const ariaLabel = props["aria-label"] || label;
return (
<RovingAccessibleButton {...props} role="menuitem" aria-label={ariaLabel}>
{children}
</RovingAccessibleButton>
);
};

View File

@@ -0,0 +1,34 @@
/*
Copyright 2024 New Vector Ltd.
Copyright 2019 The Matrix.org Foundation C.I.C.
Copyright 2018 New Vector Ltd
Copyright 2015, 2016 OpenMarket Ltd
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only
Please see LICENSE files in the repository root for full details.
*/
import React from "react";
import { RovingAccessibleButton } from "../RovingTabIndex";
interface IProps extends React.ComponentProps<typeof RovingAccessibleButton> {
label?: string;
active: boolean;
}
// Semantic component for representing a role=menuitemcheckbox
export const MenuItemCheckbox: React.FC<IProps> = ({ children, label, active, disabled, ...props }) => {
return (
<RovingAccessibleButton
{...props}
role="menuitemcheckbox"
aria-checked={active}
aria-disabled={disabled}
disabled={disabled}
aria-label={label}
>
{children}
</RovingAccessibleButton>
);
};

View File

@@ -0,0 +1,34 @@
/*
Copyright 2024 New Vector Ltd.
Copyright 2019 The Matrix.org Foundation C.I.C.
Copyright 2018 New Vector Ltd
Copyright 2015, 2016 OpenMarket Ltd
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only
Please see LICENSE files in the repository root for full details.
*/
import React from "react";
import { RovingAccessibleButton } from "../RovingTabIndex";
interface IProps extends React.ComponentProps<typeof RovingAccessibleButton> {
label?: string;
active: boolean;
}
// Semantic component for representing a role=menuitemradio
export const MenuItemRadio: React.FC<IProps> = ({ children, label, active, disabled, ...props }) => {
return (
<RovingAccessibleButton
{...props}
role="menuitemradio"
aria-checked={active}
aria-disabled={disabled}
disabled={disabled}
aria-label={label}
>
{children}
</RovingAccessibleButton>
);
};

View File

@@ -0,0 +1,77 @@
/*
Copyright 2024 New Vector Ltd.
Copyright 2019 The Matrix.org Foundation C.I.C.
Copyright 2018 New Vector Ltd
Copyright 2015, 2016 OpenMarket Ltd
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only
Please see LICENSE files in the repository root for full details.
*/
import React from "react";
import { useRovingTabIndex } from "../RovingTabIndex";
import StyledCheckbox from "../../components/views/elements/StyledCheckbox";
import { KeyBindingAction } from "../KeyboardShortcuts";
import { getKeyBindingsManager } from "../../KeyBindingsManager";
interface IProps extends React.ComponentProps<typeof StyledCheckbox> {
label?: string;
onChange(): void; // we handle keyup/down ourselves so lose the ChangeEvent
onClose(): void; // gets called after onChange on KeyBindingAction.ActivateSelectedButton
}
// Semantic component for representing a styled role=menuitemcheckbox
export const StyledMenuItemCheckbox: React.FC<IProps> = ({ children, label, onChange, onClose, ...props }) => {
const [onFocus, isActive, ref] = useRovingTabIndex<HTMLInputElement>();
const onKeyDown = (e: React.KeyboardEvent): void => {
let handled = true;
const action = getKeyBindingsManager().getAccessibilityAction(e);
switch (action) {
case KeyBindingAction.Space:
onChange();
break;
case KeyBindingAction.Enter:
onChange();
// Implements https://www.w3.org/TR/wai-aria-practices/#keyboard-interaction-12
onClose();
break;
default:
handled = false;
}
if (handled) {
e.stopPropagation();
e.preventDefault();
}
};
const onKeyUp = (e: React.KeyboardEvent): void => {
const action = getKeyBindingsManager().getAccessibilityAction(e);
switch (action) {
case KeyBindingAction.Space:
case KeyBindingAction.Enter:
// prevent the input default handler as we handle it on keydown to match
// https://www.w3.org/TR/wai-aria-practices/examples/menubar/menubar-2/menubar-2.html
e.stopPropagation();
e.preventDefault();
break;
}
};
return (
<StyledCheckbox
{...props}
role="menuitemcheckbox"
aria-label={label}
onChange={onChange}
onKeyDown={onKeyDown}
onKeyUp={onKeyUp}
onFocus={onFocus}
inputRef={ref}
tabIndex={isActive ? 0 : -1}
>
{children}
</StyledCheckbox>
);
};

View File

@@ -0,0 +1,77 @@
/*
Copyright 2024 New Vector Ltd.
Copyright 2019 The Matrix.org Foundation C.I.C.
Copyright 2018 New Vector Ltd
Copyright 2015, 2016 OpenMarket Ltd
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only
Please see LICENSE files in the repository root for full details.
*/
import React from "react";
import { useRovingTabIndex } from "../RovingTabIndex";
import StyledRadioButton from "../../components/views/elements/StyledRadioButton";
import { KeyBindingAction } from "../KeyboardShortcuts";
import { getKeyBindingsManager } from "../../KeyBindingsManager";
interface IProps extends React.ComponentProps<typeof StyledRadioButton> {
label?: string;
onChange(): void; // we handle keyup/down ourselves so lose the ChangeEvent
onClose(): void; // gets called after onChange on KeyBindingAction.Enter
}
// Semantic component for representing a styled role=menuitemradio
export const StyledMenuItemRadio: React.FC<IProps> = ({ children, label, onChange, onClose, ...props }) => {
const [onFocus, isActive, ref] = useRovingTabIndex<HTMLInputElement>();
const onKeyDown = (e: React.KeyboardEvent): void => {
let handled = true;
const action = getKeyBindingsManager().getAccessibilityAction(e);
switch (action) {
case KeyBindingAction.Space:
onChange();
break;
case KeyBindingAction.Enter:
onChange();
// Implements https://www.w3.org/TR/wai-aria-practices/#keyboard-interaction-12
onClose();
break;
default:
handled = false;
}
if (handled) {
e.stopPropagation();
e.preventDefault();
}
};
const onKeyUp = (e: React.KeyboardEvent): void => {
const action = getKeyBindingsManager().getAccessibilityAction(e);
switch (action) {
case KeyBindingAction.Enter:
case KeyBindingAction.Space:
// prevent the input default handler as we handle it on keydown to match
// https://www.w3.org/TR/wai-aria-practices/examples/menubar/menubar-2/menubar-2.html
e.stopPropagation();
e.preventDefault();
break;
}
};
return (
<StyledRadioButton
{...props}
role="menuitemradio"
aria-label={label}
onChange={onChange}
onKeyDown={onKeyDown}
onKeyUp={onKeyUp}
onFocus={onFocus}
inputRef={ref}
tabIndex={isActive ? 0 : -1}
>
{children}
</StyledRadioButton>
);
};

View File

@@ -0,0 +1,49 @@
/*
Copyright 2024 New Vector Ltd.
Copyright 2020 The Matrix.org Foundation C.I.C.
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only
Please see LICENSE files in the repository root for full details.
*/
import React, { ComponentProps } from "react";
import AccessibleButton from "../../components/views/elements/AccessibleButton";
import { useRovingTabIndex } from "../RovingTabIndex";
import { Ref } from "./types";
type Props<T extends keyof JSX.IntrinsicElements> = Omit<
ComponentProps<typeof AccessibleButton<T>>,
"inputRef" | "tabIndex"
> & {
inputRef?: Ref;
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,
element,
...props
}: Props<T>): JSX.Element => {
const [onFocusInternal, isActive, ref] = useRovingTabIndex(inputRef);
return (
<AccessibleButton
{...props}
element={element as keyof JSX.IntrinsicElements}
onFocus={(event: React.FocusEvent) => {
onFocusInternal();
onFocus?.(event);
}}
onMouseOver={(event: React.MouseEvent) => {
if (focusOnMouseOver) onFocusInternal();
onMouseOver?.(event);
}}
ref={ref}
tabIndex={isActive ? 0 : -1}
/>
);
};

View File

@@ -0,0 +1,23 @@
/*
Copyright 2024 New Vector Ltd.
Copyright 2020 The Matrix.org Foundation C.I.C.
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only
Please see LICENSE files in the repository root for full details.
*/
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 }): ReactElement<any, any>;
}
// Wrapper to allow use of useRovingTabIndex outside of React Functional Components.
export const RovingTabIndexWrapper: React.FC<IProps> = ({ children, inputRef }) => {
const [onFocus, isActive, ref] = useRovingTabIndex(inputRef);
return children({ onFocus, isActive, ref });
};

View File

@@ -0,0 +1,13 @@
/*
Copyright 2024 New Vector Ltd.
Copyright 2020 The Matrix.org Foundation C.I.C.
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only
Please see LICENSE files in the repository root for full details.
*/
import { RefObject } from "react";
export type Ref = RefObject<HTMLElement>;
export type FocusHandler = () => void;

View File

@@ -0,0 +1,371 @@
/*
Copyright 2024 New Vector Ltd.
Copyright 2017-2021 The Matrix.org Foundation C.I.C.
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only
Please see LICENSE files in the repository root for full details.
*/
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";
/**
* Create a MatrixActions.sync action that represents a MatrixClient `sync` event,
* each parameter mapping to a key-value in the action.
*
* @param {MatrixClient} matrixClient the matrix client
* @param {string} state the current sync state.
* @param {string} prevState the previous sync state.
* @returns {Object} an action of type MatrixActions.sync.
*/
function createSyncAction(matrixClient: MatrixClient, state: string, prevState: string): ActionPayload {
return {
action: "MatrixActions.sync",
state,
prevState,
matrixClient,
};
}
/**
* @typedef AccountDataAction
* @type {Object}
* @property {string} action 'MatrixActions.accountData'.
* @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
*/
/**
* Create a MatrixActions.accountData action that represents a MatrixClient `accountData`
* matrix event.
*
* @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,
previousAccountDataEvent?: MatrixEvent,
): ActionPayload {
return {
action: "MatrixActions.accountData",
event: accountDataEvent,
event_type: accountDataEvent.getType(),
event_content: accountDataEvent.getContent(),
previousEvent: previousAccountDataEvent,
};
}
/**
* @typedef RoomAccountDataAction
* @type {Object}
* @property {string} action 'MatrixActions.Room.accountData'.
* @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 {Room} room the room where the account data was changed.
*/
/**
* Create a MatrixActions.Room.accountData action that represents a MatrixClient `Room.accountData`
* matrix event.
*
* @param {MatrixClient} matrixClient the matrix client.
* @param {MatrixEvent} accountDataEvent the account data event.
* @param {Room} room the room where account data was changed
* @returns {RoomAccountDataAction} an action of type MatrixActions.Room.accountData.
*/
function createRoomAccountDataAction(
matrixClient: MatrixClient,
accountDataEvent: MatrixEvent,
room: Room,
): ActionPayload {
return {
action: "MatrixActions.Room.accountData",
event: accountDataEvent,
event_type: accountDataEvent.getType(),
event_content: accountDataEvent.getContent(),
room: room,
};
}
/**
* @typedef RoomAction
* @type {Object}
* @property {string} action 'MatrixActions.Room'.
* @property {Room} room the Room that was stored.
*/
/**
* Create a MatrixActions.Room action that represents a MatrixClient `Room`
* matrix event, emitted when a Room is stored in the client.
*
* @param {MatrixClient} matrixClient the matrix client.
* @param {Room} room the Room that was stored.
* @returns {RoomAction} an action of type `MatrixActions.Room`.
*/
function createRoomAction(matrixClient: MatrixClient, room: Room): ActionPayload {
return { action: "MatrixActions.Room", room };
}
/**
* @typedef RoomTagsAction
* @type {Object}
* @property {string} action 'MatrixActions.Room.tags'.
* @property {Room} room the Room whose tags changed.
*/
/**
* Create a MatrixActions.Room.tags action that represents a MatrixClient
* `Room.tags` matrix event, emitted when the m.tag room account data
* event is updated.
*
* @param {MatrixClient} matrixClient the matrix client.
* @param {MatrixEvent} roomTagsEvent the m.tag event.
* @param {Room} room the Room whose tags were changed.
* @returns {RoomTagsAction} an action of type `MatrixActions.Room.tags`.
*/
function createRoomTagsAction(matrixClient: MatrixClient, roomTagsEvent: MatrixEvent, room: Room): ActionPayload {
return { action: "MatrixActions.Room.tags", room };
}
/**
* Create a MatrixActions.Room.receipt action that represents a MatrixClient
* `Room.receipt` event, each parameter mapping to a key-value in the action.
*
* @param {MatrixClient} matrixClient the matrix client
* @param {MatrixEvent} event the receipt event.
* @param {Room} room the room the receipt happened in.
* @returns {Object} an action of type MatrixActions.Room.receipt.
*/
function createRoomReceiptAction(matrixClient: MatrixClient, event: MatrixEvent, room: Room): ActionPayload {
return {
action: "MatrixActions.Room.receipt",
event,
room,
matrixClient,
};
}
/**
* @typedef IRoomTimelineActionPayload
* @type {Object}
* @property {string} action 'MatrixActions.Room.timeline'.
* @property {boolean} isLiveEvent whether the event was attached to a
* live timeline.
* @property {boolean} isLiveUnfilteredRoomTimelineEvent whether the
* event was attached to a timeline in the set of unfiltered timelines.
* @property {Room} room the Room whose tags changed.
*/
export interface IRoomTimelineActionPayload extends Pick<ActionPayload, "action"> {
action: "MatrixActions.Room.timeline";
event: MatrixEvent;
room: Room | null;
isLiveEvent?: boolean;
isLiveUnfilteredRoomTimelineEvent: boolean;
}
/**
* @typedef IRoomStateEventsActionPayload
* @type {Object}
* @property {string} action 'MatrixActions.RoomState.events'.
* @property {MatrixEvent} event the state event received
* @property {RoomState} state the room state into which the event was applied
* @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";
event: MatrixEvent;
state: RoomState;
lastStateEvent: MatrixEvent | null;
}
/**
* Create a MatrixActions.Room.timeline action that represents a
* MatrixClient `Room.timeline` matrix event, emitted when an event
* is added to or removed from a timeline of a room.
*
* @param {MatrixClient} matrixClient the matrix client.
* @param {MatrixEvent} timelineEvent the event that was added/removed.
* @param {?Room} room the Room that was stored.
* @param {boolean} toStartOfTimeline whether the event is being added
* to the start (and not the end) of the timeline.
* @param {boolean} removed whether the event was removed from the
* timeline.
* @param {Object} data
* @param {boolean} data.liveEvent whether the event is a live event,
* belonging to a live timeline.
* @param {EventTimeline} data.timeline the timeline being altered.
* @returns {IRoomTimelineActionPayload} an action of type `MatrixActions.Room.timeline`.
*/
function createRoomTimelineAction(
matrixClient: MatrixClient,
timelineEvent: MatrixEvent,
room: Room | null,
toStartOfTimeline: boolean,
removed: boolean,
data: IRoomTimelineData,
): IRoomTimelineActionPayload {
return {
action: "MatrixActions.Room.timeline",
event: timelineEvent,
isLiveEvent: data.liveEvent,
isLiveUnfilteredRoomTimelineEvent: data.timeline.getTimelineSet() === room?.getUnfilteredTimelineSet(),
room,
};
}
/**
* Create a MatrixActions.Room.timeline action that represents a
* MatrixClient `Room.timeline` matrix event, emitted when an event
* is added to or removed from a timeline of a room.
*
* @param {MatrixClient} matrixClient the matrix client.
* @param {MatrixEvent} event the state event received
* @param {RoomState} state the room state into which the event was applied
* @param {MatrixEvent | null} lastStateEvent the previous value for this (event-type, state-key) tuple in room state
* @returns {IRoomStateEventsActionPayload} an action of type `MatrixActions.RoomState.events`.
*/
function createRoomStateEventsAction(
matrixClient: MatrixClient,
event: MatrixEvent,
state: RoomState,
lastStateEvent: MatrixEvent | null,
): IRoomStateEventsActionPayload {
return {
action: "MatrixActions.RoomState.events",
event,
state,
lastStateEvent,
};
}
/**
* @typedef RoomMembershipAction
* @type {Object}
* @property {string} action 'MatrixActions.Room.myMembership'.
* @property {Room} room to room for which the self-membership changed.
* @property {string} membership the new membership
* @property {string} oldMembership the previous membership, can be null.
*/
/**
* Create a MatrixActions.Room.myMembership action that represents
* a MatrixClient `Room.myMembership` event for the syncing user,
* emitted when the syncing user's membership is updated for a room.
*
* @param {MatrixClient} matrixClient the matrix client.
* @param {Room} room to room for which the self-membership changed.
* @param {string} membership the new membership
* @param {string} oldMembership the previous membership, can be null.
* @returns {RoomMembershipAction} an action of type `MatrixActions.Room.myMembership`.
*/
function createSelfMembershipAction(
matrixClient: MatrixClient,
room: Room,
membership: string,
oldMembership: string,
): ActionPayload {
return { action: "MatrixActions.Room.myMembership", room, membership, oldMembership };
}
/**
* @typedef EventDecryptedAction
* @type {Object}
* @property {string} action 'MatrixActions.Event.decrypted'.
* @property {MatrixEvent} event the matrix event that was decrypted.
*/
/**
* Create a MatrixActions.Event.decrypted action that represents
* a MatrixClient `Event.decrypted` matrix event, emitted when a
* matrix event is decrypted.
*
* @param {MatrixClient} matrixClient the matrix client.
* @param {MatrixEvent} event the matrix event that was decrypted.
* @returns {EventDecryptedAction} an action of type `MatrixActions.Event.decrypted`.
*/
function createEventDecryptedAction(matrixClient: MatrixClient, event: MatrixEvent): ActionPayload {
return { action: "MatrixActions.Event.decrypted", event };
}
type Listener = () => void;
type ActionCreator = (matrixClient: MatrixClient, ...args: any) => ActionPayload;
// A list of callbacks to call to unregister all listeners added
let matrixClientListenersStop: Listener[] = [];
/**
* Start listening to events of type eventName on matrixClient and when they are emitted,
* dispatch an action created by the actionCreator function.
* @param {MatrixClient} matrixClient a MatrixClient to register a listener with.
* @param {string} eventName the event to listen to on MatrixClient.
* @param {function} actionCreator a function that should return an action to dispatch
* when given the MatrixClient as an argument as well as
* arguments emitted in the MatrixClient event.
*/
function addMatrixClientListener(
matrixClient: MatrixClient,
eventName: Parameters<MatrixClient["emit"]>[0],
actionCreator: ActionCreator,
): void {
const listener: Listener = (...args) => {
const payload = actionCreator(matrixClient, ...args);
if (payload) {
// Consumers shouldn't have to worry about calling js-sdk methods mid-dispatch, so make this dispatch async
dis.dispatch(payload, false);
}
};
matrixClient.on(eventName, listener);
matrixClientListenersStop.push(() => {
matrixClient.removeListener(eventName, listener);
});
}
/**
* This object is responsible for dispatching actions when certain events are emitted by
* the given MatrixClient.
*/
export default {
/**
* Start listening to certain events from the MatrixClient and dispatch actions when
* they are emitted.
* @param {MatrixClient} matrixClient the MatrixClient to listen to events from
*/
start(matrixClient: MatrixClient) {
addMatrixClientListener(matrixClient, ClientEvent.Sync, createSyncAction);
addMatrixClientListener(matrixClient, ClientEvent.AccountData, createAccountDataAction);
addMatrixClientListener(matrixClient, RoomEvent.AccountData, createRoomAccountDataAction);
addMatrixClientListener(matrixClient, ClientEvent.Room, createRoomAction);
addMatrixClientListener(matrixClient, RoomEvent.Tags, createRoomTagsAction);
addMatrixClientListener(matrixClient, RoomEvent.Receipt, createRoomReceiptAction);
addMatrixClientListener(matrixClient, RoomEvent.Timeline, createRoomTimelineAction);
addMatrixClientListener(matrixClient, RoomEvent.MyMembership, createSelfMembershipAction);
addMatrixClientListener(matrixClient, MatrixEventEvent.Decrypted, createEventDecryptedAction);
addMatrixClientListener(matrixClient, RoomStateEvent.Events, createRoomStateEventsAction);
},
/**
* Stop listening to events.
*/
stop() {
matrixClientListenersStop.forEach((stopListener) => stopListener());
matrixClientListenersStop = [];
},
};

View File

@@ -0,0 +1,133 @@
/*
Copyright 2024 New Vector Ltd.
Copyright 2020 The Matrix.org Foundation C.I.C.
Copyright 2018 New Vector Ltd
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only
Please see LICENSE files in the repository root for full details.
*/
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 { AsyncActionPayload } from "../dispatcher/payloads";
import RoomListStore from "../stores/room-list/RoomListStore";
import { SortAlgorithm } from "../stores/room-list/algorithms/models";
import { DefaultTagID, TagID } from "../stores/room-list/models";
import ErrorDialog from "../components/views/dialogs/ErrorDialog";
export default class RoomListActions {
/**
* Creates an action thunk that will do an asynchronous request to
* tag room.
*
* @param {MatrixClient} matrixClient the matrix client to set the
* account data on.
* @param {Room} room the room to tag.
* @param {string} oldTag the tag to remove (unless oldTag ==== newTag)
* @param {string} newTag the tag with which to tag the room.
* @param {?number} oldIndex the previous position of the room in the
* list of rooms.
* @param {?number} newIndex the new position of the room in the list
* of rooms.
* @returns {AsyncActionPayload} an async action payload
* @see asyncAction
*/
public static tagRoom(
matrixClient: MatrixClient,
room: Room,
oldTag: TagID | null,
newTag: TagID | null,
newIndex: number,
): AsyncActionPayload {
let metaData: Parameters<MatrixClient["setRoomTag"]>[2] | undefined;
// Is the tag ordered manually?
const store = RoomListStore.instance;
if (newTag && store.getTagSorting(newTag) === SortAlgorithm.Manual) {
const newList = [...store.orderedLists[newTag]];
newList.sort((a, b) => a.tags[newTag].order - b.tags[newTag].order);
const indexBefore = newIndex - 1;
const indexAfter = newIndex;
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: 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("room_list|failed_set_dm_tag"),
description: err && err.message ? err.message : _t("invite|failed_generic"),
});
});
}
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("room_list|failed_remove_tag", { tagName: oldTag }),
description: err && err.message ? err.message : _t("invite|failed_generic"),
});
});
promises.push(promiseToDelete);
}
// 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"),
});
throw err;
});
promises.push(promiseToAdd);
}
return Promise.all(promises);
},
() => {
// For an optimistic update
return {
room,
oldTag,
newTag,
metaData,
};
},
);
}
}

View File

@@ -0,0 +1,54 @@
/*
Copyright 2024 New Vector Ltd.
Copyright 2020 The Matrix.org Foundation C.I.C.
Copyright 2017 New Vector Ltd
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only
Please see LICENSE files in the repository root for full details.
*/
import { AsyncActionFn, AsyncActionPayload } from "../dispatcher/payloads";
/**
* Create an action thunk that will dispatch actions indicating the current
* status of the Promise returned by fn.
*
* @param {string} id the id to give the dispatched actions. This is given a
* suffix determining whether it is pending, successful or
* a failure.
* @param {function} fn a function that returns a Promise.
* @param {function?} pendingFn a function that returns an object to assign
* to the `request` key of the ${id}.pending
* payload.
* @returns {AsyncActionPayload} an async action payload. Includes a function
* that uses its single argument as a dispatch function
* to dispatch the following actions:
* `${id}.pending` and either
* `${id}.success` or
* `${id}.failure`.
*
* The shape of each are:
* { action: '${id}.pending', request }
* { action: '${id}.success', result }
* { action: '${id}.failure', err }
*
* where `request` is returned by `pendingFn` and
* result is the result of the promise returned by
* `fn`.
*/
export function asyncAction(id: string, fn: () => Promise<any>, pendingFn: () => any | null): AsyncActionPayload {
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 });
});
};
return new AsyncActionPayload(helper);
}

View File

@@ -0,0 +1,21 @@
/*
Copyright 2024 New Vector Ltd.
Copyright 2022 The Matrix.org Foundation C.I.C.
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only
Please see LICENSE files in the repository root for full details.
*/
import { UserTab } from "../../components/views/dialogs/UserTab";
import { Action } from "../../dispatcher/actions";
import defaultDispatcher from "../../dispatcher/dispatcher";
/**
* Open user device manager settings
*/
export const viewUserDeviceSettings = (): void => {
defaultDispatcher.dispatch({
action: Action.ViewUserSettings,
initialTabId: UserTab.SessionManager,
});
};

View File

@@ -7,10 +7,10 @@ Please see LICENSE files in the repository root for full details.
import React, { ReactNode } from "react";
import { Text, Heading, Button, Separator } from "@vector-im/compound-web";
import SdkConfig from "matrix-react-sdk/src/SdkConfig";
import { Flex } from "matrix-react-sdk/src/components/utils/Flex";
import PopOutIcon from "@vector-im/compound-design-tokens/assets/web/icons/pop-out";
import SdkConfig from "../../SdkConfig";
import { Flex } from "../../components/utils/Flex";
import { _t } from "../../languageHandler";
import { Icon as AppleIcon } from "../../../res/themes/element/img/compound/apple.svg";
import { Icon as MicrosoftIcon } from "../../../res/themes/element/img/compound/microsoft.svg";

View File

@@ -0,0 +1,67 @@
/*
Copyright 2024 New Vector Ltd.
Copyright 2020 The Matrix.org Foundation C.I.C.
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only
Please see LICENSE files in the repository root for full details.
*/
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 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;
}
interface IState {
disabling: boolean;
}
/*
* Allows the user to disable the Event Index.
*/
export default class DisableEventIndexDialog extends React.Component<IProps, IState> {
public constructor(props: IProps) {
super(props);
this.state = {
disabling: false,
};
}
private onDisable = async (): Promise<void> => {
this.setState({
disabling: true,
});
await SettingsStore.setValue("enableEventIndexing", null, SettingLevel.DEVICE, false);
await EventIndexPeg.deleteEventIndex();
this.props.onFinished(true);
dis.fire(Action.ViewUserSettings);
};
public render(): React.ReactNode {
return (
<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("action|disable")}
onPrimaryButtonClick={this.onDisable}
primaryButtonClass="danger"
cancelButtonClass="warning"
onCancel={this.props.onFinished}
disabled={this.state.disabling}
/>
</BaseDialog>
);
}
}

View File

@@ -0,0 +1,200 @@
/*
Copyright 2024 New Vector Ltd.
Copyright 2020, 2021 The Matrix.org Foundation C.I.C.
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only
Please see LICENSE files in the repository root for full details.
*/
import React, { ChangeEvent } from "react";
import { Room } from "matrix-js-sdk/src/matrix";
import { _t } from "../../../../languageHandler";
import SdkConfig from "../../../../SdkConfig";
import SettingsStore from "../../../../settings/SettingsStore";
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 BaseDialog from "../../../../components/views/dialogs/BaseDialog";
import DialogButtons from "../../../../components/views/elements/DialogButtons";
import { IIndexStats } from "../../../../indexing/BaseEventIndexManager";
interface IProps {
onFinished(): void;
}
interface IState {
eventIndexSize: number;
eventCount: number;
crawlingRoomsCount: number;
roomCount: number;
currentRoom: string | null;
crawlerSleepTime: number;
}
/*
* Allows the user to introspect the event index state and disable it.
*/
export default class ManageEventIndexDialog extends React.Component<IProps, IState> {
public constructor(props: IProps) {
super(props);
this.state = {
eventIndexSize: 0,
eventCount: 0,
crawlingRoomsCount: 0,
roomCount: 0,
currentRoom: null,
crawlerSleepTime: SettingsStore.getValueAt(SettingLevel.DEVICE, "crawlerSleepTime"),
};
}
public updateCurrentRoom = async (room: Room): Promise<void> => {
const eventIndex = EventIndexPeg.get();
if (!eventIndex) return;
let stats: IIndexStats | undefined;
try {
stats = await eventIndex.getStats();
} catch {
// This call may fail if sporadically, not a huge issue as we will
// try later again and probably succeed.
return;
}
let currentRoom: string | null = null;
if (room) currentRoom = room.name;
const roomStats = eventIndex.crawlingRooms();
const crawlingRoomsCount = roomStats.crawlingRooms.size;
const roomCount = roomStats.totalRooms.size;
this.setState({
eventIndexSize: stats?.size ?? 0,
eventCount: stats?.eventCount ?? 0,
crawlingRoomsCount: crawlingRoomsCount,
roomCount: roomCount,
currentRoom: currentRoom,
});
};
public componentWillUnmount(): void {
const eventIndex = EventIndexPeg.get();
if (eventIndex !== null) {
eventIndex.removeListener("changedCheckpoint", this.updateCurrentRoom);
}
}
public async componentDidMount(): Promise<void> {
let eventIndexSize = 0;
let crawlingRoomsCount = 0;
let roomCount = 0;
let eventCount = 0;
let currentRoom: string | null = null;
const eventIndex = EventIndexPeg.get();
if (eventIndex !== null) {
eventIndex.on("changedCheckpoint", this.updateCurrentRoom);
try {
const stats = await eventIndex.getStats();
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
// probably succeed.
}
const roomStats = eventIndex.crawlingRooms();
crawlingRoomsCount = roomStats.crawlingRooms.size;
roomCount = roomStats.totalRooms.size;
const room = eventIndex.currentRoom();
if (room) currentRoom = room.name;
}
this.setState({
eventIndexSize,
eventCount,
crawlingRoomsCount,
roomCount,
currentRoom,
});
}
private onDisable = async (): Promise<void> => {
const DisableEventIndexDialog = (await import("./DisableEventIndexDialog")).default;
Modal.createDialog(DisableEventIndexDialog, undefined, undefined, /* priority = */ false, /* static = */ true);
};
private onCrawlerSleepTimeChange = (e: ChangeEvent<HTMLInputElement>): void => {
this.setState({ crawlerSleepTime: parseInt(e.target.value, 10) });
SettingsStore.setValue("crawlerSleepTime", null, SettingLevel.DEVICE, e.target.value);
};
public render(): React.ReactNode {
const brand = SdkConfig.get().brand;
let crawlerState;
if (this.state.currentRoom === null) {
crawlerState = _t("settings|security|message_search_indexing_idle");
} else {
crawlerState = _t("settings|security|message_search_indexing", { currentRoom: this.state.currentRoom });
}
const doneRooms = Math.max(0, this.state.roomCount - this.state.crawlingRoomsCount);
const eventIndexingSettings = (
<div>
{_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 />
<Field
label={_t("settings|security|message_search_sleep_time")}
type="number"
value={this.state.crawlerSleepTime.toString()}
onChange={this.onCrawlerSleepTimeChange}
/>
</div>
</div>
);
return (
<BaseDialog
className="mx_ManageEventIndexDialog"
onFinished={this.props.onFinished}
title={_t("settings|security|message_search_section")}
>
{eventIndexingSettings}
<DialogButtons
primaryButton={_t("action|done")}
onPrimaryButtonClick={this.props.onFinished}
primaryButtonClass="primary"
cancelButton={_t("action|disable")}
onCancel={this.onDisable}
cancelButtonClass="danger"
/>
</BaseDialog>
);
}
}

View File

@@ -0,0 +1,186 @@
/*
Copyright 2024 New Vector Ltd.
Copyright 2019, 2020 The Matrix.org Foundation C.I.C.
Copyright 2018, 2019 New Vector Ltd
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only
Please see LICENSE files in the repository root for full details.
*/
import React from "react";
import { logger } from "matrix-js-sdk/src/logger";
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";
enum Phase {
BackingUp = "backing_up",
Done = "done",
}
interface IProps {
onFinished(done?: boolean): void;
}
interface IState {
phase: Phase;
passPhrase: string;
passPhraseValid: boolean;
passPhraseConfirm: string;
copied: boolean;
downloaded: boolean;
error?: boolean;
}
/**
* 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> {
public constructor(props: IProps) {
super(props);
this.state = {
phase: Phase.BackingUp,
passPhrase: "",
passPhraseValid: false,
passPhraseConfirm: "",
copied: false,
downloaded: false,
};
}
public componentDidMount(): void {
this.createBackup();
}
private createBackup = async (): Promise<void> => {
this.setState({
error: undefined,
});
const cli = MatrixClientPeg.safeGet();
try {
// 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 {
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();
});
}
this.setState({
phase: Phase.Done,
});
} catch (e) {
logger.error("Error creating key backup", e);
// TODO: If creating a version succeeds, but backup fails, should we
// 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.
this.setState({
error: true,
});
}
};
private onCancel = (): void => {
this.props.onFinished(false);
};
private onDone = (): void => {
this.props.onFinished(true);
};
private renderBusyPhase(): JSX.Element {
return (
<div>
<Spinner />
</div>
);
}
private renderPhaseDone(): JSX.Element {
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.BackingUp:
return _t("settings|key_backup|backup_starting");
case Phase.Done:
return _t("settings|key_backup|backup_success");
default:
return _t("settings|key_backup|create_title");
}
}
public render(): React.ReactNode {
let content;
if (this.state.error) {
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.BackingUp:
content = this.renderBusyPhase();
break;
case Phase.Done:
content = this.renderPhaseDone();
break;
}
}
return (
<BaseDialog
className="mx_CreateKeyBackupDialog"
onFinished={this.props.onFinished}
title={this.titleForPhase(this.state.phase)}
hasCancel={[Phase.Done].includes(this.state.phase)}
>
<div>{content}</div>
</BaseDialog>
);
}
}

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