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:
committed by
GitHub
parent
2b99496025
commit
c05c429803
52
src/@types/common.ts
Normal file
52
src/@types/common.ts
Normal 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
46
src/@types/commonmark.ts
Normal 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
29
src/@types/diff-dom.d.ts
vendored
Normal 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
14
src/@types/electron-to-chromium.d.ts
vendored
Normal 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
188
src/@types/global.d.ts
vendored
@@ -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
77
src/@types/matrix-js-sdk.d.ts
vendored
Normal 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
57
src/@types/opus-recorder.d.ts
vendored
Normal 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
18
src/@types/png-chunks-extract.d.ts
vendored
Normal 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
48
src/@types/polyfill.ts
Normal 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);
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
8
src/@types/raw-loader.d.ts
vendored
8
src/@types/raw-loader.d.ts
vendored
@@ -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
16
src/@types/react.d.ts
vendored
Normal 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
15
src/@types/sanitize-html.d.ts
vendored
Normal 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
1
src/@types/svg.d.ts
vendored
@@ -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
15
src/@types/worker-loader.d.ts
vendored
Normal 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
336
src/AddThreepid.ts
Normal 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
86
src/AsyncWrapper.tsx
Normal 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
179
src/Avatar.ts
Normal 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
459
src/BasePlatform.ts
Normal 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
26
src/BlurhashEncoder.ts
Normal 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
687
src/ContentMessages.ts
Normal 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
362
src/DateUtils.ts
Normal 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);
|
||||
430
src/DecryptionFailureTracker.ts
Normal file
430
src/DecryptionFailureTracker.ts
Normal 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
503
src/DeviceListener.ts
Normal 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
78
src/DraftCleaner.ts
Normal 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
13
src/Editing.ts
Normal 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
583
src/HtmlUtils.tsx
Normal 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
222
src/IConfigOptions.ts
Normal 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
177
src/IdentityAuthClient.tsx
Normal 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
54
src/ImageUtils.ts
Normal 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
159
src/KeyBindingsDefaults.ts
Normal 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
180
src/KeyBindingsManager.ts
Normal 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
81
src/Keyboard.ts
Normal 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
1178
src/LegacyCallHandler.tsx
Normal file
File diff suppressed because it is too large
Load Diff
1132
src/Lifecycle.ts
Normal file
1132
src/Lifecycle.ts
Normal file
File diff suppressed because it is too large
Load Diff
247
src/Linkify.tsx
Normal file
247
src/Linkify.tsx
Normal 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
52
src/Livestream.ts
Normal 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
289
src/Login.ts
Normal 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
388
src/Markdown.ts
Normal 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
471
src/MatrixClientPeg.ts
Normal 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
215
src/MediaDeviceHandler.ts
Normal 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
471
src/Modal.tsx
Normal 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
129
src/NodeAnimator.tsx
Normal 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
543
src/Notifier.ts
Normal 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
17
src/PageTypes.ts
Normal 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
108
src/PasswordReset.ts
Normal 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
53
src/PlatformPeg.ts
Normal 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
26
src/PlaybackEncoder.ts
Normal 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
447
src/PosthogAnalytics.ts
Normal 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
134
src/PosthogTrackers.ts
Normal 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
101
src/Presence.ts
Normal 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
75
src/Registration.tsx
Normal 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
58
src/Resend.ts
Normal 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
28
src/Roles.ts
Normal 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
27
src/RoomAliasCache.ts
Normal 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
193
src/RoomInvite.tsx
Normal 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
297
src/RoomNotifs.ts
Normal 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
129
src/Rooms.ts
Normal 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
273
src/ScalarAuthClient.ts
Normal 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
993
src/ScalarMessaging.ts
Normal 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
159
src/SdkConfig.ts
Normal 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
721
src/Searching.ts
Normal 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
291
src/SecurityManager.ts
Normal 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
67
src/SendHistoryManager.ts
Normal 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
1061
src/SlashCommands.tsx
Normal file
File diff suppressed because it is too large
Load Diff
410
src/SlidingSyncManager.ts
Normal file
410
src/SlidingSyncManager.ts
Normal 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
114
src/SupportedBrowser.ts
Normal 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
205
src/Terms.ts
Normal 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
959
src/TextForEvent.tsx
Normal 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
47
src/TimezoneHandler.ts
Normal 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
15
src/Typeguards.ts
Normal 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
152
src/Unread.ts
Normal 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
225
src/UserActivity.ts
Normal 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
29
src/UserAddress.ts
Normal 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
51
src/Views.ts
Normal 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 they’ll 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
150
src/VoipUserMapper.ts
Normal 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
66
src/WhoIsTyping.ts
Normal 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
38
src/WorkerManager.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
126
src/accessibility/KeyboardShortcutUtils.ts
Normal file
126
src/accessibility/KeyboardShortcutUtils.ts
Normal 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);
|
||||
};
|
||||
730
src/accessibility/KeyboardShortcuts.ts
Normal file
730
src/accessibility/KeyboardShortcuts.ts
Normal 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"),
|
||||
},
|
||||
};
|
||||
97
src/accessibility/LandmarkNavigation.ts
Normal file
97
src/accessibility/LandmarkNavigation.ts
Normal 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");
|
||||
}
|
||||
},
|
||||
};
|
||||
384
src/accessibility/RovingTabIndex.tsx
Normal file
384
src/accessibility/RovingTabIndex.tsx
Normal 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";
|
||||
60
src/accessibility/Toolbar.tsx
Normal file
60
src/accessibility/Toolbar.tsx
Normal 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;
|
||||
40
src/accessibility/context_menu/ContextMenuButton.tsx
Normal file
40
src/accessibility/context_menu/ContextMenuButton.tsx
Normal 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>
|
||||
);
|
||||
});
|
||||
39
src/accessibility/context_menu/ContextMenuTooltipButton.tsx
Normal file
39
src/accessibility/context_menu/ContextMenuTooltipButton.tsx
Normal 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>
|
||||
);
|
||||
});
|
||||
28
src/accessibility/context_menu/MenuItem.tsx
Normal file
28
src/accessibility/context_menu/MenuItem.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
34
src/accessibility/context_menu/MenuItemCheckbox.tsx
Normal file
34
src/accessibility/context_menu/MenuItemCheckbox.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
34
src/accessibility/context_menu/MenuItemRadio.tsx
Normal file
34
src/accessibility/context_menu/MenuItemRadio.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
77
src/accessibility/context_menu/StyledMenuItemCheckbox.tsx
Normal file
77
src/accessibility/context_menu/StyledMenuItemCheckbox.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
77
src/accessibility/context_menu/StyledMenuItemRadio.tsx
Normal file
77
src/accessibility/context_menu/StyledMenuItemRadio.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
49
src/accessibility/roving/RovingAccessibleButton.tsx
Normal file
49
src/accessibility/roving/RovingAccessibleButton.tsx
Normal 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}
|
||||
/>
|
||||
);
|
||||
};
|
||||
23
src/accessibility/roving/RovingTabIndexWrapper.tsx
Normal file
23
src/accessibility/roving/RovingTabIndexWrapper.tsx
Normal 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 });
|
||||
};
|
||||
13
src/accessibility/roving/types.ts
Normal file
13
src/accessibility/roving/types.ts
Normal 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;
|
||||
371
src/actions/MatrixActionCreators.ts
Normal file
371
src/actions/MatrixActionCreators.ts
Normal 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 = [];
|
||||
},
|
||||
};
|
||||
133
src/actions/RoomListActions.ts
Normal file
133
src/actions/RoomListActions.ts
Normal 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,
|
||||
};
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
54
src/actions/actionCreators.ts
Normal file
54
src/actions/actionCreators.ts
Normal 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);
|
||||
}
|
||||
21
src/actions/handlers/viewUserDeviceSettings.ts
Normal file
21
src/actions/handlers/viewUserDeviceSettings.ts
Normal 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,
|
||||
});
|
||||
};
|
||||
@@ -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";
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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
Reference in New Issue
Block a user