feat: show call participants in room list (Discord-style)
Docker / Docker Buildx (push) Has been cancelled
Build Debian package / Build package (release) Has been cancelled
Build and Deploy / prepare (release) Has been cancelled
Deploy release / Deploy to Cloudflare Pages (release) Has been cancelled
Build and Deploy / Trigger Pro pipeline (release) Has been cancelled
Build and Deploy / Windows arm64 (release) Has been cancelled
Build and Deploy / Windows x64 (release) Has been cancelled
Build and Deploy / macOS (release) Has been cancelled
Build and Deploy / Linux amd64 (sqlcipher static) (release) Has been cancelled
Build and Deploy / Linux arm64 (sqlcipher static) (release) Has been cancelled
Build and Deploy / ${{ needs.prepare.outputs.deploy == 'true' && 'Deploy' || 'Deploy (dry-run)' }} (release) Has been cancelled
Build and Deploy / Deploy builds to ESS (release) Has been cancelled

This commit is contained in:
sorB
2026-05-10 14:25:35 +02:00
parent b797925316
commit 3da363517f
4610 changed files with 827237 additions and 1 deletions
+64
View File
@@ -0,0 +1,64 @@
/*
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 OR LicenseRef-Element-Commercial
Please see LICENSE files in the repository root for full details.
*/
import { type JSX, type 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 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;
/* eslint-disable @typescript-eslint/no-unsafe-function-type */
export type DeepReadonly<T> = T extends (infer R)[]
? DeepReadonlyArray<R>
: T extends Function
? T
: T extends object
? DeepReadonlyObject<T>
: T;
/* eslint-enable @typescript-eslint/no-unsafe-function-type */
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];
/**
* Returns a union type of the keys of the input Object type whose values are assignable to the given Item type.
* Based on https://stackoverflow.com/a/57862073
*/
export type Assignable<Object, Item> = {
[Key in keyof Object]: Object[Key] extends Item ? Key : never;
}[keyof Object];
/**
* Like `Partial` but for applied to all nested objects.
* Based on https://dev.to/perennialautodidact/adventures-in-typescript-deeppartial-2f2a
*/
export type DeepPartial<T> = T extends object
? {
[P in keyof T]?: DeepPartial<T[P]>;
}
: T;
+46
View File
@@ -0,0 +1,46 @@
/*
Copyright 2024 New Vector Ltd.
Copyright 2022 The Matrix.org Foundation C.I.C.
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE files in the repository root for full details.
*/
import type * as commonmark from "commonmark";
declare module "commonmark" {
export type Attr = [key: string, value: string];
/* eslint-disable @typescript-eslint/naming-convention */
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 */
}
+8
View File
@@ -0,0 +1,8 @@
/*
Copyright 2026 Element Creations Ltd.
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE files in the repository root for full details.
*/
declare module "*.css";
+30
View File
@@ -0,0 +1,30 @@
/*
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 OR LicenseRef-Element-Commercial
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;
}
// eslint-disable-next-line @typescript-eslint/no-empty-object-type
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
View File
@@ -0,0 +1,14 @@
/*
Copyright 2024 New Vector Ltd.
Copyright 2024 The Matrix.org Foundation C.I.C.
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
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;
}
+221
View File
@@ -0,0 +1,221 @@
/*
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 OR LicenseRef-Element-Commercial
Please see LICENSE files in the repository root for full details.
*/
// eslint-disable-next-line no-restricted-imports
import "matrix-js-sdk/src/@types/global"; // load matrix-js-sdk's type extensions first
import type { ModuleLoader } from "@element-hq/element-web-module-api";
import type { logger } from "matrix-js-sdk/src/logger";
import type ContentMessages from "../ContentMessages";
import { type IMatrixClientPeg } from "../MatrixClientPeg";
import type ToastStore from "../stores/ToastStore";
import { type DeviceListener } from "../device-listener";
import { type RoomListStore } from "../stores/room-list/Interface";
import { type PlatformPeg } from "../PlatformPeg";
import type RoomListLayoutStore from "../stores/room-list/RoomListLayoutStore";
import { type IntegrationManagers } from "../integrations/IntegrationManagers";
import { type ModalManager } from "../Modal";
import type SettingsStore from "../settings/SettingsStore";
import { type Notifier } from "../Notifier";
import type RightPanelStore from "../stores/right-panel/RightPanelStore";
import type WidgetStore from "../stores/WidgetStore";
import type LegacyCallHandler from "../LegacyCallHandler";
import type UserActivity from "../UserActivity";
import { type ModalWidgetStore } from "../stores/ModalWidgetStore";
import { type WidgetLayoutStore } from "../stores/widgets/WidgetLayoutStore";
import { type SpaceStoreClass } from "../stores/spaces/SpaceStore";
import type TypingStore from "../stores/TypingStore";
import { type EventIndexPeg } from "../indexing/EventIndexPeg";
import { type VoiceRecordingStore } from "../stores/VoiceRecordingStore";
import type PerformanceMonitor from "../performance";
import type UIStore from "../stores/UIStore";
import { type SetupEncryptionStore } from "../stores/SetupEncryptionStore";
import { type RoomScrollStateStore } from "../stores/RoomScrollStateStore";
import { type ConsoleLogger, type IndexedDBLogStore } from "../rageshake/rageshake";
import type ActiveWidgetStore from "../stores/ActiveWidgetStore";
import { type IConfigOptions } from "../IConfigOptions";
import { type MatrixDispatcher } from "../dispatcher/dispatcher";
import { type DeepReadonly } from "./common";
import type MatrixChat from "../components/structures/MatrixChat";
import { type InitialCryptoSetupStore } from "../stores/InitialCryptoSetupStore";
import { type ModuleApiType } from "../modules/Api.ts";
import type { RoomListStoreV3Class } from "../stores/room-list-v3/RoomListStoreV3.ts";
/* eslint-disable @typescript-eslint/naming-convention */
type ElectronChannel =
| "app_onAction"
| "before-quit"
| "check_updates"
| "install_update"
| "ipcCall"
| "ipcReply"
| "loudNotification"
| "preferences"
| "seshat"
| "seshatReply"
| "setBadgeCount"
| "update-downloaded"
| "userDownloadCompleted"
| "userDownloadAction"
| "openDesktopCapturerSourcePicker"
| "userAccessToken"
| "homeserverUrl"
| "serverSupportedVersions"
| "showToast";
declare global {
// use `number` as the return type in all cases for globalThis.set{Interval,Timeout},
// so we don't accidentally use the methods on NodeJS.Timeout - they only exist in a subset of environments.
// The overload for clear{Interval,Timeout} is resolved as expected.
// We use `ReturnType<typeof setTimeout>` in the code to be agnostic of if this definition gets loaded.
function setInterval(handler: TimerHandler, timeout: number, ...arguments: any[]): number;
function setTimeout(handler: TimerHandler, timeout: number, ...arguments: any[]): number;
interface Window {
mxSendRageshake: (text: string, withLogs?: boolean) => Promise<void>;
matrixLogger: typeof logger;
matrixChat?: MatrixChat;
mxSendSentryReport: (userText: string, issueUrl: string, error: Error) => Promise<void>;
mxLoginWithAccessToken: (hsUrl: string, accessToken: string) => Promise<void>;
mxDispatcher: MatrixDispatcher;
mxMatrixClientPeg: IMatrixClientPeg;
mxReactSdkConfig: DeepReadonly<IConfigOptions>;
// 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;
mxContentMessages: ContentMessages;
mxToastStore: ToastStore;
mxDeviceListener: DeviceListener;
mxRoomListStore: RoomListStore;
getRoomListStoreV3: () => RoomListStoreV3Class;
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;
mxSpaceStore: SpaceStoreClass;
mxVoiceRecordingStore: VoiceRecordingStore;
mxTypingStore: TypingStore;
mxEventIndexPeg: EventIndexPeg;
mxPerformanceMonitor: PerformanceMonitor;
mxPerformanceEntryNames: any;
mxUIStore: UIStore;
mxSetupEncryptionStore?: SetupEncryptionStore;
mxInitialCryptoStore?: InitialCryptoSetupStore;
mxRoomScrollStateStore?: RoomScrollStateStore;
mxActiveWidgetStore?: ActiveWidgetStore;
mxOnRecaptchaLoaded?: () => void;
mxModuleLoader: ModuleLoader;
mxModuleApi: ModuleApiType;
// electron-only
electron?: Electron;
// opera-only
opera?: any;
// https://developer.mozilla.org/en-US/docs/Web/API/InstallTrigger
InstallTrigger: any;
}
interface Electron {
// Legacy
on(channel: ElectronChannel, listener: (event: Event, ...args: any[]) => void): void;
send(channel: ElectronChannel, ...args: any[]): void;
// Initialisation
initialise(): Promise<{
protocol: string;
sessionId: string;
supportsBadgeOverlay: boolean;
config: IConfigOptions;
supportedSettings: Record<string, boolean>;
}>;
// Settings
setSettingValue(settingName: string, value: any): Promise<void>;
getSettingValue(settingName: string): Promise<any>;
}
interface DesktopCapturerSource {
id: string;
name: string;
thumbnailURL: string;
}
interface GetSourcesOptions {
types: Array<string>;
thumbnailSize?: {
height: number;
width: number;
};
fetchWindowIcons?: boolean;
}
interface StorageEstimate {
usageDetails?: { [key: string]: number };
}
// 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;
}
// 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;
}
/* eslint-enable @typescript-eslint/naming-convention */
+14
View File
@@ -0,0 +1,14 @@
/*
Copyright 2025 Element Creations Ltd.
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE files in the repository root for full details.
*/
import { type TranslationKey as _TranslationKey } from "matrix-web-i18n";
import type Translations from "../i18n/strings/en_EN.json";
declare global {
type TranslationKey = _TranslationKey<typeof Translations>;
}
+29
View File
@@ -0,0 +1,29 @@
/*
Copyright 2025 New Vector Ltd.
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE files in the repository root for full details.
*/
export const INVITE_RULES_ACCOUNT_DATA_TYPE = "org.matrix.msc4155.invite_permission_config";
export interface InviteConfigAccountData {
allowed_users?: string[];
blocked_users?: string[];
ignored_users?: string[];
allowed_servers?: string[];
blocked_servers?: string[];
ignored_servers?: string[];
}
/**
* Computed values based on MSC4155. Currently Element Web only supports
* blocking all invites.
*/
export interface ComputedInviteConfig extends Record<string, unknown> {
/**
* Are all invites blocked. This is only about blocking all invites,
* but this being false may still block invites through other rules.
*/
allBlocked: boolean;
}
+21
View File
@@ -0,0 +1,21 @@
/*
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 OR LicenseRef-Element-Commercial
Please see LICENSE files in the repository root for full details.
*/
import "jitsi-meet";
declare module "jitsi-meet" {
interface ExternalAPIEventCallbacks {
errorOccurred: (e: { error: Error & { isFatal?: boolean } }) => void;
}
interface JitsiMeetExternalAPI {
executeCommand(command: "setTileView", value: boolean): void;
}
}
export as namespace Jitsi;
+13
View File
@@ -0,0 +1,13 @@
/*
Copyright 2024 New Vector Ltd.
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE files in the repository root for full details.
*/
export type JsonValue = null | string | number | boolean;
export type JsonArray = Array<JsonValue | JsonObject | JsonArray>;
export interface JsonObject {
[key: string]: JsonObject | JsonArray | JsonValue;
}
export type Json = JsonArray | JsonObject;
+119
View File
@@ -0,0 +1,119 @@
/*
Copyright 2024, 2025 New Vector Ltd.
Copyright 2024 The Matrix.org Foundation C.I.C.
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
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 { EncryptedFile } from "matrix-js-sdk/src/types";
import type { EmptyObject } from "matrix-js-sdk/src/matrix";
import type { DeviceClientInformation } from "../utils/device/types.ts";
import type { UserWidget } from "../utils/WidgetUtils-types.ts";
import { type MediaPreviewConfig } from "./media_preview.ts";
import { type INVITE_RULES_ACCOUNT_DATA_TYPE, type InviteConfigAccountData } from "./invite-rules.ts";
// 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 ImageInfo {
/**
* @see https://github.com/matrix-org/matrix-spec-proposals/pull/4230
*/
"org.matrix.msc4230.is_animated"?: boolean;
}
export interface StateEvents {
// Jitsi-backed video room state events
[JitsiCallMemberEventType]: JitsiCallMemberContent;
// Unstable widgets state events
"im.vector.modular.widgets": IWidget | EmptyObject;
[WIDGET_LAYOUT_EVENT_TYPE]: ILayoutStateEvent;
// 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 AccountDataEvents {
// Analytics account data event
"im.vector.analytics": {
id: string;
pseudonymousAnalyticsOptIn?: boolean;
};
// Device client information account data event
[key: `io.element.matrix_client_information.${string}`]: DeviceClientInformation;
// Element settings account data events
"im.vector.setting.breadcrumbs": { recent_rooms: string[] };
"io.element.recent_emoji": { recent_emoji: string[] };
"im.vector.setting.integration_provisioning": { enabled: boolean };
"im.vector.riot.breadcrumb_rooms": { recent_rooms: string[] };
"im.vector.web.settings": Record<string, any>;
// URL preview account data event
"org.matrix.preview_urls": { disable: boolean };
// This is not yet in the Matrix spec yet is being used as if it was
"m.widgets": {
[widgetId: string]: UserWidget;
};
// This is not in the Matrix spec yet seems to use an `m.` prefix
"m.accepted_terms": {
accepted: string[];
};
// MSC4155: Invite filtering
[INVITE_RULES_ACCOUNT_DATA_TYPE]: InviteConfigAccountData;
"io.element.msc4278.media_preview_config": MediaPreviewConfig;
// Indicate whether recovery is enabled or disabled
"io.element.recovery": { enabled: boolean };
}
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"?: EmptyObject;
}
}
+33
View File
@@ -0,0 +1,33 @@
/*
Copyright 2025 New Vector Ltd.
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE files in the repository root for full details.
*/
export enum MediaPreviewValue {
/**
* Media previews should be enabled.
*/
On = "on",
/**
* Media previews should only be enabled for rooms with non-public join rules.
*/
Private = "private",
/**
* Media previews should be disabled.
*/
Off = "off",
}
export const MEDIA_PREVIEW_ACCOUNT_DATA_TYPE = "io.element.msc4278.media_preview_config";
export interface MediaPreviewConfig extends Record<string, unknown> {
/**
* Media preview setting for thumbnails of media in rooms.
*/
media_previews?: MediaPreviewValue;
/**
* Media preview settings for avatars of rooms we have been invited to.
*/
invite_avatars?: MediaPreviewValue.On | MediaPreviewValue.Off;
}
+57
View File
@@ -0,0 +1,57 @@
/*
Copyright 2024 New Vector Ltd.
Copyright 2023 The Matrix.org Foundation C.I.C.
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
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;
}
+8
View File
@@ -0,0 +1,8 @@
/*
Copyright 2026 Element Creations Ltd.
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE files in the repository root for full details.
*/
declare module "*.pcss";
+48
View File
@@ -0,0 +1,48 @@
/*
Copyright 2024 New Vector Ltd.
Copyright 2020 The Matrix.org Foundation C.I.C.
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
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);
}
};
}
}
+12
View File
@@ -0,0 +1,12 @@
/*
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 OR LicenseRef-Element-Commercial
Please see LICENSE files in the repository root for full details.
*/
declare module "!!raw-loader!*" {
const contents: string;
export default contents;
}
+19
View File
@@ -0,0 +1,19 @@
/*
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 OR LicenseRef-Element-Commercial
Please see LICENSE files in the repository root for full details.
*/
import { type ComponentType } from "react";
declare module "react" {
// Fix lazy types - https://stackoverflow.com/a/71017028
function lazy<T extends ComponentType<any>>(factory: () => Promise<{ default: T }>): T;
// Standardize defaultProps for FunctionComponent so we can write generics assuming `defaultProps` exists on ComponentType
interface FunctionComponent {
defaultProps?: unknown;
}
}
+14
View File
@@ -0,0 +1,14 @@
/*
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 OR LicenseRef-Element-Commercial
Please see LICENSE files in the repository root for full details.
*/
declare module "*.svg" {
const path: string;
export const Icon: React.FC<React.SVGProps<SVGSVGElement>>;
export default path;
}
+15
View File
@@ -0,0 +1,15 @@
/*
Copyright 2024 New Vector Ltd.
Copyright 2021 The Matrix.org Foundation C.I.C.
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE files in the repository root for full details.
*/
declare module "*.worker.ts" {
class WebpackWorker extends Worker {
public constructor();
}
export default WebpackWorker;
}
+348
View File
@@ -0,0 +1,348 @@
/*
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 OR LicenseRef-Element-Commercial
Please see LICENSE files in the repository root for full details.
*/
import {
type IAddThreePidOnlyBody,
type IRequestMsisdnTokenResponse,
type IRequestTokenResponse,
type MatrixClient,
MatrixError,
HTTPError,
type 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, {
type 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 });
} else if (err instanceof MatrixError && err.errcode === "M_THREEPID_MEDIUM_NOT_SUPPORTED") {
throw new UserFriendlyError("settings|general|email_adding_unsupported_by_hs", { 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().
*
* @throws {UserFriendlyError} An appropriate user-friendly error if the verification code could not be sent.
*/
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 });
} else if (err instanceof MatrixError && err.errcode === "M_THREEPID_MEDIUM_NOT_SUPPORTED") {
throw new UserFriendlyError("settings|general|msisdn_adding_unsupported_by_hs", { cause: err });
} else if (err instanceof MatrixError && err.errcode === "M_INVALID_PARAM") {
throw new UserFriendlyError("settings|general|invalid_phone_number", { 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?: IAddThreePidOnlyBody | 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?.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<IAddThreePidOnlyBody>, {
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)
*/
// eslint-disable-next-line @typescript-eslint/no-empty-object-type
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?: IAddThreePidOnlyBody | 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?.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<IAddThreePidOnlyBody>, {
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;
}
}
}
}
+52
View File
@@ -0,0 +1,52 @@
/*
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 OR LicenseRef-Element-Commercial
Please see LICENSE files in the repository root for full details.
*/
import React, { type ReactNode, Suspense } from "react";
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";
interface IProps {
onFinished(): void;
children: ReactNode;
}
interface IState {
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> {
public static getDerivedStateFromError(error: Error): IState {
return { error };
}
public state: IState = {};
public render(): React.ReactNode {
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.props.onFinished}
hasCancel={false}
/>
</BaseDialog>
);
}
return <Suspense fallback={<Spinner />}>{this.props.children}</Suspense>;
}
}
+195
View File
@@ -0,0 +1,195 @@
/*
Copyright 2024 New Vector Ltd.
Copyright 2015, 2016 OpenMarket Ltd
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE files in the repository root for full details.
*/
import { type RoomMember, type User, type Room, type 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";
import ThemeWatcher from "./settings/watchers/ThemeWatcher";
/**
* 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_LIGHT_COLORS = ["#e0f8d9", "#e3f5f8", "#faeefb", "#f1efff", "#ffecf0", "#ffefe4"];
const AVATAR_TEXT_LIGHT_COLORS = ["#005f00", "#00548c", "#822198", "#5d26cd", "#9f0850", "#9b2200"];
const AVATAR_BG_DARK_COLORS = ["#002600", "#001b4e", "#37004e", "#22006a", "#450018", "#470000"];
const AVATAR_TEXT_DARK_COLORS = ["#56c02c", "#21bacd", "#d991de", "#ad9cfe", "#fe84a2", "#f6913d"];
// 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 if the current theme is dark
*/
function isDarkTheme(): boolean {
return new ThemeWatcher().getEffectiveTheme() === "dark";
}
/**
* 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);
// Use light colors by default
return isDarkTheme() ? AVATAR_TEXT_DARK_COLORS[index - 1] : AVATAR_TEXT_LIGHT_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.startsWith("#") &&
!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);
// Light colors are the default
const color =
cssValue || isDarkTheme() ? AVATAR_BG_DARK_COLORS[colorIndex - 1] : AVATAR_BG_LIGHT_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,
avatarMxcOverride?: string,
): string | null {
if (!room) return null; // null-guard
const mxc = avatarMxcOverride ?? room.getMxcAvatarUrl();
if (mxc) {
const media = mediaFromMxc(mxc);
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;
}
+518
View File
@@ -0,0 +1,518 @@
/*
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 OR LicenseRef-Element-Commercial
Please see LICENSE files in the repository root for full details.
*/
import {
type MatrixClient,
type MatrixEvent,
type Room,
type SSOAction,
encodeUnpaddedBase64,
type OidcRegistrationClientMetadata,
MatrixEventEvent,
} from "matrix-js-sdk/src/matrix";
import { logger } from "matrix-js-sdk/src/logger";
import dis from "./dispatcher/dispatcher";
import type BaseEventIndexManager from "./indexing/BaseEventIndexManager";
import { type ActionPayload } from "./dispatcher/payloads";
import { type 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 { type ViewRoomPayload } from "./dispatcher/payloads/ViewRoomPayload";
import { type IConfigOptions } from "./IConfigOptions";
import SdkConfig from "./SdkConfig";
import { buildAndEncodePickleKey, encryptPickleKey } from "./utils/tokens/pickling";
import Favicon from "./favicon.ts";
import { getVectorConfig } from "./vector/getconfig.ts";
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 _favicon?: Favicon;
public readonly initialised = Promise.resolve<void>(undefined);
protected constructor() {
dis.register(this.onAction.bind(this));
this.startUpdateCheck = this.startUpdateCheck.bind(this);
}
public async getConfig(): Promise<IConfigOptions | undefined> {
return getVectorConfig();
}
/**
* Get a sensible default display name for the device Element is running on
*/
public abstract getDefaultDeviceDisplayName(): string;
protected onAction(payload: ActionPayload): void {
switch (payload.action) {
case Action.ClientNotViable:
case Action.OnLoggedOut:
this.setNotificationCount(0);
break;
}
}
// Used primarily for Analytics
public abstract getHumanReadableName(): string;
public setNotificationCount(count: number): void {
if (this.notificationCount === count) return;
this.notificationCount = count;
this.updateFavicon();
}
public setErrorStatus(errorDidOccur: boolean): void {
if (this.errorDidOccur === errorDidOccur) return;
this.errorDidOccur = errorDidOccur;
this.updateFavicon();
}
/**
* 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 {
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();
};
const closeHandler = (): void => notification.close();
// Clear a notification from a redacted event.
if (ev) {
ev.once(MatrixEventEvent.BeforeRedaction, closeHandler);
notification.onclose = () => {
ev.off(MatrixEventEvent.BeforeRedaction, closeHandler);
};
}
return notification;
}
public loudNotification(ev: MatrixEvent, room: Room): void {}
/**
* 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 {
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 {
// The redirect URL has to exactly match that registered at the OIDC server, so
// build it from scratch to avoid leaking ephemeral query params (e.g. `updated`).
const url = new URL(window.location.origin + window.location.pathname);
// Set no_universal_links=true to prevent the callback being handled by Element X installed on macOS Apple Silicon
url.searchParams.set("no_universal_links", "true");
return url;
}
/**
* Delay creating the `Favicon` instance until first use (on the first notification) as
* it uses canvas, which can trigger a permission prompt in Firefox's resist fingerprinting mode.
* See https://github.com/element-hq/element-web/issues/9605.
*/
public get favicon(): Favicon {
if (this._favicon) {
return this._favicon;
}
this._favicon = new Favicon();
return this._favicon;
}
private updateFavicon(): void {
const notif: string | number = this.notificationCount;
if (this.errorDidOccur) {
this.favicon.badge(notif || "×", { bgColor: "#f00" });
}
this.favicon.badge(notif);
}
/**
* Begin update polling, if applicable
*/
public startUpdater(): void {}
/**
* Checks if the current session is lock-free, i.e., no other instance is holding the session lock.
* Platforms that support session locking should override this method.
* @returns {boolean} True if the session is lock-free, false otherwise.
*/
public abstract checkSessionLockFree(): boolean;
/**
* Attempts to acquire a session lock for this instance.
* If another instance is detected, calls the provided callback.
* Platforms that support session locking should override this method.
* @param _onNewInstance Callback to invoke if a new instance is detected.
* @returns {Promise<boolean>} True if the lock was acquired, false otherwise.
*/
public abstract getSessionLock(_onNewInstance: () => Promise<void>): Promise<boolean>;
}
+25
View File
@@ -0,0 +1,25 @@
/*
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 OR LicenseRef-Element-Commercial
Please see LICENSE files in the repository root for full details.
*/
import { type Request, type 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);
}
}
+718
View File
@@ -0,0 +1,718 @@
/*
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 OR LicenseRef-Element-Commercial
Please see LICENSE files in the repository root for full details.
*/
import {
type MatrixClient,
MsgType,
HTTPError,
type IEventRelation,
type ISendEventResponse,
type MatrixEvent,
type UploadOpts,
type UploadProgress,
THREAD_RELATION_TYPE,
} from "matrix-js-sdk/src/matrix";
import {
type ImageInfo,
type AudioInfo,
type VideoInfo,
type EncryptedFile,
type MediaEventContent,
type 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 {
type UploadCanceledPayload,
type UploadErrorPayload,
type UploadFinishedPayload,
type UploadProgressPayload,
type 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 "./utils/messages.ts";
import { doMaybeLocalRoomAction } from "./utils/local-room";
import { blobIsAnimated } from "./utils/Image.ts";
// 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 {}
export class UploadFailedError extends Error {
public constructor(cause: any) {
super();
this.cause = cause;
}
}
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", "image/svg+xml"];
/**
* 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";
}
// We don't await this immediately so it can happen in the background
const isAnimatedPromise = blobIsAnimated(imageFile);
const imageElement = await loadImageElement(imageFile);
const result = await createThumbnail(imageElement.img, imageElement.width, imageElement.height, thumbnailType);
const imageInfo = result.info;
const isAnimated = await isAnimatedPromise;
if (isAnimated !== undefined) {
imageInfo["org.matrix.msc4230.is_animated"] = await isAnimatedPromise;
}
// 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 (await matrixClient.getCrypto()?.isEncryptionEnabledInRoom(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]);
let url: string;
try {
({ content_uri: url } = await matrixClient.uploadContent(blob, {
progressHandler,
abortController,
includeFilename: false,
type: "application/octet-stream",
}));
} catch (e) {
if (abortController.signal.aborted) throw new UploadCanceledError();
console.error("Failed to upload file", e);
throw new UploadFailedError(e);
}
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 {
let url: string;
try {
({ content_uri: url } = await matrixClient.uploadContent(file, { progressHandler, abortController }));
} catch (e) {
if (abortController.signal.aborted) throw new UploadCanceledError();
console.error("Failed to upload file", e);
throw new UploadFailedError(e);
}
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 {
return this.mediaConfig?.["m.upload.size"] ?? null;
}
/**
* Sends a list of files to a room.
* @param files - The files to send.
* @param roomId - The ID of the room to send the files to.
* @param relation - The relation to the event being replied to.
* @param replyToEvent - The event being replied to, if any.
* @param matrixClient - The Matrix client to use for sending the files.
* @param context - The context in which the files are being sent.
* @returns A promise that resolves when the files have been sent.
*/
public async sendContentListToRoom(
files: File[],
roomId: string,
relation: IEventRelation | undefined,
replyToEvent: MatrixEvent | undefined,
matrixClient: MatrixClient,
context = TimelineRenderingType.Room,
): Promise<void> {
if (matrixClient.isGuest()) {
dis.dispatch({ action: "require_registration" });
return;
}
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);
}
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 UploadFailedError) {
// 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.startsWith("audio/")) {
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.startsWith("video/")) {
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) {
// Unwrap UploadFailedError to get the underlying error
const unwrappedError = error instanceof UploadFailedError && error.cause ? error.cause : 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 (unwrappedError instanceof HTTPError && unwrappedError.httpStatus === 413) {
this.mediaConfig = null;
}
if (!upload.cancelled) {
let desc = _t("upload_failed_generic", { fileName: upload.fileName });
if (unwrappedError instanceof HTTPError && unwrappedError.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?.["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;
}
}
+80
View File
@@ -0,0 +1,80 @@
/*
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 OR LicenseRef-Element-Commercial
Please see LICENSE files in the repository root for full details.
*/
import { type AuthDict, type MatrixClient, MatrixError } from "matrix-js-sdk/src/matrix";
import { SSOAuthEntry } from "./components/views/auth/InteractiveAuthEntryComponents";
import Modal from "./Modal";
import { _t } from "./languageHandler";
import InteractiveAuthDialog from "./components/views/dialogs/InteractiveAuthDialog";
/**
* Ensures that cross signing keys are created and uploaded for the user.
* The homeserver may require user-interactive auth to upload the keys, in
* which case the user will be prompted to authenticate.
*
* This function does not set up backups of the created cross-signing keys
* (or message keys): the cross-signing keys are stored locally and will be
* lost requiring a crypto reset, if the user logs out or loses their session.
*
* @param cli The Matrix Client to use
*/
export async function createCrossSigning(cli: MatrixClient): Promise<void> {
const cryptoApi = cli.getCrypto();
if (!cryptoApi) {
throw new Error("No crypto API found!");
}
await cryptoApi.bootstrapCrossSigning({
authUploadDeviceSigningKeys: (makeRequest) => uiAuthCallback(cli, makeRequest),
});
}
export async function uiAuthCallback(
matrixClient: MatrixClient,
makeRequest: (authData: AuthDict | null) => Promise<void>,
): Promise<void> {
try {
await makeRequest(null);
} catch (error) {
if (!(error instanceof MatrixError) || !error.data || !error.data.flows) {
// Not a UIA response
throw error;
}
const dialogAesthetics = {
[SSOAuthEntry.PHASE_PREAUTH]: {
title: _t("auth|uia|sso_title"),
body: _t("auth|uia|sso_preauth_body"),
continueText: _t("auth|sso"),
continueKind: "primary",
},
[SSOAuthEntry.PHASE_POSTAUTH]: {
title: _t("encryption|confirm_encryption_setup_title"),
body: _t("encryption|confirm_encryption_setup_body"),
continueText: _t("action|confirm"),
continueKind: "primary",
},
};
const { finished } = Modal.createDialog(InteractiveAuthDialog, {
title: "",
matrixClient,
makeRequest,
aestheticsForStagePhases: {
[SSOAuthEntry.LOGIN_TYPE]: dialogAesthetics,
[SSOAuthEntry.UNSTABLE_LOGIN_TYPE]: dialogAesthetics,
},
});
const [confirmed] = await finished;
if (!confirmed) {
throw new Error("Cross-signing key upload auth canceled");
}
}
}
+323
View File
@@ -0,0 +1,323 @@
/*
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 OR LicenseRef-Element-Commercial
Please see LICENSE files in the repository root for full details.
*/
import { _t, getUserLanguage } from "./languageHandler";
import { getUserTimezone } from "./TimezoneHandler";
export { formatSeconds } from "@element-hq/web-shared-components";
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 dateTimeFormat = new Intl.DateTimeFormat(getUserLanguage(), { weekday, timeZone: "UTC" });
return [...Array(7).keys()].map((day) => dateTimeFormat.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 dateTimeFormat = new Intl.DateTimeFormat(getUserLanguage(), { month, timeZone: "UTC" });
return [...Array(12).keys()].map((m) => dateTimeFormat.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
export 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 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 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: Date | undefined, nextEventDate: Date | undefined): 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);
+434
View File
@@ -0,0 +1,434 @@
/*
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 OR LicenseRef-Element-Commercial
Please see LICENSE files in the repository root for full details.
*/
import ScalableBloomFilter from "bloom-filters/dist/bloom/scalable-bloom-filter";
import { HttpApiEvent, type MatrixClient, MatrixEventEvent, type MatrixEvent } from "matrix-js-sdk/src/matrix";
import { type 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";
case DecryptionFailureCode.SENDER_IDENTITY_PREVIOUSLY_VERIFIED:
return "ExpectedVerificationViolation";
case DecryptionFailureCode.UNSIGNED_SENDER_DEVICE:
return "ExpectedSentByInsecureDevice";
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);
}
}
+78
View File
@@ -0,0 +1,78 @@
/*
Copyright 2024 New Vector Ltd.
Copyright 2024 The Matrix.org Foundation C.I.C.
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
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 {
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
View File
@@ -0,0 +1,13 @@
/*
Copyright 2024 New Vector Ltd.
Copyright 2022 The Matrix.org Foundation C.I.C.
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE files in the repository root for full details.
*/
import { type 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}`;
+571
View File
@@ -0,0 +1,571 @@
/*
Copyright 2024, 2025 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 OR LicenseRef-Element-Commercial
Please see LICENSE files in the repository root for full details.
*/
import React, { type JSX, type Key, type LegacyRef, type ReactNode } from "react";
import sanitizeHtml, { type IOptions } from "sanitize-html";
import classNames from "classnames";
import katex from "katex";
import { decode } from "html-entities";
import { type IContent } from "matrix-js-sdk/src/matrix";
import escapeHtml from "escape-html";
import { getEmojiFromUnicode } from "@matrix-org/emojibase-bindings";
import { PERMITTED_URL_SCHEMES, LINKIFIED_DATA_ATTRIBUTE } from "@element-hq/web-shared-components";
import SettingsStore from "./settings/SettingsStore";
import { stripHTMLReply, stripPlainReply } from "./utils/Reply";
import { sanitizeHtmlParams, transformTags, linkifyHtml } from "./Linkify";
import { graphemeSegmenter } from "./utils/strings";
export { 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 {
// v mode not supported; fall back to matching nothing
return /(?!)/;
}
})();
const BIGEMOJI_REGEX = (() => {
try {
return new RegExp(`^(${EMOJI_REGEX.source})+$`, "iv");
} catch {
// 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 {
return false;
}
}
// this is the same as the above except with less rewriting
const composerSanitizeHtmlParams: IOptions = {
...sanitizeHtmlParams,
transformTags: {
"code": transformTags["code"],
"*": transformTags["*"],
},
};
// reduced set of allowed tags to avoid turning topics into Myspace
const topicSanitizeHtmlParams: IOptions = {
...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: Key): 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;
/**
* Should inline media be rendered?
*/
mediaIsVisible?: boolean;
linkify?: boolean;
}
function analyseEvent(content: IContent, highlights?: string[], opts: EventRenderOpts = {}): EventAnalysis {
let sanitizeParams = sanitizeHtmlParams;
if (opts.forComposerQuote) {
sanitizeParams = composerSanitizeHtmlParams;
}
if (opts.mediaIsVisible === false && sanitizeParams.transformTags?.["img"]) {
// Prevent mutating the source of sanitizeParams.
sanitizeParams = {
...sanitizeParams,
transformTags: {
...sanitizeParams.transformTags,
img: (tagName) => {
// Remove element
return { tagName, attribs: {} };
},
},
};
}
if (opts.linkify) {
// Prevent mutating the source of sanitizeParams.
sanitizeParams = { ...sanitizeParams };
if (typeof sanitizeParams.allowedAttributes === "object") {
const attribs = { ...sanitizeParams.allowedAttributes };
// We allow data-linkified because TextualBody uses it to passthrough links.
attribs["a"] = [...sanitizeParams.allowedAttributes["a"], `data-${LINKIFIED_DATA_ATTRIBUTE}`];
sanitizeParams.allowedAttributes = attribs;
} // else: No attibutes are are allowed for "a"
}
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 (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("");
};
}
if (isFormattedBody) {
let unsafeBody = formattedBody!;
if (opts.linkify) {
unsafeBody = linkifyHtml(unsafeBody);
}
safeBody = sanitizeHtml(unsafeBody, 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 (opts.linkify) {
// If we are linkifying plain text, pass the result through sanitizeHtml so that the highlighter configured in sanitizeParams.textFilter gets applied.
safeBody = sanitizeHtml(linkifyHtml(escapeHtml(plainBody)), sanitizeParams);
} else if (highlighter) {
safeBody = highlighter.applyHighlights(escapeHtml(plainBody), safeHighlights!).join("");
}
return { bodyHasEmoji, isHtmlMessage, strippedBody, safeBody, isFormattedBody };
} finally {
delete sanitizeParams.textFilter;
}
}
interface BodyToNodeReturn {
strippedBody: string;
formattedBody?: string;
emojiBodyElements: JSX.Element[] | undefined;
className: string;
}
export function bodyToNode(content: IContent, highlights?: 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;
let emojiBodyElements: JSX.Element[] | undefined;
if (eventInfo.bodyHasEmoji) {
if (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("");
} else {
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?: string[], opts: EventRenderOpts = {}): string {
const eventInfo = analyseEvent(content, highlights, opts);
let formattedBody = eventInfo.safeBody;
if (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 {
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;
}
}
+224
View File
@@ -0,0 +1,224 @@
/*
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 OR LicenseRef-Element-Commercial
Please see LICENSE files in the repository root for full details.
*/
import { type IClientWellKnown } from "matrix-js-sdk/src/matrix";
import { type 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"] } ] */
/**
* Bug reports are enabled but must only be locally
* downloadable.
*/
export const BugReportEndpointURLLocal = "local";
// 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
logo_link_url: string;
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_win64arm?: 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;
mobile_guide_app_variant?: "element" | "element-classic" | "element-pro";
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. "local" means the logs should not be uploaded.
*/
bug_report_endpoint_url?: typeof BugReportEndpointURLLocal | string; // omission disables bug reporting
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: {
guest_spa_url?: string;
use_exclusively?: boolean;
brand?: string;
};
logout_redirect_url?: string;
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;
help_key_storage_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
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[];
};
modules?: string[];
}
export interface ISsoRedirectOptions {
immediate?: boolean;
on_welcome_page?: boolean;
on_login_page?: boolean;
}
+177
View File
@@ -0,0 +1,177 @@
/*
Copyright 2024 New Vector Ltd.
Copyright 2019 The Matrix.org Foundation C.I.C.
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE files in the repository root for full details.
*/
import React from "react";
import { SERVICE_TYPES, createClient, type 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 || accessToken;
if (check) await this.checkToken(identityAccessToken);
return identityAccessToken;
}
}
+54
View File
@@ -0,0 +1,54 @@
/*
Copyright 2024 New Vector Ltd.
Copyright 2015, 2016 , 2020 Copyright 2020 The Matrix.org Foundation C.I.C.
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
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
View File
@@ -0,0 +1,159 @@
/*
Copyright 2024 New Vector Ltd.
Copyright 2022 Šimon Brandner <simon.bra.ag@gmail.com>
Copyright 2021 Clemens Zeidler
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
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 { type IKeyBindingsProvider, type 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
View File
@@ -0,0 +1,180 @@
/*
Copyright 2024 New Vector Ltd.
Copyright 2022 Šimon Brandner <simon.bra.ag@gmail.com>
Copyright 2021 Clemens Zeidler
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE files in the repository root for full details.
*/
import { type 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;
}
+90
View File
@@ -0,0 +1,90 @@
/*
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 OR LicenseRef-Element-Commercial
Please see LICENSE files in the repository root for full details.
*/
import type 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;
}
}
/**
* Checks if the given keyboard event is a modified key event (i.e., if any modifier keys are active).
* @param ev The keyboard event to check
* @returns True if the event is a modified key event, false otherwise
*/
export function isModifiedKeyEvent(ev: React.KeyboardEvent | KeyboardEvent): boolean {
return ev.metaKey || ev.altKey || ev.ctrlKey || ev.shiftKey;
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+406
View File
@@ -0,0 +1,406 @@
/*
Copyright 2026 Element Creations Ltd.
Copyright 2024, 2025 New Vector Ltd.
Copyright 2024 The Matrix.org Foundation C.I.C.
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE files in the repository root for full details.
*/
import sanitizeHtml, { type IOptions } from "sanitize-html";
import {
PERMITTED_URL_SCHEMES,
linkifyString as _linkifyString,
linkifyHtml as _linkifyHtml,
LinkifyMatrixOpaqueIdType,
generateLinkedTextOptions,
type LinkEventListener,
} from "@element-hq/web-shared-components";
import { getHttpUriForMxc, User } from "matrix-js-sdk/src/matrix";
import { ELEMENT_URL_PATTERN } from "./linkify-matrix";
import { mediaFromMxc } from "./customisations/Media";
import {
parsePermalink,
tryTransformEntityToPermalink,
tryTransformPermalinkToLocalHref,
} from "./utils/permalinks/Permalinks";
import dis from "./dispatcher/dispatcher";
import { Action } from "./dispatcher/actions";
import { type ViewUserPayload } from "./dispatcher/payloads/ViewUserPayload";
import { type ViewRoomPayload } from "./dispatcher/payloads/ViewRoomPayload";
import { MatrixClientPeg } from "./MatrixClientPeg";
const COLOR_REGEX = /^#[0-9a-fA-F]{6}$/;
const MEDIA_API_MXC_REGEX = /\/_matrix\/media\/r0\/(?:download|thumbnail)\/(.+?)\/(.+?)(?:[?/]|$)/;
export const transformTags: NonNullable<IOptions["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
ELEMENT_URL_PATTERN.test(attribs.href) // 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.
if (!src) {
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: IOptions = {
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,
};
/**
* Handler function when a UserID link is clicked.
* @param event The click event
* @param userId The linked UserID
*/
function onUserClick(event: MouseEvent, userId: string): void {
event.preventDefault();
dis.dispatch<ViewUserPayload>({
action: Action.ViewUser,
member: new User(userId),
});
}
/**
* Handler function when a Room Alias link is clicked.
* @param event The click event
* @param roomAlias The linked room alias
*/
function onAliasClick(event: MouseEvent, roomAlias: string): void {
event.preventDefault();
dis.dispatch<ViewRoomPayload>({
action: Action.ViewRoom,
room_alias: roomAlias,
metricsTrigger: "Timeline",
metricsViaKeyboard: false,
});
}
/**
* Generates a set of event handlers for a regular URL link.
*
* @param href The link location.
* @returns Event listenenrs compatible with linkifyjs.
*/
function urlEventListeners(href: string): LinkEventListener {
// intercept local permalinks to users and show them like userids (in userinfo of current room)
try {
const permalink = parsePermalink(href);
if (permalink?.userId) {
return {
click: function (e: MouseEvent) {
onUserClick(e, permalink.userId!);
},
};
} else {
// for events, rooms etc. (anything other than users)
const localHref = tryTransformPermalinkToLocalHref(href);
if (localHref !== href) {
// it could be converted to a localHref -> therefore handle locally
return {
click: function (e: MouseEvent) {
e.preventDefault();
globalThis.location.hash = localHref;
},
};
}
}
} catch {
// OK fine, it's not actually a permalink
}
return {};
}
/**
* Generates a set of event handlers for a UserID link.
*
* @param href A link that contains a userId.
* @returns Event listenenrs compatible with linkifyjs.
*/
export function userIdEventListeners(href: string): LinkEventListener {
return {
click: function (e: MouseEvent) {
e.preventDefault();
const userId = parsePermalink(href)?.userId ?? href;
if (userId) onUserClick(e, userId);
},
};
}
/**
* Generates a set of event handlers for a UserID link.
*
* @param href A link that contains a room alias.
* @returns Event listenenrs compatible with linkifyjs.
*/
export function roomAliasEventListeners(href: string): LinkEventListener {
return {
click: function (e: MouseEvent) {
e.preventDefault();
const alias = parsePermalink(href)?.roomIdOrAlias ?? href;
if (alias) onAliasClick(e, alias);
},
};
}
/**
* Generates a `target` attribute for the anchor element
* for the given `href` value.
*
* @param href A URL from a link.
* @returns The resulting `target` value.
*/
function urlTargetTransformFunction(href: string): string {
try {
const transformed = tryTransformPermalinkToLocalHref(href);
if (
transformed !== href || // if it could be converted to handle locally for matrix symbols e.g. @user:server.tdl and matrix.to
ELEMENT_URL_PATTERN.test(decodeURIComponent(href)) // for https links to Element domains
) {
return "";
} else {
return "_blank";
}
} catch {
// malformed URI
}
return "";
}
/**
* Generates the result `href` value based on an incoming `href` value and a link type.
*
* @param href A URL from a link.
* @param type The type of link beinh handled.
* @returns The resulting `href` value.
*/
export function formatHref(href: string, type: LinkifyMatrixOpaqueIdType): string {
switch (type) {
case LinkifyMatrixOpaqueIdType.URL:
if (href.startsWith("mxc://") && MatrixClientPeg.get()) {
return getHttpUriForMxc(
MatrixClientPeg.get()!.baseUrl,
href,
undefined,
undefined,
undefined,
false,
true,
);
}
// fallthrough
case LinkifyMatrixOpaqueIdType.RoomAlias:
case LinkifyMatrixOpaqueIdType.UserId:
default: {
return tryTransformEntityToPermalink(MatrixClientPeg.safeGet(), href) ?? "";
}
}
}
/**
* The standard configuration for a LinkedTextContext.Provider
* within Element Web.
*/
export const LinkedTextConfiguration = {
userIdListener: userIdEventListeners,
roomAliasListener: roomAliasEventListeners,
urlListener: urlEventListeners,
hrefTransformer: formatHref,
urlTargetTransformer: urlTargetTransformFunction,
};
/**
* Linkifies the given string. This is a wrapper around 'linkifyjs/string'.
*
* @param str string to linkify
* @param [options] Options for linkifyString.
* @returns Linkified string
*/
export function linkifyString(value: string, options = generateLinkedTextOptions(LinkedTextConfiguration)): string {
return _linkifyString(value, options);
}
/**
* Linkifies the given HTML-formatted string. This is a wrapper around 'linkifyjs/html'.
*
* @param str HTML string to linkify
* @param [options] Options for linkifyHtml.
* @returns Linkified string
*/
export function linkifyHtml(value: string, options = generateLinkedTextOptions(LinkedTextConfiguration)): string {
return _linkifyHtml(value, options);
}
/**
* Linkify the given string and sanitize the HTML afterwards.
*
* @param dirtyString The string to linkify, and then sanitize.
* @param [options] Options for linkifyString. Default: linkifyMatrixOptions
* @returns HTML string
*/
export function linkifyAndSanitizeHtml(
dirtyHtml: string,
options = generateLinkedTextOptions(LinkedTextConfiguration),
): string {
return sanitizeHtml(linkifyString(dirtyHtml, options), sanitizeHtmlParams);
}
+52
View File
@@ -0,0 +1,52 @@
/*
Copyright 2024 New Vector Ltd.
Copyright 2021 The Matrix.org Foundation C.I.C.
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE files in the repository root for full details.
*/
import { type ClientWidgetApi } from "matrix-widget-api";
import { type 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,
widgetApi: ClientWidgetApi,
roomId: string,
): Promise<void> {
const streamId = await createLiveStream(matrixClient, roomId);
await widgetApi.transport.send(ElementWidgetActions.StartLiveStream, {
rtmpStreamKey: AUDIOSTREAM_DUMMY_URL + streamId,
});
}
+290
View File
@@ -0,0 +1,290 @@
/*
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 OR LicenseRef-Element-Commercial
Please see LICENSE files in the repository root for full details.
*/
import {
createClient,
type MatrixClient,
type LoginFlow,
DELEGATED_OIDC_COMPATIBILITY,
type ILoginFlow,
type LoginRequest,
type OidcClientConfig,
type ISSOFlow,
} from "matrix-js-sdk/src/matrix";
import { logger } from "matrix-js-sdk/src/logger";
import { type IMatrixClientCreds } from "./MatrixClientPeg";
import { ModuleRunner } from "./modules/ModuleRunner";
import { getOidcClientId } from "./utils/oidc/registerClient";
import { type 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("Failed to get oidc native flow", 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 as ISSOFlow),
);
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;
}
+390
View File
@@ -0,0 +1,390 @@
/*
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 OR LicenseRef-Element-Commercial
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 { findLinksInString } from "@element-hq/web-shared-components";
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 = findLinksInString(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 = findLinksInString(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");
};
// We inhibit the default escape function as we escape the entire output string to correctly handle backslashes
renderer.esc = (input: string) => input;
return escape(renderer.render(this.parsed));
}
}
+488
View File
@@ -0,0 +1,488 @@
/*
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 OR LicenseRef-Element-Commercial
Please see LICENSE files in the repository root for full details.
*/
import {
EventTimeline,
EventTimelineSet,
type ICreateClientOpts,
type IStartClientOpts,
type MatrixClient,
MemoryStore,
PendingEventOrdering,
type RoomNameState,
RoomNameType,
type 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 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 { setDeviceIsolationMode } from "./settings/controllers/DeviceIsolationModeController.ts";
import { initialiseDehydrationIfEnabled } from "./utils/device/dehydration";
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 {
return false;
}
}
public userRegisteredAfter(timestamp: Date): boolean {
try {
const registrationTime = parseInt(window.localStorage.getItem("mx_registration_time")!, 10);
return timestamp.getTime() <= registrationTime;
} catch {
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_user_status")) {
opts.unstableMSC4429SyncUserProfileFields = ["org.matrix.msc4426.status"];
}
if (SettingsStore.getValue("feature_sliding_sync")) {
throw new UserFriendlyError("sliding_sync_legacy_no_longer_supported");
}
// If the user has enabled the labs feature for sliding sync, set it up
// otherwise check if the feature is supported
if (SettingsStore.getValue("feature_simplified_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.");
}
await this.matrixClient.initRustCrypto({
storageKey: rustCryptoStoreKey,
storagePassword: rustCryptoStorePassword,
});
StorageManager.setCryptoInitialised(true);
setDeviceIsolationMode(this.matrixClient, SettingsStore.getValue("feature_exclude_insecure_devices"));
// Start dehydration. This code is only for the case where the client
// gets restarted, so we only do this if we already have the dehydration
// key cached, and we don't have to try to rehydrate a device. If this
// is a new login, we will start dehydration after Secret Storage is
// unlocked.
try {
await initialiseDehydrationIfEnabled(this.matrixClient, { onlyIfKeyCached: true, rehydrate: false });
} catch (e) {
// We may get an error dehydrating, such as if cross-signing and
// SSSS are not set up yet. Just log the error and continue.
// If SSSS gets set up later, we will re-try dehydration.
console.log("Error starting device dehydration", e);
}
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 },
enableEncryptedStateEvents: SettingsStore.getValue("feature_msc4362_encrypted_state_events"),
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
View File
@@ -0,0 +1,215 @@
/*
Copyright 2024 New Vector Ltd.
Copyright 2021 Šimon Brandner <simon.bra.ag@gmail.com>
Copyright 2017 Michael Telatynski <7t3chguy@gmail.com>
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
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);
}
}
+501
View File
@@ -0,0 +1,501 @@
/*
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 OR LicenseRef-Element-Commercial
Please see LICENSE files in the repository root for full details.
*/
import React, { StrictMode } from "react";
import { createRoot, type Root } from "react-dom/client";
import classNames from "classnames";
import { TypedEventEmitter } from "matrix-js-sdk/src/matrix";
import { Glass, TooltipProvider } from "@vector-im/compound-web";
import { I18nContext } from "@element-hq/web-shared-components";
import defaultDispatcher from "./dispatcher/dispatcher";
import AsyncWrapper from "./AsyncWrapper";
import { type Defaultize } from "./@types/common";
import { type ActionPayload } from "./dispatcher/payloads";
import { filterBoolean } from "./utils/arrays.ts";
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>;
/**
* The parameter types of the `onFinished` callback property exposed by the component which forms the
* body of the dialog.
*
* @typeParam C - The type of the React component which forms the body of the dialog.
*/
type OnFinishedParams<C extends ComponentType> = Parameters<React.ComponentProps<C>["onFinished"]>;
/**
* The properties exposed by the `props` argument to {@link Modal.createDialog}: the same as
* those exposed by the underlying component, with the exception of `onFinished`, which is provided by
* `createDialog`.
*
* @typeParam C - The type of the React component which forms the body of the dialog.
*/
export type ComponentProps<C extends ComponentType> = Defaultize<
Omit<React.ComponentProps<C>, "onFinished">,
C["defaultProps"]
>;
export interface IModal<C extends ComponentType> {
elem: React.ReactNode;
className?: string;
beforeClosePromise?: Promise<boolean>;
closeReason?: ModalCloseReason;
onBeforeClose?(reason?: ModalCloseReason): Promise<boolean>;
/**
* Run the {@link deferred} with the given arguments, and close this modal.
*
* This method is passed as the `onFinished` callback to the underlying component,
* as well as being returned by {@link Modal.createDialog} to the caller.
*/
close(...args: OnFinishedParams<C> | []): void;
hidden?: boolean;
/** A deferred to resolve when the dialog closes, with the results as provided by
* the call to {@link close} (normally from the `onFinished` callback).
*/
deferred?: PromiseWithResolvers<OnFinishedParams<C> | []>;
}
/** The result of {@link Modal.createDialog}.
*
* @typeParam C - The type of the React component which forms the body of the dialog.
*/
export interface IHandle<C extends ComponentType> {
/**
* A promise which will resolve when the dialog closes.
*
* If the dialog body component calls the `onFinished` property, or the caller calls {@link close},
* the promise resolves with an array holding the arguments to that call.
*
* If the dialog is closed by clicking in the background, the promise resolves with an empty array.
*/
finished: Promise<OnFinishedParams<C> | []>;
/**
* A function which, if called, will close the dialog.
*
* @param args - Arguments to return to {@link finished}.
*/
close(this: void, ...args: OnFinishedParams<C>): 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";
function getOrCreateContainer(id: string): HTMLDivElement {
let container = document.getElementById(id) as HTMLDivElement | null;
if (!container) {
container = document.createElement("div");
container.id = id;
document.body.appendChild(container);
}
return container;
}
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 root?: Root;
private static getOrCreateRoot(): Root {
if (!ModalManager.root) {
const container = getOrCreateContainer(DIALOG_CONTAINER_ID);
ModalManager.root = createRoot(container);
}
return ModalManager.root;
}
private static staticRoot?: Root;
private static getOrCreateStaticRoot(): Root {
if (!ModalManager.staticRoot) {
const container = getOrCreateContainer(STATIC_DIALOG_CONTAINER_ID);
ModalManager.staticRoot = createRoot(container);
}
return ModalManager.staticRoot;
}
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;
}
/**
* 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 {
const modals = filterBoolean([...this.modals, this.staticModal, this.priorityModal]);
for (const modal of modals) {
modal.deferred?.resolve([]);
this.emitClosed();
}
this.modals = [];
this.staticModal = null;
this.priorityModal = null;
this.reRender();
}
/**
* @typeParam C - the component type
*/
private buildModal<C extends ComponentType>(
Component: C,
props?: ComponentProps<C>,
className?: string,
options?: IOptions<C>,
): {
modal: IModal<C>;
closeDialog: IHandle<C>["close"];
onFinishedProm: IHandle<C>["finished"];
} {
const modal = {
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>;
const [closeDialog, onFinishedProm] = this.getCloseFn<C>(modal);
// don't attempt to reuse the same AsyncWrapper for different dialogs,
// otherwise we'll get confused.
const modalCount = this.counter++;
// Typescript doesn't like us passing props as any here, but we know that they are well typed due to the rigorous generics.
modal.elem = (
<AsyncWrapper key={modalCount} onFinished={closeDialog}>
<Component {...(props as any)} onFinished={closeDialog} />
</AsyncWrapper>
);
modal.close = closeDialog;
return { modal, closeDialog, onFinishedProm };
}
private getCloseFn<C extends ComponentType>(modal: IModal<C>): [IHandle<C>["close"], IHandle<C>["finished"]] {
modal.deferred = Promise.withResolvers<OnFinishedParams<C> | []>();
return [
async (...args: OnFinishedParams<C>): 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);
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 component The component to render as a dialog. This component must accept an `onFinished` prop function as
* per the type {@link ComponentType}. If loading a component with esoteric dependencies consider
* using React.lazy to async load the component.
* e.g. `lazy(() => import('./MyComponent'))`
*
* @param props properties to pass to the displayed component. (We will also pass an `onFinished` property; when
* called, that property will close the dialog and return the results to the caller via {@link IHandle.finished}.)
*
* @param className CSS class to apply to the modal wrapper
*
* @param 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 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 options? extra options for the dialog
* @param options.onBeforeClose a callback to decide whether to close the dialog
* @returns {@link IHandle} object.
*/
public createDialog<C extends ComponentType>(
component: 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>(component, 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,
};
}
public appendDialog<C extends ComponentType>(
component: C,
props?: ComponentProps<C>,
className?: string,
): IHandle<C> {
const beforeModal = this.getCurrentModal();
const { modal, closeDialog, onFinishedProm } = this.buildModal<C>(component, 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> {
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
defaultDispatcher.dispatch({
action: "aria_unhide_main_app",
});
ModalManager.getOrCreateRoot().render(<></>);
ModalManager.getOrCreateStaticRoot().render(<></>);
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
defaultDispatcher.dispatch({
action: "aria_hide_main_app",
});
if (this.staticModal) {
const classes = classNames("mx_Dialog_wrapper mx_Dialog_staticWrapper", this.staticModal.className);
const staticDialog = (
<StrictMode>
{/* Provide I18nContext for shared-components used inside dialogs rendered in a separate root. */}
<I18nContext.Provider value={window.mxModuleApi.i18n}>
<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>
</I18nContext.Provider>
</StrictMode>
);
ModalManager.getOrCreateStaticRoot().render(staticDialog);
} else {
// This is safe to call repeatedly if we happen to do that
ModalManager.getOrCreateStaticRoot().render(<></>);
}
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 = (
<StrictMode>
{/* Provide I18nContext for shared-components used inside dialogs rendered in a separate root. */}
<I18nContext.Provider value={window.mxModuleApi.i18n}>
<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>
</I18nContext.Provider>
</StrictMode>
);
ModalManager.getOrCreateRoot().render(dialog);
} else {
// This is safe to call repeatedly if we happen to do that
ModalManager.getOrCreateRoot().render(<></>);
}
}
}
if (!window.singletonModalManager) {
window.singletonModalManager = new ModalManager();
}
export default window.singletonModalManager;
+131
View File
@@ -0,0 +1,131 @@
/*
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 OR LicenseRef-Element-Commercial
Please see LICENSE files in the repository root for full details.
*/
import React, { type Key, type RefObject, type ReactElement, type RefCallback, type HTMLAttributes } from "react";
interface IChildProps {
style: React.CSSProperties;
ref: RefCallback<HTMLElement>;
}
interface IProps {
// either a list of child nodes, or a single child.
children: React.ReactNode;
// a list of state objects to apply to each child node in turn
startStyles: React.CSSProperties[];
innerRef?: RefObject<any>;
}
function isReactElement(c: ReturnType<(typeof React.Children)["toArray"]>[number]): 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, HTMLElement> = {};
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 {
if (!styles) return;
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;
const props = c.props as HTMLAttributes<HTMLElement>;
if (oldChildren[c.key!]) {
const old = oldChildren[c.key!];
const oldNode = this.nodes[old.key!];
if (oldNode && props.style && oldNode.style.left !== props.style.left) {
this.applyStyles(oldNode, { left: 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, props, 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 = 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, domNode: HTMLElement | null, restingStyle?: React.CSSProperties): void {
const key = typeof k === "bigint" ? Number(k) : k;
if (domNode && this.nodes[key] === undefined && this.props.startStyles.length > 0) {
const startStyles = this.props.startStyles;
// 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, startStyles[i]);
}
// and then we animate to the resting state
window.setTimeout(() => {
this.applyStyles(domNode, restingStyle);
}, 0);
}
if (domNode) {
this.nodes[key] = domNode;
} else {
delete this.nodes[key];
}
if (this.props.innerRef) {
this.props.innerRef.current = domNode;
}
}
public render(): React.ReactNode {
return <>{Object.values(this.children)}</>;
}
}
+643
View File
@@ -0,0 +1,643 @@
/*
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 OR LicenseRef-Element-Commercial
Please see LICENSE files in the repository root for full details.
*/
import {
MatrixEvent,
MatrixEventEvent,
type Room,
RoomEvent,
ClientEvent,
MsgType,
SyncState,
type SyncStateData,
type IRoomTimelineData,
M_LOCATION,
EventType,
TypedEventEmitter,
} from "matrix-js-sdk/src/matrix";
import { logger } from "matrix-js-sdk/src/logger";
import { type PermissionChanged as PermissionChangedEvent } from "@matrix-org/analytics-events/types/typescript/PermissionChanged";
import { type SessionMembershipData, type IRTCNotificationContent } 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 { SdkContextClass } from "./contexts/SDKContext";
import { localNotificationsAreSilenced, createLocalNotificationSettingsIfNeeded } from "./utils/notifications";
import { getIncomingCallToastKey, getNotificationEventSendTs, IncomingCallToast } from "./toasts/IncomingCallToast";
import ToastStore from "./stores/ToastStore";
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 => {
return TextForEvent.textForEvent(event, MatrixClientPeg.safeGet());
},
};
/**
* Extracts plain text from a message body, replacing any spoilered content
* with '[Spoiler]' to prevent spoilers in desktop notifications.
*/
function getNotificationBodyWithoutSpoilers(ev: MatrixEvent): string {
const content = ev.getContent();
const plainBody = content.body ?? "";
const formattedBody = content.formatted_body;
if (typeof formattedBody !== "string" || !formattedBody.length) {
return plainBody;
}
/** Recursively walks HTML tree to hide spoilers. */
function replaceSpoilers(node: Node): Node {
if (node.nodeType !== Node.ELEMENT_NODE || !(node instanceof Element)) {
return node;
}
if (node.hasAttribute("data-mx-spoiler")) {
const e = document.createElement("span");
e.appendChild(document.createTextNode("[Spoiler]"));
return e;
}
for (const childNode of node.childNodes) {
node.replaceChild(replaceSpoilers(childNode), childNode);
}
return node;
}
try {
// Dev note: ideally we would reuse more of the existing rendering stack
// rather than re-parsing and updating the generated HTML here. However,
// that rendering stack is currently quite consolidated and cannot
// easily be refactored to allow the call-site to control how spoilers
// are rendered. The problem is that we now need two different output
// formats:
// - The existing format where spoilers are wrapped in html <span> tags
// - The new format where the spoilered text is replaced with [Spoiler]
const parser = new DOMParser();
const doc = parser.parseFromString(formattedBody, "text/html");
// Use textContent rather than innerHTML/outerHTML since textContent is
// XSS-safe and the input is untrusted.
return replaceSpoilers(doc.body).textContent ?? plainBody;
} catch {
return plainBody;
}
}
export const enum NotifierEvent {
NotificationHiddenChange = "notification_hidden_change",
}
interface EmittedEvents {
[NotifierEvent.NotificationHiddenChange]: (hidden: boolean) => void;
}
/**
* Type representing a notification sound setting
*/
export type NotificationSound = {
url: string;
name?: string;
type?: string;
size?: number;
};
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(getNotificationBodyWithoutSpoilers(ev));
}
} 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(getNotificationBodyWithoutSpoilers(ev));
}
}
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): NotificationSound | 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?.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.
if (this.notifsByRoom[room.roomId] === undefined) return;
for (const notif of this.notifsByRoom[room.roomId]) {
notif.close();
}
delete this.notifsByRoom[room.roomId];
}
};
// XXX: exported for tests
public evaluateEvent(ev: MatrixEvent): void {
const roomId = ev.getRoomId()!;
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);
}
}
}
/**
* Handle `EventType.RTCNotification` notifications.
* @param ev The notification event.
* @param toaster The toast store.
* @param room The room that contains the notification
* @returns A promise that will always resolve.
*/
private async handleRTCNotification(ev: MatrixEvent, toaster: ToastStore, room: Room): Promise<void> {
// TODO: Use the call_id to get the *correct* call. We assume there is only one call per room here.
const rtcSession = room && room.client.matrixRTC.getRoomSession(room);
if (
rtcSession?.slotDescription?.application == "m.call" &&
rtcSession.memberships.some((membership) => membership.userId === room.client.getUserId())
) {
// If we're already joined to the session, don't notify.
return;
}
// XXX: Should use parseCallNotificationContent once the types are exported.
const content = ev.getContent() as IRTCNotificationContent;
const roomId = ev.getRoomId();
const referencedMembershipEventId = ev.getRelation()?.event_id;
// Check maximum age of a call notification event that will trigger a ringing notification
if (Date.now() - getNotificationEventSendTs(ev) > content.lifetime) {
logger.warn("Received outdated RTCNotification event.");
return;
}
if (!roomId) {
logger.warn("Could not get roomId for RTCNotification event");
return;
}
if (!referencedMembershipEventId) {
logger.warn("Could not get referenced membership for notification");
return;
}
if (content["m.relates_to"].rel_type !== "m.reference") {
logger.warn("Ignored RTCNotification due to invalid rel_type");
return;
}
let callMembership = room?.findEventById(referencedMembershipEventId);
if (!callMembership) {
// Attempt to fetch from the homeserver, if we do not have the event locally.
// This is a rare case as obviously the referenced event for a m.call notification must
// be sent first.
try {
callMembership = new MatrixEvent(await room.client.fetchRoomEvent(roomId, referencedMembershipEventId));
} catch (ex) {
logger.warn(`Call membership for notification could not be found`, ex);
}
}
// If the event could not be found even after requesting it from the homeserver.
if (!callMembership) {
// We will not show a call notification if there is no valid call membership.
logger.warn(
`Could not find call membership (${referencedMembershipEventId} ${roomId}) for notification event.`,
);
return;
}
// If we cannot determine the key, we'll accept it but assume it's empty string.
// This means if you have malformed notifications or call memberships your notifications
// will overwrite, but the solution to that is to use well-formed events.
const callId = callMembership.getContent<SessionMembershipData>().call_id ?? "";
const key = getIncomingCallToastKey(callId, roomId);
if (toaster.hasToast(key)) {
logger.debug(`Detected duplicate notification for call ${key}, ignoring`);
return;
}
toaster.addOrReplaceToast({
key,
priority: 100,
component: IncomingCallToast,
bodyClassName: "mx_IncomingCallToast",
props: { notificationEvent: ev },
});
}
/**
* Some events require special handling such as showing in-app toasts.
* This function may either create a toast or ignore the event based
* on current app state.
*/
private performCustomEventHandling(ev: MatrixEvent): void {
const toaster = ToastStore.sharedInstance();
const cli = MatrixClientPeg.safeGet();
const room = cli.getRoom(ev.getRoomId());
if (room && EventType.RTCNotification === ev.getType()) {
// We don't need to await this.
void this.handleRTCNotification(ev, toaster, room);
}
}
}
if (!window.mxNotifier) {
window.mxNotifier = new NotifierClass();
}
export default window.mxNotifier;
export const Notifier: NotifierClass = window.mxNotifier;
+17
View File
@@ -0,0 +1,17 @@
/*
Copyright 2024 New Vector Ltd.
Copyright 2017 Vector Creations Ltd
Copyright 2015, 2016 OpenMarket Ltd
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
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
View File
@@ -0,0 +1,108 @@
/*
Copyright 2024 New Vector Ltd.
Copyright 2019-2022 The Matrix.org Foundation C.I.C.
Copyright 2015, 2016 OpenMarket Ltd
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE files in the repository root for full details.
*/
import { createClient, type IRequestTokenResponse, type 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;
}
}
}
+55
View File
@@ -0,0 +1,55 @@
/*
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 OR LicenseRef-Element-Commercial
Please see LICENSE files in the repository root for full details.
*/
import type BasePlatform from "./BasePlatform";
/*
* 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;
private platformPromiseWithResolvers = Promise.withResolvers<BasePlatform>();
/**
* Returns a promise for the Platform object for the application.
*/
public get platformPromise(): Promise<BasePlatform> {
return this.platformPromiseWithResolvers.promise;
}
/**
* 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.platformPromiseWithResolvers.resolve(platform);
this.platform = platform;
}
}
if (!window.mxPlatformPeg) {
window.mxPlatformPeg = new PlatformPeg();
}
export default window.mxPlatformPeg;
+25
View File
@@ -0,0 +1,25 @@
/*
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 OR LicenseRef-Element-Commercial
Please see LICENSE files in the repository root for full details.
*/
import { type Request, type 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);
}
}
+455
View File
@@ -0,0 +1,455 @@
/*
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 OR LicenseRef-Element-Commercial
Please see LICENSE files in the repository root for full details.
*/
import posthog, { type CaptureOptions, type PostHog, type Properties } from "posthog-js";
import { type MatrixClient } from "matrix-js-sdk/src/matrix";
import { logger } from "matrix-js-sdk/src/logger";
import { type UserProperties } from "@matrix-org/analytics-events/types/typescript/UserProperties";
import { type 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 { type ScreenName } from "./PosthogTrackers";
import { type ActionPayload } from "./dispatcher/payloads";
import { Action } from "./dispatcher/actions";
import { isSettingUpdatedPayload, type 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);
SettingsStore.monitorSetting("urlPreviewsEnabled", null);
this.onLayoutUpdated();
this.onUrlPreviewSettingUpdated(SettingsStore.getValue("urlPreviewsEnabled"));
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 readonly onUrlPreviewSettingUpdated = (value: boolean): void => {
this.setProperty("URLPreviewsEnabled", value);
};
private onAction = (payload: ActionPayload): void => {
if (payload.action !== Action.SettingUpdated) return;
const settingsPayload = payload as SettingUpdatedPayload;
if (["layout", "useCompactLayout"].includes(settingsPayload.settingName)) {
this.onLayoutUpdated();
} else if (isSettingUpdatedPayload(settingsPayload, "urlPreviewsEnabled") && !settingsPayload.roomId) {
this.onUrlPreviewSettingUpdated(settingsPayload.newValue);
}
};
// 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 {
// 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, {
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();
}
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,
);
}
}
+186
View File
@@ -0,0 +1,186 @@
/*
Copyright 2026 Element Creations Ltd.
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 OR LicenseRef-Element-Commercial
Please see LICENSE files in the repository root for full details.
*/
import { PureComponent, type SyntheticEvent } from "react";
import { type WebScreen as ScreenEvent } from "@matrix-org/analytics-events/types/typescript/WebScreen";
import { type Interaction as InteractionEvent } from "@matrix-org/analytics-events/types/typescript/Interaction";
import { type PinUnpinAction } from "@matrix-org/analytics-events/types/typescript/PinUnpinAction";
import { type RoomListSortingAlgorithmChanged } from "@matrix-org/analytics-events/types/typescript/RoomListSortingAlgorithmChanged";
import { type UrlPreviewRendered } from "@matrix-org/analytics-events/types/typescript/UrlPreviewRendered";
import PageType from "./PageTypes";
import Views from "./Views";
import { PosthogAnalytics } from "./PosthogAnalytics";
import { SortingAlgorithm } from "./stores/room-list-v3/skip-list/sorters";
import { LruCache } from "./utils/LruCache";
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.FORGOT_PASSWORD]: "ForgotPassword",
[Views.COMPLETE_SECURITY]: "CompleteSecurity",
[Views.E2E_SETUP]: "E2ESetup",
[Views.PENDING_CLIENT_START]: "Loading",
[Views.SOFT_LOGOUT]: "SoftLogout",
[Views.LOCK_STOLEN]: "SessionLockStolen",
};
const loggedInPageTypeMap: Record<PageType | string, ScreenName> = {
[PageType.HomePage]: "Home",
[PageType.RoomView]: "Room",
[PageType.UserView]: "User",
};
const SortingAlgorithmMap: Record<SortingAlgorithm, RoomListSortingAlgorithmChanged["newAlgorithm"]> = {
[SortingAlgorithm.Recency]: "Activity",
[SortingAlgorithm.Unread]: "Unread",
[SortingAlgorithm.Alphabetic]: "Alphabetic",
};
export default class PosthogTrackers {
private static internalInstance: PosthogTrackers;
private readonly previewedEventIds = new LruCache<string, true>(1000);
public static get instance(): PosthogTrackers {
if (!PosthogTrackers.internalInstance) {
PosthogTrackers.internalInstance = new PosthogTrackers();
}
return PosthogTrackers.internalInstance;
}
private view: Views = Views.LOADING;
private pageType?: PageType | string;
private override?: ScreenName;
public trackPageChange(view: Views, pageType: PageType | string | 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,
});
}
/**
* Track when the user chooses a different sorting algorithm for the room-list.
* @param oldAlgorithm - The old algorithm.
* @param newAlgorithm - The new algorithm.
*/
public static trackRoomListSortingAlgorithmChange(
oldAlgorithm: SortingAlgorithm,
newAlgorithm: SortingAlgorithm,
): void {
PosthogAnalytics.instance.trackEvent<RoomListSortingAlgorithmChanged>({
eventName: "RoomListSortingAlgorithmChanged",
oldAlgorithm: SortingAlgorithmMap[oldAlgorithm],
newAlgorithm: SortingAlgorithmMap[newAlgorithm],
});
}
/**
* Track if an event has had a previewed rendered in the client.
* This function makes a best-effort attempt to prevent double counting.
*
* @param eventId EventID for deduplication.
* @param isEncrypted Whether the event (and effectively the room) was encrypted.
* @param previews The previews generated from the event.
*/
public trackUrlPreview(eventId: string, isEncrypted: boolean, previews: { image?: unknown }[]): void {
// Discount any previews that we have already tracked.
if (this.previewedEventIds.get(eventId)) {
return;
}
PosthogAnalytics.instance.trackEvent<UrlPreviewRendered>({
eventName: "UrlPreviewRendered",
previewKind: "LegacyCard",
hasThumbnail: previews.some((p) => !!p.image),
previewCount: previews.length,
encryptedRoom: isEncrypted,
});
this.previewedEventIds.set(eventId, true);
}
}
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
}
}
+98
View File
@@ -0,0 +1,98 @@
/*
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 OR LicenseRef-Element-Commercial
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 { type ActionPayload } from "./dispatcher/payloads";
import { Action } from "./dispatcher/actions.ts";
// 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;
private dispatcherRef?: string;
private state?: SetPresence;
/**
* 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 {
/* aborted, stop got called */
}
}
}
/**
* Stop tracking user activity
*/
public stop(): void {
dis.unregister(this.dispatcherRef);
this.dispatcherRef = undefined;
this.unavailableTimer?.abort();
this.unavailableTimer = undefined;
}
/**
* Get the current presence state.
* @returns {string} the presence state (see PRESENCE enum)
*/
public getState(): SetPresence | null {
return this.state ?? null;
}
private onAction = (payload: ActionPayload): void => {
if (payload.action === Action.UserActivity) {
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
View File
@@ -0,0 +1,75 @@
/*
Copyright 2018-2024 New Vector Ltd.
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
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>,
]
: [],
});
modal.finished.then(([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
View File
@@ -0,0 +1,58 @@
/*
Copyright 2024 New Vector Ltd.
Copyright 2015-2021 The Matrix.org Foundation C.I.C.
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE files in the repository root for full details.
*/
import { type MatrixEvent, EventStatus, type Room, type 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
View File
@@ -0,0 +1,28 @@
/*
Copyright 2024 New Vector Ltd.
Copyright 2017 Vector Creations Ltd
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
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 });
}
}
+29
View File
@@ -0,0 +1,29 @@
/*
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 OR LicenseRef-Element-Commercial
Please see LICENSE files in the repository root for full details.
*/
type CacheResult = { roomId: string; viaServers: string[] };
/**
* 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 cache = new Map<string, CacheResult>();
export function storeRoomAliasInCache(alias: string, roomId: string, viaServers: string[]): void {
cache.set(alias, { roomId, viaServers });
}
export function getCachedRoomIdForAlias(alias: string): CacheResult | undefined {
return cache.get(alias);
}
+148
View File
@@ -0,0 +1,148 @@
/*
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 OR LicenseRef-Element-Commercial
Please see LICENSE files in the repository root for full details.
*/
import React, { type ComponentProps } from "react";
import { EventType, type MatrixEvent, type Room, type User } from "matrix-js-sdk/src/matrix";
import type MultiInviter from "./utils/MultiInviter";
import { type 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 { type Member } from "./utils/direct-messages";
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 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;
}
+320
View File
@@ -0,0 +1,320 @@
/*
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 OR LicenseRef-Element-Commercial
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,
EventStatus,
} from "matrix-js-sdk/src/matrix";
import type { IPushRule, Room, MatrixClient, MatrixEvent } from "matrix-js-sdk/src/matrix";
import { NotificationLevel } from "./stores/notifications/NotificationLevel";
import { doesRoomHaveUnreadMessages, doesRoomOrThreadHaveUnreadMessages } from "./Unread";
import { EffectiveMembership, getEffectiveMembership, isKnockDenied } from "./utils/membership";
import SettingsStore from "./settings/SettingsStore";
import { getMarkedUnreadState } from "./utils/notifications";
/**
* Gets all pending events in a room that have a status of `EventStatus.NOT_SENT`
* and belong to the current thread, if specified.
* @param room The room to check.
* @param threadId The thread to check. If not specified, no thread filtering is performed.
* @returns An array of unsent matrix events.
*/
export function getUnsentMessages(room: Room, threadId?: string): MatrixEvent[] {
if (!room) {
return [];
}
return room.getPendingEvents().filter(function (ev) {
if (ev.status !== EventStatus.NOT_SENT) {
return false;
}
if (threadId && threadId !== ev.threadRootId) {
return false;
}
return true;
});
}
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 {
// 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; invited: boolean } {
if (!room) {
return { symbol: null, count: 0, level: NotificationLevel.None, invited: false };
}
if (getUnsentMessages(room, threadId).length > 0) {
return { symbol: "!", count: 1, level: NotificationLevel.Unsent, invited: false };
}
if (getEffectiveMembership(room.getMyMembership()) === EffectiveMembership.Invite) {
return { symbol: "!", count: 1, level: NotificationLevel.Highlight, invited: true };
}
if (SettingsStore.getValue("feature_ask_to_join") && isKnockDenied(room)) {
return { symbol: "!", count: 1, level: NotificationLevel.Highlight, invited: false };
}
if (getRoomNotifsState(room.client, room.roomId) === RoomNotifState.Mute) {
return { symbol: null, count: 0, level: NotificationLevel.None, invited: false };
}
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, invited: false };
}
const markedUnreadState = getMarkedUnreadState(room);
if (greyNotifs > 0 || markedUnreadState) {
return { symbol: null, count: trueCount, level: NotificationLevel.Notification, invited: false };
}
// 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,
invited: false,
};
}
+114
View File
@@ -0,0 +1,114 @@
/*
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 OR LicenseRef-Element-Commercial
Please see LICENSE files in the repository root for full details.
*/
import { type Room, EventType, type RoomMember, type MatrixClient } from "matrix-js-sdk/src/matrix";
import AliasCustomisations from "./customisations/Alias";
import { filterValidMDirect } from "./utils/dm/filterValidMDirect.ts";
/**
* 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 { filteredContent } = filterValidMDirect(mDirectEvent?.getContent() ?? {});
// remove it from the lists of all users (it can only be a DM room for one person)
for (const thisUserId in filteredContent) {
if (!filteredContent[thisUserId]) continue;
filteredContent[thisUserId] = filteredContent[thisUserId].filter((room) => room !== roomId);
}
// now add it if the caller asked for it to be a DM room
if (userId) {
if (!filteredContent[userId]) {
filteredContent[userId] = [];
}
filteredContent[userId].push(roomId);
}
await client.setAccountData(EventType.Direct, filteredContent);
}
/**
* 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
View File
@@ -0,0 +1,273 @@
/*
Copyright 2024 New Vector Ltd.
Copyright 2016-2019 , 2021 The Matrix.org Foundation C.I.C.
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE files in the repository root for full details.
*/
import { logger } from "matrix-js-sdk/src/logger";
import { SERVICE_TYPES, type Room, type IOpenIDToken } from "matrix-js-sdk/src/matrix";
import SettingsStore from "./settings/SettingsStore";
import { Service, startTermsFlow, type TermsInteractionCallback, TermsNotSignedError } from "./Terms";
import { MatrixClientPeg } from "./MatrixClientPeg";
import SdkConfig from "./SdkConfig";
import { type 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
View File
@@ -0,0 +1,993 @@
/*
Copyright 2018-2024 New Vector Ltd.
Copyright 2017 Vector Creations Ltd
Copyright 2016 OpenMarket Ltd
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
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 { type IContent, type MatrixEvent, type IEvent, type 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);
}
async function getRoomEncState(event: MessageEvent<any>, roomId: string): Promise<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 = Boolean(await client.getCrypto()?.isEncryptionEnabledInRoom(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 {
// No integrations UI URL, ignore silently.
return;
}
let eventOriginUrl: URL;
try {
eventOriginUrl = new URL(event.origin);
} catch {
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);
}
}
+147
View File
@@ -0,0 +1,147 @@
/*
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 OR LicenseRef-Element-Commercial
Please see LICENSE files in the repository root for full details.
*/
import { mergeWith } from "lodash";
import { SnakedObject } from "./utils/SnakedObject";
import { type IConfigOptions } from "./IConfigOptions";
import { isObject, objectClone } from "./utils/objects";
import { type DeepPartial, type DeepReadonly, type 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",
branding: {
logo_link_url: "https://element.io",
auth_header_logo_url: "themes/element/img/logos/element-logo.svg",
welcome_background_url: "themes/element/img/backgrounds/lake.jpg",
},
help_url: "https://element.io/help",
help_encryption_url: "https://element.io/help#encryption",
help_key_storage_url: "https://element.io/help#encryption5",
integrations_ui_url: "https://scalar.vector.im/",
integrations_rest_url: "https://scalar.vector.im/api",
show_labs_settings: false,
force_verification: false,
jitsi: {
preferred_domain: "meet.element.io",
},
element_call: {
use_exclusively: false,
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,
// eslint-disable-next-line @typescript-eslint/no-require-imports
logo: require("../res/img/element-desktop-logo.svg").default,
url: "https://element.io/get-started",
},
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_win64arm: "https://packages.element.io/desktop/install/win32/arm64/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<DeepPartial<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]>>
: SnakedObject<NonNullable<IConfigOptions[K]>> | null | undefined;
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: DeepPartial<ConfigOptions>): void {
SdkConfig.put(mergeConfig(SdkConfig.get(), cfg));
}
}
+745
View File
@@ -0,0 +1,745 @@
/*
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 OR LicenseRef-Element-Commercial
Please see LICENSE files in the repository root for full details.
*/
import {
type IResultRoomEvents,
type ISearchRequestBody,
type ISearchResponse,
type ISearchResult,
type ISearchResults,
SearchOrderBy,
type IRoomEventFilter,
EventType,
type MatrixClient,
type SearchResult,
} from "matrix-js-sdk/src/matrix";
import { type 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");
}
// Fix state_key: null issue - Seshat includes "state_key": null for non-state events,
// which causes matrix-js-sdk to incorrectly treat them as state events
if (localResult.results) {
for (const searchResult of localResult.results) {
const event = searchResult.result as unknown as Record<string, unknown>;
if (event?.state_key === null) delete event.state_key;
// Also fix context events
if (searchResult.context) {
for (const ctxEvent of searchResult.context.events_before || []) {
const ev = ctxEvent as unknown as Record<string, unknown>;
if (ev?.state_key === null) delete ev.state_key;
}
for (const ctxEvent of searchResult.context.events_after || []) {
const ev = ctxEvent as unknown as Record<string, unknown>;
if (ev?.state_key === null) delete ev.state_key;
}
}
}
}
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;
}
async function eventIndexSearch(
client: MatrixClient,
term: string,
roomId?: string,
abortSignal?: AbortSignal,
): Promise<ISearchResults> {
let searchPromise: Promise<ISearchResults>;
if (roomId !== undefined) {
if (await client.getCrypto()?.isEncryptionEnabledInRoom(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;
/**
* Describe the error if any occured.
*/
error?: Error;
}
+274
View File
@@ -0,0 +1,274 @@
/*
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 OR LicenseRef-Element-Commercial
Please see LICENSE files in the repository root for full details.
*/
import { lazy } from "react";
import { type SecretStorage } from "matrix-js-sdk/src/matrix";
import { deriveRecoveryKeyFromPassphrase, decodeRecoveryKey, type CryptoCallbacks } from "matrix-js-sdk/src/crypto-api";
import { logger as rootLogger } from "matrix-js-sdk/src/logger";
import Modal from "./Modal";
import { MatrixClientPeg } from "./MatrixClientPeg";
import { _t } from "./languageHandler";
import AccessSecretStorageDialog, {
type KeyParams,
} from "./components/views/dialogs/security/AccessSecretStorageDialog";
import { ModuleRunner } from "./modules/ModuleRunner";
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<ArrayBuffer>> = {};
let secretStorageKeyInfo: Record<string, SecretStorage.SecretStorageKeyDescription> = {};
let secretStorageBeingAccessed = false;
const logger = rootLogger.getChild("SecurityManager:");
/**
* 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");
}
}
function makeInputToKey(
keyInfo: SecretStorage.SecretStorageKeyDescription,
): (keyParams: KeyParams) => Promise<Uint8Array<ArrayBuffer>> {
return async ({ passphrase, recoveryKey }): Promise<Uint8Array<ArrayBuffer>> => {
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>;
},
secretName: string,
): Promise<[string, Uint8Array<ArrayBuffer>]> {
const cli = MatrixClientPeg.safeGet();
const defaultKeyId = await cli.secretStorage.getDefaultKeyId();
let keyId: string;
// If the defaultKey is useful, use that
if (defaultKeyId && keyInfos[defaultKeyId]) {
keyId = defaultKeyId;
} else {
// Fall back to a heuristic of using the
// only available key, if only one key is set
const usefulKeys = Object.keys(keyInfos);
if (usefulKeys.length > 1) {
throw new Error("Multiple storage key requests not implemented");
}
keyId = usefulKeys[0];
}
const keyInfo = keyInfos[keyId];
logger.debug(
`getSecretStorageKey: request for 4S keys [${Object.keys(keyInfos)}] for secret \`${secretName}\`: 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.debug("getSecretStorageKey: Using secret storage key from CryptoSetupExtension");
cacheSecretStorageKey(keyId, keyInfo, keyFromCustomisations);
return [keyId, keyFromCustomisations];
}
// We only prompt the user for the default key
if (keyId !== defaultKeyId) {
logger.debug(`getSecretStorageKey: request for non-default key ${keyId}: not prompting user`);
throw new Error("Request for non-default 4S key");
}
logger.debug(`getSecretStorageKey: prompting user for key ${keyId}`);
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);
},
},
);
const [keyParams] = await finished;
if (!keyParams) {
throw new AccessCancelledError();
}
logger.debug(`getSecretStorageKey: got key ${keyId} 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<ArrayBuffer>,
): void {
if (secretStorageBeingAccessed) {
logger.debug(`Caching 4S key ${keyId}`);
secretStorageKeys[keyId] = key;
secretStorageKeyInfo[keyId] = keyInfo;
}
}
export const crossSigningCallbacks: CryptoCallbacks = {
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("enabling 4S key cache");
secretStorageBeingAccessed = true;
try {
return await func();
} finally {
// Clear secret storage key cache now that work is complete
logger.debug("disabling 4S key cache");
secretStorageBeingAccessed = false;
secretStorageKeys = {};
secretStorageKeyInfo = {};
}
}
export interface AccessSecretStorageOpts {
/**
* Reset secret storage even if it's already set up.
* @deprecated send the user to the Encryption settings tab to reset secret storage
*/
forceReset?: boolean;
}
/**
* 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. (Only if `opts.forceReset` is set) 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.
*
* Throws an error if secret storage is not set up (and `opts.forceReset` is not set)
*
* @param {Function} [func] An operation to perform once secret storage has been
* bootstrapped. Optional.
* @param [opts] The options to use when accessing secret storage.
*/
export async function accessSecretStorage(
func = async (): Promise<void> => {},
opts: AccessSecretStorageOpts = {},
): Promise<void> {
await withSecretStorageKeyCache(() => doAccessSecretStorage(func, opts));
}
/** Helper for {@link #accessSecretStorage} */
async function doAccessSecretStorage(func: () => Promise<void>, opts: AccessSecretStorageOpts): 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.");
}
if (opts.forceReset) {
logger.debug("accessSecretStorage: resetting 4S");
// This dialog calls bootstrap itself after guiding the user through
// passphrase creation.
const { finished } = Modal.createDialog(
lazy(() => import("./async-components/views/dialogs/security/CreateSecretStorageDialog")),
opts,
undefined,
/* priority = */ false,
/* static = */ true,
);
const [confirmed] = await finished;
if (!confirmed) {
throw new Error("Secret storage creation canceled");
}
} else if (!(await cli.secretStorage.hasKey())) {
logger.debug("accessSecretStorage: no 4S key configured");
throw new Error("Secret storage has not been created yet.");
} 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
View File
@@ -0,0 +1,67 @@
/*
Copyright 2024 New Vector Ltd.
Copyright 2017 Aviral Dasgupta
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE files in the repository root for full details.
*/
import { clamp } from "lodash";
import { type MatrixEvent } from "matrix-js-sdk/src/matrix";
import { logger } from "matrix-js-sdk/src/logger";
import { type SerializedPart } from "./editor/parts";
import type 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];
}
}
+420
View File
@@ -0,0 +1,420 @@
/*
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 OR LicenseRef-Element-Commercial
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 { type MatrixClient, ClientEvent, EventType, type Room } from "matrix-js-sdk/src/matrix";
import {
type MSC3575Filter,
type MSC3575List,
type MSC3575SlidingSyncResponse,
MSC3575_STATE_KEY_LAZY,
MSC3575_STATE_KEY_ME,
MSC3575_WILDCARD,
SlidingSync,
SlidingSyncEvent,
SlidingSyncState,
} from "matrix-js-sdk/src/sliding-sync";
import { logger } from "matrix-js-sdk/src/logger";
import { sleep } from "matrix-js-sdk/src/utils";
// how long to long poll for
const SLIDING_SYNC_TIMEOUT_MS = 20 * 1000;
// The state events we will get for every single room/space/old room/etc
// This list is only augmented when a direct room subscription is made. (e.g you view a room)
const REQUIRED_STATE_LIST = [
[EventType.RoomJoinRules, ""], // the public icon on the room list
[EventType.RoomAvatar, ""], // any room avatar
[EventType.RoomCanonicalAlias, ""], // for room name calculations
[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
];
// 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: REQUIRED_STATE_LIST,
},
};
// lazy load room members so rooms like Matrix HQ don't take forever to load
const UNENCRYPTED_SUBSCRIPTION_NAME = "unencrypted";
const UNENCRYPTED_SUBSCRIPTION = {
required_state: [
[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 = {
required_state: [
[MSC3575_WILDCARD, MSC3575_WILDCARD], // all events
],
...DEFAULT_ROOM_SUBSCRIPTION_INFO,
};
// the complete set of lists made in SSS. The manager will spider all of these lists depending
// on the count for each one.
const sssLists: Record<string, MSC3575List> = {
spaces: {
ranges: [[0, 10]],
timeline_limit: 0, // we don't care about the most recent message for spaces
required_state: REQUIRED_STATE_LIST,
include_old_rooms: {
timeline_limit: 0,
required_state: REQUIRED_STATE_LIST,
},
filters: {
room_types: ["m.space"],
},
},
invites: {
ranges: [[0, 10]],
timeline_limit: 1, // most recent message display
required_state: REQUIRED_STATE_LIST,
include_old_rooms: {
timeline_limit: 0,
required_state: REQUIRED_STATE_LIST,
},
filters: {
is_invite: true,
},
},
favourites: {
ranges: [[0, 10]],
timeline_limit: 1, // most recent message display
required_state: REQUIRED_STATE_LIST,
include_old_rooms: {
timeline_limit: 0,
required_state: REQUIRED_STATE_LIST,
},
filters: {
tags: ["m.favourite"],
},
},
dms: {
ranges: [[0, 10]],
timeline_limit: 1, // most recent message display
required_state: REQUIRED_STATE_LIST,
include_old_rooms: {
timeline_limit: 0,
required_state: REQUIRED_STATE_LIST,
},
filters: {
is_dm: true,
is_invite: false,
// If a DM has a Favourite & Low Prio tag then it'll be shown in those lists instead
not_tags: ["m.favourite", "m.lowpriority"],
},
},
untagged: {
// SSS will dupe suppress invites/dms from here, so we don't need "not dms, not invites"
ranges: [[0, 10]],
timeline_limit: 1, // most recent message display
required_state: REQUIRED_STATE_LIST,
include_old_rooms: {
timeline_limit: 0,
required_state: REQUIRED_STATE_LIST,
},
},
};
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 serverSupportsSlidingSync: boolean;
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 = Promise.withResolvers<void>();
public static get instance(): SlidingSyncManager {
return SlidingSyncManager.internalInstance;
}
private configure(client: MatrixClient, proxyUrl: string): SlidingSync {
this.client = client;
// create the set of lists we will use.
const lists = new Map();
for (const listName in sssLists) {
lists.set(listName, sssLists[listName]);
}
// 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, lists, ENCRYPTED_SUBSCRIPTION, client, SLIDING_SYNC_TIMEOUT_MS);
this.slidingSync.addCustomSubscription(UNENCRYPTED_SUBSCRIPTION_NAME, UNENCRYPTED_SUBSCRIPTION);
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
],
},
...updateArgs,
};
} else {
const updatedList = { ...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)!;
}
/**
* Announces that the user has chosen to view the given room and that room will now
* be displayed, so it should have more state loaded.
* @param roomId The room to set visible
*/
public async setRoomVisible(roomId: string): Promise<void> {
await this.configureDefer.promise;
const subscriptions = this.slidingSync!.getRoomSubscriptions();
if (subscriptions.has(roomId)) return;
subscriptions.add(roomId);
const room = this.client?.getRoom(roomId);
// 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.
let shouldLazyLoad = false;
if (room) {
// do not lazy load encrypted rooms as we need the entire member list.
shouldLazyLoad = !(await this.client?.getCrypto()?.isEncryptionEnabledInRoom(roomId));
}
logger.log("SlidingSync setRoomVisible:", roomId, "shouldLazyLoad:", shouldLazyLoad);
if (shouldLazyLoad) {
// lazy load this room
this.slidingSync!.useCustomSubscription(roomId, UNENCRYPTED_SUBSCRIPTION_NAME);
}
this.slidingSync!.modifyRoomSubscriptions(subscriptions);
if (room) {
return; // we have data already for this room, show immediately e.g it's in a list
}
// wait until we know about this room. This may take a little while.
return new Promise((resolve) => {
logger.log(`SlidingSync setRoomVisible room ${roomId} not found, waiting for ClientEvent.Room`);
const waitForRoom = (r: Room): void => {
if (r.roomId === roomId) {
this.client?.off(ClientEvent.Room, waitForRoom);
logger.log(`SlidingSync room ${roomId} found, resolving setRoomVisible`);
resolve();
}
};
this.client?.on(ClientEvent.Room, waitForRoom);
});
}
/**
* Retrieve all rooms on the user's account. Retrieval is gradual over time.
* This function MUST be called BEFORE the first sync request goes out.
* @param batchSize The number of rooms to return in each request.
* @param gapBetweenRequestsMs The number of milliseconds to wait between requests.
*/
private async startSpidering(
slidingSync: SlidingSync,
batchSize: number,
gapBetweenRequestsMs: number,
): Promise<void> {
// The manager has created several lists (see `sssLists` in this file), all of which will be spidered simultaneously.
// There are multiple lists to ensure that we can populate invites/favourites/DMs sections immediately, rather than
// potentially waiting minutes if they are all very old rooms (and hence are returned last by the server). In this
// way, the lists are effectively priority requests. We don't actually care which room goes into which list at this
// point, as the RoomListStore will calculate this based on the returned data.
// copy the initial set of list names and ranges, we'll keep this map updated.
const listToUpperBound = new Map(
Object.keys(sssLists).map((listName) => {
return [listName, sssLists[listName].ranges[0][1]];
}),
);
console.log("startSpidering:", listToUpperBound);
// listen for a response from the server. ANY 200 OK will do here, as we assume that it is ACKing
// the request change we have sent out. TODO: this may not be true if you concurrently subscribe to a room :/
// but in that case, for spidering at least, it isn't the end of the world as request N+1 includes all indexes
// from request N.
const lifecycle = async (
state: SlidingSyncState,
_: MSC3575SlidingSyncResponse | null,
err?: Error,
): Promise<void> => {
if (state !== SlidingSyncState.Complete) {
return;
}
await sleep(gapBetweenRequestsMs); // don't tightloop; even on errors
if (err) {
return;
}
// for all lists with total counts > range => increase the range
let hasSetRanges = false;
listToUpperBound.forEach((currentUpperBound, listName) => {
const totalCount = slidingSync.getListData(listName)?.joinedCount || 0;
if (currentUpperBound < totalCount) {
// increment the upper bound
const newUpperBound = currentUpperBound + batchSize;
console.log(`startSpidering: ${listName} ${currentUpperBound} => ${newUpperBound}`);
listToUpperBound.set(listName, newUpperBound);
// make the next request. This will only send the request when this callback has finished, so if
// we set all the list ranges at once we will only send 1 new request.
slidingSync.setListRanges(listName, [[0, newUpperBound]]);
hasSetRanges = true;
}
});
if (!hasSetRanges) {
// finish spidering
slidingSync.off(SlidingSyncEvent.Lifecycle, lifecycle);
}
};
slidingSync.on(SlidingSyncEvent.Lifecycle, lifecycle);
}
/**
* 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 slidingSync = this.configure(client, client.baseUrl);
logger.info("Simplified Sliding Sync activated at", client.baseUrl);
this.startSpidering(slidingSync, 50, 50); // 50 rooms at a time, 50ms apart
return slidingSync;
}
/**
* 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.simplified_msc3575");
if (support) {
logger.log("nativeSlidingSyncSupport: org.matrix.simplified_msc3575 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)) {
SlidingSyncManager.serverSupportsSlidingSync = true;
return;
}
SlidingSyncManager.serverSupportsSlidingSync = false;
}
}
+118
View File
@@ -0,0 +1,118 @@
/*
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 OR LicenseRef-Element-Commercial
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 memoizeOne from "memoize-one";
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 calculateBrowserSupport(): 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);
if (!details.client) {
logger.warn("Browser unsupported, unknown client", navigator.userAgent);
return false;
}
if (!SUPPORTED_DEVICE_TYPES.includes(details.deviceType)) {
logger.warn("Browser unsupported, unsupported device type", details.deviceType);
return false;
}
// 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 true;
}
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);
return false;
}
return true;
}
/**
* 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.
* This is calculated once and stored for the lifetime of the session to prevent logspam.
*
* @returns `true` if the browser is supported by us, or `false` if *any* of the checks fail.
*/
export const getBrowserSupport = memoizeOne(calculateBrowserSupport);
/**
* 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,
});
}
+197
View File
@@ -0,0 +1,197 @@
/*
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 OR LicenseRef-Element-Commercial
Please see LICENSE files in the repository root for full details.
*/
import classNames from "classnames";
import {
type SERVICE_TYPES,
type MatrixClient,
type Terms,
type Policy,
type InternationalisedPolicy,
} 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";
import { pickBestLanguage } from "./languageHandler.tsx";
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 type ServicePolicyPair = {
policies: Terms["policies"];
service: Service;
};
export type TermsInteractionCallback = (
policiesAndServicePairs: ServicePolicyPair[],
agreedUrls: string[],
extraClassNames?: string,
) => Promise<string[]>;
export function pickBestPolicyLanguage(policy: Policy): InternationalisedPolicy | undefined {
const termsLang = pickBestLanguage(Object.keys(policy).filter((k) => k !== "version"));
return <InternationalisedPolicy>policy[termsLang];
}
/**
* 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: Terms[] = 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 = client.getAccountData("m.accepted_terms")?.getContent();
const agreedUrlSet = new Set<string>(currentAcceptedTerms?.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: Terms["policies"] = {};
for (const [policyName, policy] of Object.entries(policies)) {
let policyAgreed = false;
for (const lang of Object.keys(policy)) {
if (lang === "version" || typeof policy[lang] === "string") 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" || typeof policy[lang] === "string") 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;
}
+962
View File
@@ -0,0 +1,962 @@
/*
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 OR LicenseRef-Element-Commercial
Please see LICENSE files in the repository root for full details.
*/
import React from "react";
import {
type MatrixEvent,
type MatrixClient,
GuestAccess,
HistoryVisibility,
JoinRule,
EventType,
MsgType,
M_POLL_START,
M_POLL_END,
ContentHelpers,
} 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 { type 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 { getSenderName } from "./utils/event/getSenderName";
import PosthogTrackers from "./PosthogTrackers.ts";
import { ElementCallEventType } from "./call-types.ts";
import Spoiler from "./components/views/elements/Spoiler.tsx";
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,
): (() => Renderable) | 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:
if (allowJSX) {
return reason
? () =>
_t(
"timeline|m.room.member|ban_reason_spoiler",
{ senderName, reason },
{ user: () => <Spoiler>{targetName}</Spoiler> },
)
: () =>
_t(
"timeline|m.room.member|ban_spoiler",
{ senderName },
{ user: () => <Spoiler>{targetName}</Spoiler> },
);
}
return reason
? () => _t("timeline|m.room.member|ban_reason", { senderName, reason })
: () => _t("timeline|m.room.member|ban", { senderName });
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 () =>
reason
? _t("timeline|m.room.member|reject_invite_reason", { targetName, reason })
: _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();
const topic = ContentHelpers.parseTopicContent(ev.getContent()).text;
return () =>
topic
? _t("timeline|m.room.topic|changed", {
senderDisplayName,
topic,
})
: _t("timeline|m.room.topic|removed", {
senderDisplayName,
});
}
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 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 content = event.getContent<{ pinned: string[] }>();
const prevContent = event.getPrevContent();
const pinned = Array.isArray(content.pinned) ? content.pinned : [];
const previouslyPinned: string[] = Array.isArray(prevContent.pinned) ? prevContent.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.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,
};
// 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 ElementCallEventType.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()];
try {
return Boolean(handler?.(ev, client, false, showHiddenEvents));
} catch (e) {
console.error(`Error encountered when trying to render event type=${ev.getType()} id=${ev.getId()}`, e);
// Returning true if we have a handler so we can show an error tile rather than no tile at all
return !!handler;
}
}
/**
* 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
View File
@@ -0,0 +1,47 @@
/*
Copyright 2024 New Vector Ltd.
Copyright 2024 The Matrix.org Foundation C.I.C.
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
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
View File
@@ -0,0 +1,15 @@
/*
Copyright 2024 New Vector Ltd.
Copyright 2023 The Matrix.org Foundation C.I.C.
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
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;
}
+150
View File
@@ -0,0 +1,150 @@
/*
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 OR LicenseRef-Element-Commercial
Please see LICENSE files in the repository root for full details.
*/
import { M_BEACON, type Room, Thread, type MatrixEvent, EventType, type 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 { 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;
try {
return haveRendererForEvent(ev, client, false /* hidden messages should never trigger unread counts anyways */);
} catch (e) {
console.warn("Error determining if event should trigger unread count", e);
return false; // If we can't determine if the event should trigger an unread count, assume it does not.
}
}
export function doesRoomHaveUnreadMessages(room: Room, includeThreads: boolean): boolean {
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);
}
+224
View File
@@ -0,0 +1,224 @@
/*
Copyright 2019-2024 New Vector Ltd.
Copyright 2015, 2016 OpenMarket Ltd
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE files in the repository root for full details.
*/
import dis from "./dispatcher/dispatcher";
import Timer from "./utils/Timer";
import { Action } from "./dispatcher/actions.ts";
// 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: Action.UserActivity });
if (!this.activeNowTimeout.isRunning()) {
this.activeNowTimeout.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 {
/* aborted */
}
attachedTimers.forEach((t) => t.abort());
}
private isMouseEvent(event: Event): event is MouseEvent {
return event.type.startsWith("mouse");
}
}
+29
View File
@@ -0,0 +1,29 @@
/*
Copyright 2024 New Vector Ltd.
Copyright 2017-2021 The Matrix.org Foundation C.I.C.
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
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;
}
}
+133
View File
@@ -0,0 +1,133 @@
/*
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 OR LicenseRef-Element-Commercial
Please see LICENSE files in the repository root for full details.
*/
/**
* Constants for MatrixChat.state.view.
*
* The `View` is the primary state machine of the application: it has different states for the various setup flows
* that the user may find themselves in. Once we have a functioning client, we can transition to the `LOGGED_IN` state
* which is the "normal" state of the application.
*
* An incomplete state transition diagram follows.
*
* (initial state)
* Lock held by other instance
* LOADING CONFIRM_LOCK_
* THEFT
* Lock theft confirmed
* Session recovered Token/OIDC login succeeded
*
*
* No previous session
*
* (from all other states Client logged out
* except LOCK_STOLEN) WELCOME
*
*
*
* "Sign in" "Create account"
*
*
* "Forgot ▼ "Create an
* password" ┌─────────────────┐ account"
* FORGOT_PASSWORD LOGIN REGISTER
*
* Complete / "Sign in here"
* "Sign in instead"
*
* "Forgotten your
* password?"
*
* Soft-logout error
* SOFT_LOGOUT (from all other states
* except LOCK_STOLEN)
*
* Re-authentication succeeded
*
* (postLoginSetup)
*
* PENDING_CLIENT_ Account has Account lacks
* START cross-signing cross-signing
* keys keys
*
*
* Client started,
* force_verification pending
*
* Client started,
* force_verification COMPLETE_
* not needed SECURITY
* E2E_SETUP
*
* E2EE not enabled
*
*
*
*
*
*
*
*
* LOGGED_IN
*
*
*
* (from all other states)
*
* Session lock stolen
*
*
* LOCK_STOLEN
*
*
*/
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,
/**
* We have successfully recovered a session from localstorage, but the client
* has not yet been started.
*/
PENDING_CLIENT_START,
// 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;
+66
View File
@@ -0,0 +1,66 @@
/*
Copyright 2024 New Vector Ltd.
Copyright 2017 Vector Creations Ltd
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE files in the repository root for full details.
*/
import { type Room, type 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 });
}
}
+36
View File
@@ -0,0 +1,36 @@
/*
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 OR LicenseRef-Element-Commercial
Please see LICENSE files in the repository root for full details.
*/
import { type WorkerPayload } from "./workers/worker";
export class WorkerManager<Request extends object, Response> {
private readonly worker: Worker;
private seq = 0;
private pendingDeferredMap = new Map<number, PromiseWithResolvers<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 = Promise.withResolvers<Response>();
this.pendingDeferredMap.set(seq, deferred);
this.worker.postMessage({ seq, ...request });
return deferred.promise;
}
}
@@ -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 OR LicenseRef-Element-Commercial
Please see LICENSE files in the repository root for full details.
*/
import { type 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,
type IKeyboardShortcuts,
KeyBindingAction,
KEYBOARD_SHORTCUTS,
type 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);
};
@@ -0,0 +1,741 @@
/*
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 OR LicenseRef-Element-Commercial
Please see LICENSE files in the repository root for full details.
*/
// Import i18n.tsx instead of languageHandler to avoid circular deps
import { _td } from "@element-hq/web-shared-components";
import { IS_MAC, IS_ELECTRON, Key } from "../Keyboard";
import { type IBaseSetting } from "../settings/Settings";
import { type 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",
Save = "KeyBinding.save",
/** 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,
KeyBindingAction.Save,
],
},
[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: {
ctrlKey: 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: {
ctrlKey: true,
shiftKey: true,
key: Key.J,
},
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.Save]: {
default: {
key: Key.S,
ctrlOrCmdKey: true,
},
displayName: _td("keyboard|save"),
},
[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"),
},
};
@@ -0,0 +1,104 @@
/*
* 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 OR LicenseRef-Element-Commercial
* 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";
import SettingsStore from "../settings/SettingsStore";
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]: () =>
SettingsStore.getValue("feature_new_room_list")
? document.querySelector<HTMLElement>("#room-list-search-button")
: document.querySelector<HTMLElement>(".mx_RoomSearch"),
[Landmark.ROOM_LIST]: () =>
SettingsStore.getValue("feature_new_room_list")
? document.querySelector<HTMLElement>(".mx_RoomListItemView_selected") ||
document.querySelector<HTMLElement>(".mx_RoomListItemView")
: 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");
}
},
};
@@ -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 OR LicenseRef-Element-Commercial
Please see LICENSE files in the repository root for full details.
*/
import React from "react";
import {
RovingAction,
RovingTabIndexProvider as SharedRovingTabIndexProvider,
type RovingTabIndexProviderProps,
} from "@element-hq/web-shared-components";
import { getKeyBindingsManager } from "../KeyBindingsManager";
import { KeyBindingAction } from "./KeyboardShortcuts";
export { findNextSiblingElement, RovingTabIndexContext } from "@element-hq/web-shared-components";
export { checkInputableElement } from "@element-hq/web-shared-components";
export { RovingStateActionType } from "@element-hq/web-shared-components";
export { useRovingTabIndex } from "@element-hq/web-shared-components";
export type { IAction, IState } from "@element-hq/web-shared-components";
/**
* 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
*/
const getWebRovingAction = (ev: React.KeyboardEvent): RovingAction | undefined => {
switch (getKeyBindingsManager().getAccessibilityAction(ev)) {
case KeyBindingAction.Home:
return RovingAction.Home;
case KeyBindingAction.End:
return RovingAction.End;
case KeyBindingAction.ArrowLeft:
return RovingAction.ArrowLeft;
case KeyBindingAction.ArrowUp:
return RovingAction.ArrowUp;
case KeyBindingAction.ArrowRight:
return RovingAction.ArrowRight;
case KeyBindingAction.ArrowDown:
return RovingAction.ArrowDown;
case KeyBindingAction.Tab:
return RovingAction.Tab;
default:
return undefined;
}
};
type IProps = Omit<RovingTabIndexProviderProps, "getAction">;
export const RovingTabIndexProvider: React.FC<IProps> = (props) => {
return <SharedRovingTabIndexProvider {...props} getAction={getWebRovingAction} />;
};
// re-export the semantic helper components for simplicity
export { RovingTabIndexWrapper } from "./roving/RovingTabIndexWrapper";
export { RovingAccessibleButton } from "./roving/RovingAccessibleButton";
+62
View File
@@ -0,0 +1,62 @@
/*
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 OR LicenseRef-Element-Commercial
Please see LICENSE files in the repository root for full details.
*/
import React, { type Ref, type JSX } from "react";
import { RovingTabIndexProvider } from "./RovingTabIndex";
import { getKeyBindingsManager } from "../KeyBindingsManager";
import { KeyBindingAction } from "./KeyboardShortcuts";
interface IProps extends Omit<React.HTMLProps<HTMLDivElement>, "onKeyDown"> {
ref?: Ref<HTMLDivElement>;
}
// 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 = ({ children, ref, ...props }: IProps): JSX.Element => {
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;
@@ -0,0 +1,45 @@
/*
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 OR LicenseRef-Element-Commercial
Please see LICENSE files in the repository root for full details.
*/
import React, { type Ref, type JSX } from "react";
import AccessibleButton, { type ButtonProps } from "../../components/views/elements/AccessibleButton";
type Props<T extends keyof HTMLElementTagNameMap> = ButtonProps<T> & {
label?: string;
// whether the context menu is currently open
isExpanded: boolean;
ref?: Ref<HTMLElementTagNameMap[T]>;
};
// Semantic component for representing the AccessibleButton which launches a <ContextMenu />
export const ContextMenuButton = function <T extends keyof HTMLElementTagNameMap>({
label,
isExpanded,
children,
onClick,
onContextMenu,
ref,
...props
}: Props<T>): JSX.Element {
return (
<AccessibleButton
{...props}
onClick={onClick}
onContextMenu={onContextMenu ?? onClick ?? undefined}
aria-label={label}
aria-haspopup={true}
aria-expanded={isExpanded}
ref={ref}
>
{children}
</AccessibleButton>
);
};
@@ -0,0 +1,42 @@
/*
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 OR LicenseRef-Element-Commercial
Please see LICENSE files in the repository root for full details.
*/
import React, { type JSX } from "react";
import AccessibleButton, { type ButtonProps } from "../../components/views/elements/AccessibleButton";
type Props<T extends keyof HTMLElementTagNameMap> = ButtonProps<T> & {
// whether the context menu is currently open
isExpanded: boolean;
};
// Semantic component for representing the AccessibleButton which launches a <ContextMenu />
export const ContextMenuTooltipButton = function <T extends keyof HTMLElementTagNameMap>({
isExpanded,
children,
onClick,
onContextMenu,
ref,
...props
}: Props<T>): JSX.Element {
return (
<AccessibleButton
{...props}
onClick={onClick}
onContextMenu={onContextMenu ?? onClick ?? undefined}
aria-haspopup={true}
aria-expanded={isExpanded}
disableTooltip={isExpanded}
ref={ref}
>
{children}
</AccessibleButton>
);
};
@@ -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 OR LicenseRef-Element-Commercial
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>
);
};
@@ -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 OR LicenseRef-Element-Commercial
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>
);
};
@@ -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 OR LicenseRef-Element-Commercial
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>
);
};
@@ -0,0 +1,75 @@
/*
Copyright 2024,2025 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 OR LicenseRef-Element-Commercial
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> {
onChange(this: void): void; // we handle keyup/down ourselves so lose the ChangeEvent
onClose(this: void): void; // gets called after onChange on KeyBindingAction.ActivateSelectedButton
}
// Semantic component for representing a styled role=menuitemcheckbox
export const StyledMenuItemCheckbox: React.FC<IProps> = ({ children, 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"
onChange={onChange}
onKeyDown={onKeyDown}
onKeyUp={onKeyUp}
onFocus={onFocus}
inputRef={ref}
tabIndex={isActive ? 0 : -1}
>
{children}
</StyledCheckbox>
);
};
@@ -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 OR LicenseRef-Element-Commercial
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(this: void): void; // we handle keyup/down ourselves so lose the ChangeEvent
onClose(this: void): 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>
);
};
@@ -0,0 +1,43 @@
/*
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 OR LicenseRef-Element-Commercial
Please see LICENSE files in the repository root for full details.
*/
import React, { type JSX, type RefObject } from "react";
import AccessibleButton, { type ButtonProps } from "../../components/views/elements/AccessibleButton";
import { useRovingTabIndex } from "../RovingTabIndex";
type Props<T extends keyof HTMLElementTagNameMap> = Omit<ButtonProps<T>, "tabIndex"> & {
inputRef?: RefObject<HTMLElementTagNameMap[T] | null>;
focusOnMouseOver?: boolean;
};
// Wrapper to allow use of useRovingTabIndex for simple AccessibleButtons outside of React Functional Components.
export const RovingAccessibleButton = <T extends keyof HTMLElementTagNameMap>({
inputRef,
onFocus,
onMouseOver,
focusOnMouseOver,
...props
}: Props<T>): JSX.Element => {
const [onFocusInternal, isActive, ref] = useRovingTabIndex<HTMLElementTagNameMap[T]>(inputRef);
return (
<AccessibleButton
{...props}
onFocus={(event: React.FocusEvent<never, never>) => {
onFocusInternal();
onFocus?.(event);
}}
onMouseOver={(event: React.MouseEvent<never, never>) => {
if (focusOnMouseOver) onFocusInternal();
onMouseOver?.(event);
}}
ref={ref}
tabIndex={isActive ? 0 : -1}
/>
);
};
@@ -0,0 +1,9 @@
/*
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 OR LicenseRef-Element-Commercial
Please see LICENSE files in the repository root for full details.
*/
export { RovingTabIndexWrapper } from "@element-hq/web-shared-components";
@@ -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 OR LicenseRef-Element-Commercial
Please see LICENSE files in the repository root for full details.
*/
import {
ClientEvent,
type MatrixClient,
type MatrixEvent,
MatrixEventEvent,
type Room,
RoomEvent,
type IRoomTimelineData,
type RoomState,
RoomStateEvent,
} from "matrix-js-sdk/src/matrix";
import dis from "../dispatcher/dispatcher";
import { type 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 = [];
},
};
+107
View File
@@ -0,0 +1,107 @@
/*
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 OR LicenseRef-Element-Commercial
Please see LICENSE files in the repository root for full details.
*/
import { type MatrixClient, type 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 { type AsyncActionPayload } from "../dispatcher/payloads";
import { DefaultTagID, type TagID } from "../stores/room-list-v3/skip-list/tag";
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 the matrix client to set the
* account data on.
* @param room the room to tag.
* @param oldTag the tag to remove (unless oldTag ==== newTag)
* @param newTag the tag with which to tag the room.
* @param oldIndex the previous position of the room in the
* list of rooms.
* @returns an async action payload
* @see asyncAction
*/
public static tagRoom(
matrixClient: MatrixClient,
room: Room,
oldTag: TagID | null | undefined,
newTag: TagID | null | undefined,
): AsyncActionPayload {
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?.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?.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) {
const promiseToAdd = matrixClient.setRoomTag(roomId, newTag).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?.message ?? _t("invite|failed_generic"),
});
throw err;
});
promises.push(promiseToAdd);
}
return Promise.all(promises);
},
() => {
// For an optimistic update
return {
room,
oldTag,
newTag,
};
},
);
}
}
+54
View File
@@ -0,0 +1,54 @@
/*
Copyright 2024 New Vector Ltd.
Copyright 2020 The Matrix.org Foundation C.I.C.
Copyright 2017 New Vector Ltd
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE files in the repository root for full details.
*/
import { type 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);
}
@@ -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 OR LicenseRef-Element-Commercial
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,
});
};
@@ -0,0 +1,206 @@
/*
Copyright 2020-2024 New Vector Ltd.
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE files in the repository root for full details.
*/
import React, { type JSX, type ReactNode } from "react";
import { Text, Heading, Button, Separator } from "@vector-im/compound-web";
import PopOutIcon from "@vector-im/compound-design-tokens/assets/web/icons/pop-out";
import { Flex } from "@element-hq/web-shared-components";
import { LinuxIcon, MacIcon, WindowsIcon } from "@vector-im/compound-design-tokens/assets/web/icons";
import SdkConfig from "../../SdkConfig";
import { _t } from "../../languageHandler";
// directly import the style here as this layer does not support rethemedex at this time so no matrix-react-sdk
// PostCSS variables will be accessible.
import "../../../res/css/structures/ErrorView.pcss";
interface IProps {
// both of these should already be internationalised
title: string;
messages?: string[];
footer?: ReactNode;
children?: ReactNode;
}
export const ErrorView: React.FC<IProps> = ({ title, messages, footer, children }) => {
return (
<div className="mx_ErrorView cpd-theme-light">
<img
className="mx_ErrorView_logo"
height="160"
src="themes/element/img/logos/element-app-logo.png"
alt="Element"
/>
<div className="mx_ErrorView_container">
<Heading size="md" weight="semibold">
{title}
</Heading>
{messages?.map((message) => (
<Text key={message} size="lg">
{message}
</Text>
))}
{children}
</div>
{footer}
</div>
);
};
const MobileAppLinks: React.FC<{
appleAppStoreUrl?: string;
googlePlayUrl?: string;
fdroidUrl?: string;
}> = ({ appleAppStoreUrl, googlePlayUrl, fdroidUrl }) => (
<Flex gap="var(--cpd-space-6x)" className="mx_ErrorView_flexContainer">
{appleAppStoreUrl && (
<a href={appleAppStoreUrl} target="_blank" rel="noreferrer noopener">
<img height="64" src="themes/element/img/download/apple.svg" alt="Apple App Store" />
</a>
)}
{googlePlayUrl && (
<a href={googlePlayUrl} target="_blank" rel="noreferrer noopener" key="android">
<img height="64" src="themes/element/img/download/google.svg" alt="Google Play Store" />
</a>
)}
{fdroidUrl && (
<a href={fdroidUrl} target="_blank" rel="noreferrer noopener" key="fdroid">
<img height="64" src="themes/element/img/download/fdroid.svg" alt="F-Droid" />
</a>
)}
</Flex>
);
const DesktopAppLinks: React.FC<{
macOsUrl?: string;
win64Url?: string;
win64ArmUrl?: string;
linuxUrl?: string;
}> = ({ macOsUrl, win64Url, win64ArmUrl, linuxUrl }) => {
return (
<Flex gap="var(--cpd-space-4x)" className="mx_ErrorView_flexContainer">
{macOsUrl && (
<Button as="a" href={macOsUrl} kind="secondary" Icon={MacIcon}>
{_t("incompatible_browser|macos")}
</Button>
)}
{win64Url && (
<Button as="a" href={win64Url} kind="secondary" Icon={WindowsIcon}>
{_t("incompatible_browser|windows_64bit")}
</Button>
)}
{win64ArmUrl && (
<Button as="a" href={win64ArmUrl} kind="secondary" Icon={WindowsIcon}>
{_t("incompatible_browser|windows_arm_64bit")}
</Button>
)}
{linuxUrl && (
<Button as="a" href={linuxUrl} kind="secondary" Icon={LinuxIcon}>
{_t("incompatible_browser|linux")}
</Button>
)}
</Flex>
);
};
const linkFactory =
(link: string) =>
(text: string): JSX.Element => (
<a href={link} target="_blank" rel="noreferrer noopener">
{text}
</a>
);
export const UnsupportedBrowserView: React.FC<{
onAccept?(this: void): void;
}> = ({ onAccept }) => {
const config = SdkConfig.get();
const brand = config.brand ?? "Element";
const hasDesktopBuilds =
config.desktop_builds?.available &&
(config.desktop_builds?.url_macos ||
config.desktop_builds?.url_win64 ||
config.desktop_builds?.url_win64arm ||
config.desktop_builds?.url_linux);
const hasMobileBuilds = Boolean(
config.mobile_builds?.ios || config.mobile_builds?.android || config.mobile_builds?.fdroid,
);
return (
<ErrorView
title={_t("incompatible_browser|title", { brand })}
messages={[
_t("incompatible_browser|description", {
brand,
detail: onAccept
? _t("incompatible_browser|detail_can_continue")
: _t("incompatible_browser|detail_no_continue"),
}),
]}
footer={
<>
{/* We render the apps in the footer as they are wider than the 520px container */}
{(hasDesktopBuilds || hasMobileBuilds) && <Separator />}
{hasDesktopBuilds && (
<>
<Heading as="h2" size="sm" weight="semibold">
{_t("incompatible_browser|use_desktop_heading", { brand })}
</Heading>
<DesktopAppLinks
macOsUrl={config.desktop_builds?.url_macos}
win64Url={config.desktop_builds?.url_win64}
win64ArmUrl={config.desktop_builds?.url_win64arm}
linuxUrl={config.desktop_builds?.url_linux}
/>
</>
)}
{hasMobileBuilds && (
<>
<Heading as="h2" size="sm" weight="semibold">
{hasDesktopBuilds
? _t("incompatible_browser|use_mobile_heading_after_desktop")
: _t("incompatible_browser|use_mobile_heading", { brand })}
</Heading>
<MobileAppLinks
appleAppStoreUrl={config.mobile_builds?.ios ?? undefined}
googlePlayUrl={config.mobile_builds?.android ?? undefined}
fdroidUrl={config.mobile_builds?.fdroid ?? undefined}
/>
</>
)}
</>
}
>
<Text size="lg">
{_t(
"incompatible_browser|supported_browsers",
{},
{
Chrome: linkFactory("https://google.com/chrome"),
Firefox: linkFactory("https://firefox.com"),
Edge: linkFactory("https://microsoft.com/edge"),
Safari: linkFactory("https://apple.com/safari"),
},
)}
</Text>
<Flex gap="var(--cpd-space-4x)" className="mx_ErrorView_flexContainer mx_ErrorView_buttons">
<Button Icon={PopOutIcon} kind="secondary" size="md">
{_t("incompatible_browser|learn_more")}
</Button>
{onAccept && (
<Button kind="primary" size="md" onClick={onAccept}>
{_t("incompatible_browser|continue")}
</Button>
)}
</Flex>
</ErrorView>
);
};
@@ -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 OR LicenseRef-Element-Commercial
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>
);
}
}

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