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
+24
View File
@@ -0,0 +1,24 @@
/*
Copyright 2024 New Vector Ltd.
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.
*/
import { clamp } from "lodash";
/**
* This method linearly interpolates between two points (start, end). This is
* most commonly used to find a point some fraction of the way along a line
* between two endpoints (e.g. to move an object gradually between those
* points).
* @param {number} start the starting point
* @param {number} end the ending point
* @param {number} amt the interpolant
* @returns
*/
export function lerp(start: number, end: number, amt: number): number {
amt = clamp(amt, 0, 1);
return (1 - amt) * start + amt * end;
}
+315
View File
@@ -0,0 +1,315 @@
/*
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 React, { type ReactNode } from "react";
import {
AutoDiscovery,
AutoDiscoveryError,
type ClientConfig,
type IClientWellKnown,
MatrixClient,
MatrixError,
type OidcClientConfig,
} from "matrix-js-sdk/src/matrix";
import { logger } from "matrix-js-sdk/src/logger";
import { _t, _td, UserFriendlyError } from "../languageHandler";
import SdkConfig from "../SdkConfig";
import { type ValidatedServerConfig } from "./ValidatedServerConfig";
const LIVELINESS_DISCOVERY_ERRORS: AutoDiscoveryError[] = [
AutoDiscovery.ERROR_INVALID_HOMESERVER,
AutoDiscovery.ERROR_INVALID_IDENTITY_SERVER,
];
export interface IAuthComponentState {
serverIsAlive: boolean;
serverErrorIsFatal: boolean;
serverDeadError?: ReactNode;
}
const AutoDiscoveryErrors = Object.values(AutoDiscoveryError);
const isAutoDiscoveryError = (err: unknown): err is AutoDiscoveryError => {
return AutoDiscoveryErrors.includes(err as AutoDiscoveryError);
};
const mapAutoDiscoveryErrorTranslation = (err: AutoDiscoveryError): TranslationKey => {
switch (err) {
case AutoDiscoveryError.GenericFailure:
return _td("auth|autodiscovery_invalid");
case AutoDiscoveryError.Invalid:
return _td("auth|autodiscovery_generic_failure");
case AutoDiscoveryError.InvalidHsBaseUrl:
return _td("auth|autodiscovery_invalid_hs_base_url");
case AutoDiscoveryError.InvalidHomeserver:
return _td("auth|autodiscovery_invalid_hs");
case AutoDiscoveryError.InvalidIsBaseUrl:
return _td("auth|autodiscovery_invalid_is_base_url");
case AutoDiscoveryError.InvalidIdentityServer:
return _td("auth|autodiscovery_invalid_is");
case AutoDiscoveryError.InvalidIs:
return _td("auth|autodiscovery_invalid_is_response");
case AutoDiscoveryError.MissingWellknown:
return _td("auth|autodiscovery_no_well_known");
case AutoDiscoveryError.InvalidJson:
return _td("auth|autodiscovery_invalid_json");
case AutoDiscoveryError.UnsupportedHomeserverSpecVersion:
return _td("auth|autodiscovery_hs_incompatible");
}
};
export default class AutoDiscoveryUtils {
/**
* Checks if a given error or error message is considered an error
* relating to the liveliness of the server. Must be an error returned
* from this AutoDiscoveryUtils class.
* @param {string | Error} error The error to check
* @returns {boolean} True if the error is a liveliness error.
*/
public static isLivelinessError(error: unknown): boolean {
if (!error) return false;
let msg: unknown = error;
if (error instanceof UserFriendlyError) {
msg = error.cause;
} else if (error instanceof Error) {
msg = error.message;
}
return LIVELINESS_DISCOVERY_ERRORS.includes(msg as AutoDiscoveryError);
}
/**
* Gets the common state for auth components (login, registration, forgot
* password) for a given validation error.
* @param {Error} err The error encountered.
* @param {string} pageName The page for which the error should be customized to. See
* implementation for known values.
* @returns {*} The state for the component, given the error.
*/
public static authComponentStateForError(err: unknown, pageName = "login"): IAuthComponentState {
if (!err) {
return {
serverIsAlive: true,
serverErrorIsFatal: false,
serverDeadError: null,
};
}
let title = _t("cannot_reach_homeserver");
let body: ReactNode = _t("cannot_reach_homeserver_detail");
if (!AutoDiscoveryUtils.isLivelinessError(err)) {
const brand = SdkConfig.get().brand;
title = _t("auth|misconfigured_title", { brand });
body = _t(
"auth|misconfigured_body",
{
brand,
},
{
a: (sub) => {
return (
<a
href="https://github.com/vector-im/element-web/blob/master/docs/config.md"
target="_blank"
rel="noreferrer noopener"
>
{sub}
</a>
);
},
},
);
}
let isFatalError = true;
const errorMessage = err instanceof Error ? err.message : err;
if (errorMessage === AutoDiscovery.ERROR_INVALID_IDENTITY_SERVER) {
isFatalError = false;
title = _t("auth|failed_connect_identity_server");
// It's annoying having a ladder for the third word in the same sentence, but our translations
// don't make this easy to avoid.
if (pageName === "register") {
body = _t("auth|failed_connect_identity_server_register");
} else if (pageName === "reset_password") {
body = _t("auth|failed_connect_identity_server_reset_password");
} else {
body = _t("auth|failed_connect_identity_server_other");
}
}
return {
serverIsAlive: false,
serverErrorIsFatal: isFatalError,
serverDeadError: (
<div>
<strong>{title}</strong>
<div>{body}</div>
</div>
),
};
}
/**
* Validates a server configuration, using a pair of URLs as input.
* @param {string} homeserverUrl The homeserver URL.
* @param {string} identityUrl The identity server URL.
* @param {boolean} syntaxOnly If true, errors relating to liveliness of the servers will
* not be raised.
* @returns {Promise<ValidatedServerConfig>} Resolves to the validated configuration.
*/
public static async validateServerConfigWithStaticUrls(
homeserverUrl: string,
identityUrl?: string,
syntaxOnly = false,
): Promise<ValidatedServerConfig> {
if (!homeserverUrl) {
throw new UserFriendlyError("auth|no_hs_url_provided");
}
const wellknownConfig: IClientWellKnown = {
"m.homeserver": {
base_url: homeserverUrl,
},
};
if (identityUrl) {
wellknownConfig["m.identity_server"] = {
base_url: identityUrl,
};
}
const result = await AutoDiscovery.fromDiscoveryConfig(wellknownConfig);
const url = new URL(homeserverUrl);
const serverName = url.hostname;
return AutoDiscoveryUtils.buildValidatedConfigFromDiscovery(serverName, result, syntaxOnly, true);
}
/**
* Validates a server configuration, using a homeserver domain name as input.
* @param {string} serverName The homeserver domain name (eg: "matrix.org") to validate.
* @returns {Promise<ValidatedServerConfig>} Resolves to the validated configuration.
*/
public static async validateServerName(serverName: string): Promise<ValidatedServerConfig> {
const result = await AutoDiscovery.findClientConfig(serverName);
return AutoDiscoveryUtils.buildValidatedConfigFromDiscovery(serverName, result);
}
/**
* Validates a server configuration, using a pre-calculated AutoDiscovery result as
* input.
* @param {string} serverName The domain name the AutoDiscovery result is for.
* @param {*} discoveryResult The AutoDiscovery result.
* @param {boolean} syntaxOnly If true, errors relating to liveliness of the servers will not be raised.
* @param {boolean} isSynthetic If true, then the discoveryResult was synthesised locally.
* @returns {Promise<ValidatedServerConfig>} Resolves to the validated configuration.
*/
public static async buildValidatedConfigFromDiscovery(
serverName?: string,
discoveryResult?: ClientConfig,
syntaxOnly = false,
isSynthetic = false,
): Promise<ValidatedServerConfig> {
if (!discoveryResult?.["m.homeserver"]) {
// This shouldn't happen without major misconfiguration, so we'll log a bit of information
// in the log so we can find this bit of code but otherwise tell the user "it broke".
logger.error("Ended up in a state of not knowing which homeserver to connect to.");
throw new UserFriendlyError("auth|autodiscovery_unexpected_error_hs");
}
const hsResult = discoveryResult["m.homeserver"];
const isResult = discoveryResult["m.identity_server"];
const defaultConfig = SdkConfig.get("validated_server_config");
// Validate the identity server first because an invalid identity server causes
// an invalid homeserver, which may not be picked up correctly.
// Note: In the cases where we rely on the default IS from the config (namely
// lack of identity server provided by the discovery method), we intentionally do not
// validate it. This has already been validated and this helps some off-the-grid usage
// of Element.
let preferredIdentityUrl = defaultConfig && defaultConfig["isUrl"];
if (isResult && isResult.state === AutoDiscovery.SUCCESS) {
preferredIdentityUrl = isResult["base_url"] ?? undefined;
} else if (isResult && isResult.state !== AutoDiscovery.PROMPT) {
logger.error("Error determining preferred identity server URL:", isResult);
if (isResult.state === AutoDiscovery.FAIL_ERROR) {
if (isAutoDiscoveryError(isResult.error)) {
throw new UserFriendlyError(mapAutoDiscoveryErrorTranslation(isResult.error), {
cause: hsResult.error,
});
}
throw new UserFriendlyError("auth|autodiscovery_unexpected_error_is");
} // else the error is not related to syntax - continue anyways.
// rewrite homeserver error since we don't care about problems
hsResult.error = AutoDiscovery.ERROR_INVALID_IDENTITY_SERVER;
// Also use the user's supplied identity server if provided
if (isResult["base_url"]) preferredIdentityUrl = isResult["base_url"];
}
if (hsResult.state !== AutoDiscovery.SUCCESS) {
logger.error("Error processing homeserver config:", hsResult);
if (!syntaxOnly || !AutoDiscoveryUtils.isLivelinessError(hsResult.error)) {
if (isAutoDiscoveryError(hsResult.error)) {
throw new UserFriendlyError(mapAutoDiscoveryErrorTranslation(hsResult.error), {
cause: hsResult.error,
});
}
throw new UserFriendlyError("auth|autodiscovery_unexpected_error_hs");
} // else the error is not related to syntax - continue anyways.
}
const preferredHomeserverUrl = hsResult["base_url"];
if (!preferredHomeserverUrl) {
logger.error("No homeserver URL configured");
throw new UserFriendlyError("auth|autodiscovery_unexpected_error_hs");
}
let preferredHomeserverName = serverName ?? hsResult["server_name"];
const url = new URL(preferredHomeserverUrl);
if (!preferredHomeserverName) preferredHomeserverName = url.hostname;
// It should have been set by now, so check it
if (!preferredHomeserverName) {
logger.error("Failed to parse homeserver name from homeserver URL");
throw new UserFriendlyError("auth|autodiscovery_unexpected_error_hs");
}
// This isn't inherently auto-discovery but used to be in an earlier incarnation of the MSC,
// and shuttling the data together makes a lot of sense
let delegatedAuthentication: OidcClientConfig | undefined;
let delegatedAuthenticationError: Error | undefined;
try {
const tempClient = new MatrixClient({ baseUrl: preferredHomeserverUrl });
delegatedAuthentication = await tempClient.getAuthMetadata();
} catch (e) {
if (e instanceof MatrixError && e.httpStatus === 404 && e.errcode === "M_UNRECOGNIZED") {
// 404 M_UNRECOGNIZED means the server does not support OIDC
} else {
delegatedAuthenticationError = e as Error;
}
}
return {
hsUrl: preferredHomeserverUrl,
hsName: preferredHomeserverName,
hsNameIsDifferent: url.hostname !== preferredHomeserverName,
isUrl: preferredIdentityUrl,
isDefault: false,
warning: hsResult.error ?? delegatedAuthenticationError ?? null,
isNameResolvable: !isSynthetic,
delegatedAuthentication,
} as ValidatedServerConfig;
}
}
+16
View File
@@ -0,0 +1,16 @@
/*
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 MouseEvent } from "react";
export function chromeFileInputFix(event: MouseEvent<HTMLInputElement>): void {
// Workaround for Chromium Bug
// Chrome does not fire onChange events if the same file is selected twice
// Only required on Chromium-based browsers (Electron, Chrome, Edge, Opera, Vivaldi, etc)
event.currentTarget.value = "";
}
+250
View File
@@ -0,0 +1,250 @@
/*
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 { uniq } from "lodash";
import { type Room, type MatrixEvent, EventType, ClientEvent, type MatrixClient } from "matrix-js-sdk/src/matrix";
import { KnownMembership } from "matrix-js-sdk/src/types";
import { logger } from "matrix-js-sdk/src/logger";
import { filterValidMDirect } from "./dm/filterValidMDirect";
/**
* Class that takes a Matrix Client and flips the m.direct map
* so the operation of mapping a room ID to which user it's a DM
* with can be performed efficiently.
*
* With 'start', this can also keep itself up to date over time.
*/
export default class DMRoomMap {
private static sharedInstance: DMRoomMap;
// TODO: convert these to maps
private roomToUser: { [key: string]: string } | null = null;
private userToRooms: { [key: string]: string[] } | null = null;
private hasSentOutPatchDirectAccountDataPatch: boolean;
private mDirectEvent!: { [key: string]: string[] };
public constructor(private readonly matrixClient: MatrixClient) {
// see onAccountData
this.hasSentOutPatchDirectAccountDataPatch = false;
const mDirectRawContent = matrixClient.getAccountData(EventType.Direct)?.getContent() ?? {};
this.setMDirectFromContent(mDirectRawContent);
}
/**
* Makes and returns a new shared instance that can then be accessed
* with shared(). This returned instance is not automatically started.
*/
public static makeShared(matrixClient: MatrixClient): DMRoomMap {
DMRoomMap.sharedInstance = new DMRoomMap(matrixClient);
return DMRoomMap.sharedInstance;
}
/**
* Set the shared instance to the instance supplied
* Used by tests
* @param inst the new shared instance
*/
public static setShared(inst: DMRoomMap): void {
DMRoomMap.sharedInstance = inst;
}
/**
* Returns a shared instance of the class
* that uses the singleton matrix client
* The shared instance must be started before use.
*/
public static shared(): DMRoomMap {
return DMRoomMap.sharedInstance;
}
public start(): void {
this.populateRoomToUser();
this.matrixClient.on(ClientEvent.AccountData, this.onAccountData);
}
public stop(): void {
this.matrixClient.removeListener(ClientEvent.AccountData, this.onAccountData);
}
/**
* Filter m.direct content to contain only valid data and then sets it.
* Logs if invalid m.direct content occurs.
* {@link filterValidMDirect}
*
* @param content - Raw m.direct content
*/
private setMDirectFromContent(content: unknown): void {
const { valid, filteredContent } = filterValidMDirect(content);
if (!valid) {
logger.warn("Invalid m.direct content occurred", content);
}
this.mDirectEvent = filteredContent;
}
private onAccountData = (ev: MatrixEvent): void => {
if (ev.getType() == EventType.Direct) {
this.setMDirectFromContent(ev.getContent());
this.userToRooms = null;
this.roomToUser = null;
}
};
/**
* some client bug somewhere is causing some DMs to be marked
* with ourself, not the other user. Fix it by guessing the other user and
* modifying userToRooms
*/
private patchUpSelfDMs(userToRooms: Record<string, string[]>): boolean {
const myUserId = this.matrixClient.getUserId()!;
const selfRoomIds = userToRooms[myUserId];
if (selfRoomIds) {
// any self-chats that should not be self-chats?
const guessedUserIdsThatChanged = selfRoomIds
.map((roomId) => {
const room = this.matrixClient.getRoom(roomId);
if (room) {
const userId = room.guessDMUserId();
if (userId && userId !== myUserId) {
return { userId, roomId };
}
}
})
.filter((ids) => !!ids) as { userId: string; roomId: string }[]; //filter out
// these are actually all legit self-chats
// bail out
if (!guessedUserIdsThatChanged.length) {
return false;
}
userToRooms[myUserId] = selfRoomIds.filter((roomId) => {
return !guessedUserIdsThatChanged.some((ids) => ids.roomId === roomId);
});
guessedUserIdsThatChanged.forEach(({ userId, roomId }) => {
const roomIds = userToRooms[userId];
if (!roomIds) {
userToRooms[userId] = [roomId];
} else {
roomIds.push(roomId);
userToRooms[userId] = uniq(roomIds);
}
});
return true;
}
return false;
}
public getDMRoomsForUserId(userId: string): string[] {
// Here, we return the empty list if there are no rooms,
// since the number of conversations you have with this user is zero.
return this.getUserToRooms()[userId] || [];
}
/**
* Gets the DM room which the given IDs share, if any.
* @param {string[]} ids The identifiers (user IDs and email addresses) to look for.
* @returns {Room} The DM room which all IDs given share, or falsy if no common room.
*/
public getDMRoomForIdentifiers(ids: string[]): Room | null {
// TODO: [Canonical DMs] Handle lookups for email addresses.
// For now we'll pretend we only get user IDs and end up returning nothing for email addresses
let commonRooms = this.getDMRoomsForUserId(ids[0]);
for (let i = 1; i < ids.length; i++) {
const userRooms = this.getDMRoomsForUserId(ids[i]);
commonRooms = commonRooms.filter((r) => userRooms.includes(r));
}
const joinedRooms = commonRooms
.map((r) => this.matrixClient.getRoom(r))
.filter((r) => r && r.getMyMembership() === KnownMembership.Join);
return joinedRooms[0];
}
public getUserIdForRoomId(roomId: string): string | undefined {
if (this.roomToUser == null) {
// we lazily populate roomToUser so you can use
// this class just to call getDMRoomsForUserId
// which doesn't do very much, but is a fairly
// convenient wrapper and there's no point
// iterating through the map if getUserIdForRoomId()
// is never called.
this.populateRoomToUser();
}
// Here, we return undefined if the room is not in the map:
// the room ID you gave is not a DM room for any user.
if (this.roomToUser![roomId] === undefined) {
// no entry? if the room is an invite, look for the is_direct hint.
const room = this.matrixClient.getRoom(roomId);
if (room) {
return room.getDMInviter();
}
}
return this.roomToUser![roomId];
}
public getUniqueRoomsWithIndividuals(): { [userId: string]: Room } {
if (!this.roomToUser) return {}; // No rooms means no map.
// map roomToUser to valid rooms with two participants
return Object.keys(this.roomToUser).reduce(
(acc, roomId: string) => {
const userId = this.getUserIdForRoomId(roomId);
const room = this.matrixClient.getRoom(roomId);
const hasTwoMembers = room?.getInvitedAndJoinedMemberCount() === 2;
if (userId && room && hasTwoMembers) {
acc[userId] = room;
}
return acc;
},
{} as Record<string, Room>,
);
}
/**
* @returns all room Ids from m.direct
*/
public getRoomIds(): Set<string> {
return Object.values(this.mDirectEvent).reduce((prevRoomIds: Set<string>, roomIds: string[]): Set<string> => {
roomIds.forEach((roomId) => prevRoomIds.add(roomId));
return prevRoomIds;
}, new Set<string>());
}
private getUserToRooms(): { [key: string]: string[] } {
if (!this.userToRooms) {
const userToRooms = this.mDirectEvent;
const myUserId = this.matrixClient.getUserId()!;
const selfDMs = userToRooms[myUserId];
if (selfDMs?.length) {
const neededPatching = this.patchUpSelfDMs(userToRooms);
// to avoid multiple devices fighting to correct
// the account data, only try to send the corrected
// version once.
logger.warn(`Invalid m.direct account data detected (self-chats that shouldn't be), patching it up.`);
if (neededPatching && !this.hasSentOutPatchDirectAccountDataPatch) {
this.hasSentOutPatchDirectAccountDataPatch = true;
this.matrixClient.setAccountData(EventType.Direct, userToRooms);
}
}
this.userToRooms = userToRooms;
}
return this.userToRooms;
}
private populateRoomToUser(): void {
this.roomToUser = {};
for (const user of Object.keys(this.getUserToRooms())) {
for (const roomId of this.userToRooms![user]) {
this.roomToUser[roomId] = user;
}
}
}
}
+73
View File
@@ -0,0 +1,73 @@
/*
Copyright 2024 New Vector Ltd.
Copyright 2016-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.
*/
// Pull in the encryption lib so that we can decrypt attachments.
import encrypt from "matrix-encrypt-attachment";
import { parseErrorResponse } from "matrix-js-sdk/src/matrix";
import { type EncryptedFile, type MediaEventInfo } from "matrix-js-sdk/src/types";
import { mediaFromContent } from "../customisations/Media";
import { getBlobSafeMimeType } from "./blobs";
export class DownloadError extends Error {
public constructor(e: Error) {
super(e.message);
this.name = "DownloadError";
this.stack = e.stack;
}
}
export class DecryptError extends Error {
public constructor(e: Error) {
super(e.message);
this.name = "DecryptError";
this.stack = e.stack;
}
}
/**
* Decrypt a file attached to a matrix event.
* @param {EncryptedFile} file The encrypted file information taken from the matrix event.
* This passed to [link]{@link https://github.com/matrix-org/matrix-encrypt-attachment}
* as the encryption info object, so will also have the those keys in addition to
* the keys below.
* @param {MediaEventInfo} info The info parameter taken from the matrix event.
* @returns {Promise<Blob>} Resolves to a Blob of the file.
*/
export async function decryptFile(file?: EncryptedFile, info?: MediaEventInfo): Promise<Blob> {
// throws if file is falsy
const media = mediaFromContent({ file });
let responseData: ArrayBuffer;
try {
// Download the encrypted file as an array buffer.
const response = await media.downloadSource();
if (!response.ok) {
throw parseErrorResponse(response, await response.text());
}
responseData = await response.arrayBuffer();
} catch (e) {
throw new DownloadError(e as Error);
}
try {
// Decrypt the array buffer using the information taken from the event content.
const dataArray = await encrypt.decryptAttachment(responseData, file!);
// Turn the array into a Blob and give it the correct MIME-type.
// IMPORTANT: we must not allow scriptable mime-types into Blobs otherwise
// they introduce XSS attacks if the Blob URI is viewed directly in the
// browser (e.g. by copying the URI into a new tab or window.)
// See warning at top of file.
const mimetype = getBlobSafeMimeType(info?.mimetype?.split(";")[0].trim() ?? "");
return new Blob([dataArray], { type: mimetype });
} catch (e) {
throw new DecryptError(e as Error);
}
}
+144
View File
@@ -0,0 +1,144 @@
/*
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 classnames from "classnames";
import { type ComponentProps } from "react";
import { type MatrixClient } from "matrix-js-sdk/src/matrix";
import defaultDispatcher from "../dispatcher/dispatcher";
import { type ActionPayload } from "../dispatcher/payloads";
import Modal from "../Modal";
import RoomSettingsDialog from "../components/views/dialogs/RoomSettingsDialog";
import ForwardDialog from "../components/views/dialogs/ForwardDialog";
import { Action } from "../dispatcher/actions";
import ReportEventDialog from "../components/views/dialogs/ReportEventDialog";
import SpacePreferencesDialog from "../components/views/dialogs/SpacePreferencesDialog";
import SpaceSettingsDialog from "../components/views/dialogs/SpaceSettingsDialog";
import InviteDialog from "../components/views/dialogs/InviteDialog";
import AddExistingToSpaceDialog from "../components/views/dialogs/AddExistingToSpaceDialog";
import { type ButtonEvent } from "../components/views/elements/AccessibleButton";
import PosthogTrackers from "../PosthogTrackers";
import { showAddExistingSubspace, showCreateNewRoom } from "./space";
import { SdkContextClass } from "../contexts/SDKContext";
/**
* Auxiliary class to listen for dialog opening over the dispatcher and
* open the required dialogs. Not all dialogs run through here, but the
* ones which cause import cycles are good candidates.
*/
export class DialogOpener {
public static readonly instance = new DialogOpener();
private isRegistered = false;
private matrixClient?: MatrixClient;
private constructor() {}
// We could do this in the constructor, but then we wouldn't have
// a function to call from Lifecycle to capture the class.
public prepare(matrixClient: MatrixClient): void {
this.matrixClient = matrixClient;
if (this.isRegistered) return;
defaultDispatcher.register(this.onDispatch);
this.isRegistered = true;
}
private onDispatch = (payload: ActionPayload): void => {
if (!this.matrixClient) return;
switch (payload.action) {
case "open_room_settings":
Modal.createDialog(
RoomSettingsDialog,
{
roomId: payload.room_id || SdkContextClass.instance.roomViewStore.getRoomId(),
initialTabId: payload.initial_tab_id,
sdkContext: SdkContextClass.instance,
},
/*className=*/ undefined,
/*isPriority=*/ false,
/*isStatic=*/ true,
);
break;
case Action.OpenForwardDialog:
Modal.createDialog(ForwardDialog, {
matrixClient: this.matrixClient,
event: payload.event,
permalinkCreator: payload.permalinkCreator,
});
break;
case Action.OpenReportEventDialog:
Modal.createDialog(
ReportEventDialog,
{
mxEvent: payload.event,
},
"mx_Dialog_reportEvent",
);
break;
case Action.OpenSpacePreferences:
Modal.createDialog(
SpacePreferencesDialog,
{
space: payload.space,
},
undefined,
false,
true,
);
break;
case Action.OpenSpaceSettings:
Modal.createDialog(
SpaceSettingsDialog,
{
matrixClient: payload.space.client,
space: payload.space,
},
/*className=*/ undefined,
/*isPriority=*/ false,
/*isStatic=*/ true,
);
break;
case Action.OpenInviteDialog:
Modal.createDialog(
InviteDialog,
{
kind: payload.kind,
call: payload.call,
roomId: payload.roomId,
} as Omit<ComponentProps<typeof InviteDialog>, "onFinished">,
classnames("mx_InviteDialog_flexWrapper", payload.className),
false,
true,
).finished.then((results) => {
payload.onFinishedCallback?.(results);
});
break;
case Action.OpenAddToExistingSpaceDialog: {
const space = payload.space;
const { finished } = Modal.createDialog(
AddExistingToSpaceDialog,
{
onCreateRoomClick: (ev: ButtonEvent) => {
showCreateNewRoom(space);
PosthogTrackers.trackInteraction("WebAddExistingToSpaceDialogCreateRoomButton", ev);
},
onAddSubspaceClick: () => showAddExistingSubspace(space),
space,
},
"mx_AddExistingToSpaceDialog_wrapper",
);
finished.then(([added]) => {
if (added && SdkContextClass.instance.roomViewStore.getRoomId() === space.roomId) {
defaultDispatcher.fire(Action.UpdateSpaceHierarchy);
}
});
break;
}
}
};
}
+11
View File
@@ -0,0 +1,11 @@
/*
Copyright 2024 New Vector Ltd.
Copyright 2018-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 IProtocol } from "matrix-js-sdk/src/matrix";
export type Protocols = Record<string, IProtocol>;
+45
View File
@@ -0,0 +1,45 @@
/*
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 MatrixEvent } from "matrix-js-sdk/src/matrix";
import { type SerializedPart } from "../editor/parts";
import type DocumentOffset from "../editor/offset";
/**
* Used while editing, to pass the event, and to preserve editor state
* from one editor instance to another when remounting the editor
* upon receiving the remote echo for an unsent event.
*/
export default class EditorStateTransfer {
private serializedParts: SerializedPart[] | null = null;
private caret: DocumentOffset | null = null;
public constructor(private readonly event: MatrixEvent) {}
public setEditorState(caret: DocumentOffset | null, serializedParts: SerializedPart[]): void {
this.caret = caret;
this.serializedParts = serializedParts;
}
public hasEditorState(): boolean {
return !!this.serializedParts;
}
public getSerializedParts(): SerializedPart[] | null {
return this.serializedParts;
}
public getCaret(): DocumentOffset | null {
return this.caret;
}
public getEvent(): MatrixEvent {
return this.event;
}
}
+220
View File
@@ -0,0 +1,220 @@
/*
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 React, { type ReactNode } from "react";
import { MatrixError, ConnectionError } from "matrix-js-sdk/src/matrix";
import { logger } from "matrix-js-sdk/src/logger";
import { _t, _td, lookupString, type Tags, type TranslatedString } from "../languageHandler";
import SdkConfig from "../SdkConfig";
import { type ValidatedServerConfig } from "./ValidatedServerConfig";
import ExternalLink from "../components/views/elements/ExternalLink";
import Modal from "../Modal.tsx";
import ErrorDialog from "../components/views/dialogs/ErrorDialog.tsx";
export const resourceLimitStrings = {
"monthly_active_user": _td("error|mau"),
"hs_blocked": _td("error|hs_blocked"),
"": _td("error|resource_limits"),
};
export const adminContactStrings = {
"": _td("error|admin_contact"),
};
/**
* Produce a translated error message for a
* M_RESOURCE_LIMIT_EXCEEDED error
*
* @param {string} limitType The limit_type from the error
* @param {string} adminContact The admin_contact from the error
* @param {Object} strings Translatable string for different
* limit_type. Must include at least the empty string key
* which is the default. Strings may include an 'a' tag
* for the admin contact link.
* @param {Object} extraTranslations Extra translation substitution functions
* for any tags in the strings apart from 'a'
* @returns {*} Translated string or react component
*/
export function messageForResourceLimitError(
limitType: string | undefined,
adminContact: string | undefined,
strings: Record<string, TranslationKey>,
extraTranslations?: Tags,
): TranslatedString {
let errString = limitType ? strings[limitType] : undefined;
if (errString === undefined) errString = strings[""];
const linkSub = (sub: string): ReactNode => {
if (adminContact) {
return (
<a href={adminContact} target="_blank" rel="noreferrer noopener">
{sub}
</a>
);
} else {
return sub;
}
};
if (lookupString(errString).includes("<a>")) {
return _t(errString, {}, Object.assign({ a: linkSub }, extraTranslations));
} else {
return _t(errString, {}, extraTranslations!);
}
}
export function messageForSyncError(err: Error): ReactNode {
if (err instanceof MatrixError && err.errcode === "M_RESOURCE_LIMIT_EXCEEDED") {
const limitError = messageForResourceLimitError(
err.data.limit_type,
err.data.admin_contact,
resourceLimitStrings,
);
const adminContact = messageForResourceLimitError(
err.data.limit_type,
err.data.admin_contact,
adminContactStrings,
);
return (
<div>
<div>{limitError}</div>
<div>{adminContact}</div>
</div>
);
} else {
return <div>{_t("error|sync")}</div>;
}
}
export function messageForLoginError(
err: MatrixError,
serverConfig: Pick<ValidatedServerConfig, "hsName" | "hsUrl">,
): ReactNode {
if (err.errcode === "M_RESOURCE_LIMIT_EXCEEDED") {
const errorTop = messageForResourceLimitError(
err.data.limit_type,
err.data.admin_contact,
resourceLimitStrings,
);
const errorDetail = messageForResourceLimitError(
err.data.limit_type,
err.data.admin_contact,
adminContactStrings,
);
return (
<div>
<div>{errorTop}</div>
<div className="mx_Login_smallError">{errorDetail}</div>
</div>
);
} else if (err.httpStatus === 401 || err.httpStatus === 403) {
if (err.errcode === "M_USER_DEACTIVATED") {
return _t("auth|account_deactivated");
} else if (SdkConfig.get("disable_custom_urls")) {
return (
<div>
<div>{_t("auth|incorrect_credentials")}</div>
<div className="mx_Login_smallError">
{_t("auth|incorrect_credentials_detail", {
hs: serverConfig.hsName,
})}
</div>
</div>
);
} else {
return _t("auth|incorrect_credentials");
}
} else {
return messageForConnectionError(err, serverConfig);
}
}
export function messageForConnectionError(
err: Error,
serverConfig: Pick<ValidatedServerConfig, "hsName" | "hsUrl">,
): ReactNode {
let errorText = _t("error|connection");
if (err instanceof ConnectionError) {
if (
window.location.protocol === "https:" &&
(serverConfig.hsUrl.startsWith("http:") || !serverConfig.hsUrl.startsWith("http"))
) {
return (
<span>
{_t(
"error|mixed_content",
{},
{
a: (sub) => {
return (
<a
target="_blank"
rel="noreferrer noopener"
href="https://www.google.com/search?&q=enable%20unsafe%20scripts"
>
{sub}
</a>
);
},
},
)}
</span>
);
}
return (
<span>
{_t(
"error|tls",
{},
{
a: (sub) => (
<ExternalLink target="_blank" rel="noreferrer noopener" href={serverConfig.hsUrl}>
{sub}
</ExternalLink>
),
},
)}
</span>
);
} else if (err instanceof MatrixError) {
if (err.errcode) {
errorText += `(${err.errcode})`;
} else if (err.httpStatus) {
errorText += ` (HTTP ${err.httpStatus})`;
}
}
return errorText;
}
/**
* Utility for handling unexpected errors: pops up the error dialog.
*
* Example usage:
* ```
* try {
* /// complicated operation
* } catch (e) {
* logErrorAndShowErrorDialog("Failed complicated operation", e);
* }
* ```
*
* This isn't particularly intended to be pretty; rather it lets the user know that *something* has gone wrong so that
* they can report a bug. The general idea is that it's better to let the user know of a failure, even if they
* can't do anything about it, than it is to fail silently with the appearance of success.
*
* @param title - Title for the error dialog.
* @param error - The thrown error. Becomes the content of the error dialog.
*/
export function logErrorAndShowErrorDialog(title: string, error: any): void {
logger.error(`${title}:`, error);
Modal.createDialog(ErrorDialog, { title, description: `${error}` });
}
@@ -0,0 +1,50 @@
/*
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.
*/
import React, { type JSX, type PropsWithChildren, useMemo } from "react";
import { EventPresentationProvider, type EventLayout, type EventPresentation } from "@element-hq/web-shared-components";
import { Layout } from "../settings/enums/Layout";
import { useSettingValue } from "../hooks/useSettings";
const EVENT_LAYOUT_BY_APP_LAYOUT: Record<Layout, EventLayout> = {
[Layout.Bubble]: "bubble",
[Layout.Group]: "group",
[Layout.IRC]: "irc",
};
function getEventDensity(layout: Layout, useCompactLayout: boolean): EventPresentation["density"] {
return useCompactLayout && layout === Layout.Group ? "compact" : "default";
}
/** Converts app/web layout settings into shared event presentation settings. */
export function getEventPresentation(layout: Layout, useCompactLayout: boolean): EventPresentation {
return {
layout: EVENT_LAYOUT_BY_APP_LAYOUT[layout],
density: getEventDensity(layout, useCompactLayout),
};
}
/** Props for the app/web event presentation context provider. */
export interface EventPresentationContextProviderProps {
/** Layout selected by the app/web surface rendering the timeline. */
layout: Layout;
}
/** Provides shared event presentation using app/web-owned layout settings. */
export function EventPresentationContextProvider({
layout,
children,
}: Readonly<PropsWithChildren<EventPresentationContextProviderProps>>): JSX.Element {
// Compact density is still owned by app/web; this exposes it as shared event presentation.
const useCompactLayout = useSettingValue("useCompactLayout");
const eventLayout = EVENT_LAYOUT_BY_APP_LAYOUT[layout];
const density = getEventDensity(layout, useCompactLayout);
const value = useMemo<EventPresentation>(() => ({ layout: eventLayout, density }), [eventLayout, density]);
return <EventPresentationProvider value={value}>{children}</EventPresentationProvider>;
}
+116
View File
@@ -0,0 +1,116 @@
/*
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 MatrixEvent,
type IContent,
type MatrixClient,
EventType,
MsgType,
M_POLL_END,
M_POLL_START,
M_BEACON_INFO,
} from "matrix-js-sdk/src/matrix";
import SettingsStore from "../settings/SettingsStore";
import { haveRendererForEvent, JitsiEventFactory, JSONEventFactory, pickFactory } from "../events/EventTileFactory";
import { getMessageModerationState, isLocationEvent, MessageModerationState } from "./EventUtils";
import { ElementCallEventType } from "../call-types";
const calcIsInfoMessage = (
eventType: EventType | string,
content: IContent,
isBubbleMessage: boolean,
isLeftAlignedBubbleMessage: boolean,
): boolean => {
return (
!isBubbleMessage &&
!isLeftAlignedBubbleMessage &&
eventType !== EventType.RoomMessage &&
eventType !== EventType.RoomMessageEncrypted &&
eventType !== EventType.Sticker &&
eventType !== EventType.RoomCreate &&
!M_POLL_START.matches(eventType) &&
!M_POLL_END.matches(eventType) &&
!M_BEACON_INFO.matches(eventType)
);
};
export function getEventDisplayInfo(
matrixClient: MatrixClient,
mxEvent: MatrixEvent,
showHiddenEvents: boolean,
hideEvent?: boolean,
): {
isInfoMessage: boolean;
hasRenderer: boolean;
isBubbleMessage: boolean;
isLeftAlignedBubbleMessage: boolean;
noBubbleEvent: boolean;
isSeeingThroughMessageHiddenForModeration: boolean;
} {
const content = mxEvent.getContent();
const msgtype = content.msgtype;
const eventType = mxEvent.getType();
let isSeeingThroughMessageHiddenForModeration = false;
if (SettingsStore.getValue("feature_msc3531_hide_messages_pending_moderation")) {
switch (getMessageModerationState(mxEvent, matrixClient)) {
case MessageModerationState.VISIBLE_FOR_ALL:
case MessageModerationState.HIDDEN_TO_CURRENT_USER:
// Nothing specific to do here
break;
case MessageModerationState.SEE_THROUGH_FOR_CURRENT_USER:
// Show message with a marker.
isSeeingThroughMessageHiddenForModeration = true;
break;
}
}
let factory = pickFactory(mxEvent, matrixClient, showHiddenEvents);
// Info messages are basically information about commands processed on a room
let isBubbleMessage =
eventType.startsWith("m.key.verification") ||
(eventType === EventType.RoomMessage && msgtype?.startsWith("m.key.verification")) ||
eventType === EventType.RoomCreate ||
eventType === EventType.RoomEncryption ||
factory === JitsiEventFactory;
const isLeftAlignedBubbleMessage =
!isBubbleMessage && (eventType === EventType.CallInvite || ElementCallEventType.matches(eventType));
let isInfoMessage = calcIsInfoMessage(eventType, content, isBubbleMessage, isLeftAlignedBubbleMessage);
// Some non-info messages want to be rendered in the appropriate bubble column but without the bubble background
const noBubbleEvent =
(eventType === EventType.RoomMessage && msgtype === MsgType.Emote) ||
M_POLL_START.matches(eventType) ||
M_BEACON_INFO.matches(eventType) ||
isLocationEvent(mxEvent);
// If we're showing hidden events in the timeline, we should use the
// source tile when there's no regular tile for an event and also for
// replace relations (which otherwise would display as a confusing
// duplicate of the thing they are replacing).
if (hideEvent || !haveRendererForEvent(mxEvent, matrixClient, showHiddenEvents)) {
// forcefully ask for a factory for a hidden event (hidden event setting is checked internally)
factory = pickFactory(mxEvent, matrixClient, showHiddenEvents, true);
if (factory === JSONEventFactory) {
isBubbleMessage = false;
// Reuse info message avatar and sender profile styling
isInfoMessage = true;
}
}
return {
hasRenderer: !!factory,
isInfoMessage,
isBubbleMessage,
isLeftAlignedBubbleMessage,
noBubbleEvent,
isSeeingThroughMessageHiddenForModeration,
};
}
+293
View File
@@ -0,0 +1,293 @@
/*
Copyright 2024 New Vector Ltd.
Copyright 2019-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 {
EventStatus,
MatrixEvent,
EventType,
EVENT_VISIBILITY_CHANGE_TYPE,
MsgType,
RelationType,
type MatrixClient,
THREAD_RELATION_TYPE,
M_POLL_END,
M_POLL_START,
M_LOCATION,
M_BEACON_INFO,
} from "matrix-js-sdk/src/matrix";
import { logger } from "matrix-js-sdk/src/logger";
import shouldHideEvent from "../shouldHideEvent";
import { type GetRelationsForEvent } from "../components/views/rooms/EventTile";
import SettingsStore from "../settings/SettingsStore";
import defaultDispatcher from "../dispatcher/dispatcher";
import { type TimelineRenderingType } from "../contexts/RoomContext";
import { launchPollEditor } from "../components/views/messages/MPollBody";
import { Action } from "../dispatcher/actions";
import { type ViewRoomPayload } from "../dispatcher/payloads/ViewRoomPayload";
import { ModuleApi } from "../modules/Api";
/**
* Returns whether an event should allow actions like reply, reactions, edit, etc.
* which effectively checks whether it's a regular message that has been sent and that we
* can display.
*
* @param {MatrixEvent} mxEvent The event to check
* @returns {boolean} true if actionable
*/
export function isContentActionable(mxEvent: MatrixEvent): boolean {
const { status: eventStatus } = mxEvent;
// status is SENT before remote-echo, null after
const isSent = !eventStatus || eventStatus === EventStatus.SENT;
if (isSent && !mxEvent.isRedacted()) {
if (mxEvent.getType() === "m.room.message") {
const content = mxEvent.getContent();
if (content.msgtype && content.msgtype !== "m.bad.encrypted" && content.hasOwnProperty("body")) {
return true;
}
} else if (
mxEvent.getType() === "m.sticker" ||
M_POLL_START.matches(mxEvent.getType()) ||
M_POLL_END.matches(mxEvent.getType()) ||
M_BEACON_INFO.matches(mxEvent.getType())
) {
return true;
}
}
return false;
}
export function canEditContent(matrixClient: MatrixClient, mxEvent: MatrixEvent): boolean {
const isCancellable = mxEvent.getType() === EventType.RoomMessage || M_POLL_START.matches(mxEvent.getType());
if (
!isCancellable ||
// Editing local echos is not supported(results in send a message that references the local ID).
// We need to ensure the event is not local, and therefore has no send status.
mxEvent.status !== null ||
mxEvent.isRedacted() ||
mxEvent.isRelation(RelationType.Replace) ||
mxEvent.getSender() !== matrixClient.getUserId()
) {
return false;
}
if (ModuleApi.instance.customComponents.getHintsForMessage(mxEvent)?.allowEditingEvent === false) {
return false;
}
const { msgtype, body } = mxEvent.getOriginalContent();
return (
M_POLL_START.matches(mxEvent.getType()) ||
((msgtype === MsgType.Text || msgtype === MsgType.Emote) && !!body && typeof body === "string")
);
}
export function canEditOwnEvent(matrixClient: MatrixClient, mxEvent: MatrixEvent): boolean {
// for now we only allow editing
// your own events. So this just call through
// In the future though, moderators will be able to
// edit other people's messages as well but we don't
// want findEditableEvent to return other people's events
// hence this method.
return canEditContent(matrixClient, mxEvent);
}
const MAX_JUMP_DISTANCE = 100;
export function findEditableEvent({
matrixClient,
events,
isForward,
fromEventId,
}: {
matrixClient: MatrixClient;
events: MatrixEvent[];
isForward: boolean;
fromEventId?: string;
}): MatrixEvent | undefined {
if (!events.length) return;
const maxIdx = events.length - 1;
const inc = isForward ? 1 : -1;
const beginIdx = isForward ? 0 : maxIdx;
let endIdx = isForward ? maxIdx : 0;
if (!fromEventId) {
endIdx = Math.min(Math.max(0, beginIdx + inc * MAX_JUMP_DISTANCE), maxIdx);
}
let foundFromEventId = !fromEventId;
for (let i = beginIdx; i !== endIdx + inc; i += inc) {
const e = events[i];
// find start event first
if (!foundFromEventId && e.getId() === fromEventId) {
foundFromEventId = true;
// don't look further than MAX_JUMP_DISTANCE events from `fromEventId`
// to not iterate potentially 1000nds of events on key up/down
endIdx = Math.min(Math.max(0, i + inc * MAX_JUMP_DISTANCE), maxIdx);
} else if (foundFromEventId && !shouldHideEvent(e) && canEditOwnEvent(matrixClient, e)) {
// otherwise look for editable event
return e;
}
}
}
/**
* How we should render a message depending on its moderation state.
*/
export enum MessageModerationState {
/**
* The message is visible to all.
*/
VISIBLE_FOR_ALL = "VISIBLE_FOR_ALL",
/**
* The message is hidden pending moderation and we're not a user who should
* see it nevertheless.
*/
HIDDEN_TO_CURRENT_USER = "HIDDEN_TO_CURRENT_USER",
/**
* The message is hidden pending moderation and we're either the author of
* the message or a moderator. In either case, we need to see the message
* with a marker.
*/
SEE_THROUGH_FOR_CURRENT_USER = "SEE_THROUGH_FOR_CURRENT_USER",
}
// This is lazily initialized and cached since getMessageModerationState needs it,
// and is called on timeline rendering hot-paths
let msc3531Enabled: boolean | null = null;
const getMsc3531Enabled = (): boolean => {
if (msc3531Enabled === null) {
msc3531Enabled = SettingsStore.getValue("feature_msc3531_hide_messages_pending_moderation");
}
return msc3531Enabled!;
};
/**
* Determine whether a message should be displayed as hidden pending moderation.
*
* If MSC3531 is deactivated in settings, all messages are considered visible
* to all.
*/
export function getMessageModerationState(mxEvent: MatrixEvent, client: MatrixClient): MessageModerationState {
if (!getMsc3531Enabled()) {
return MessageModerationState.VISIBLE_FOR_ALL;
}
const visibility = mxEvent.messageVisibility();
if (visibility.visible) {
return MessageModerationState.VISIBLE_FOR_ALL;
}
// At this point, we know that the message is marked as hidden
// pending moderation. However, if we're the author or a moderator,
// we still need to display it.
if (mxEvent.sender?.userId === client.getUserId()) {
// We're the author, show the message.
return MessageModerationState.SEE_THROUGH_FOR_CURRENT_USER;
}
const room = client.getRoom(mxEvent.getRoomId());
if (
EVENT_VISIBILITY_CHANGE_TYPE.name &&
room?.currentState.maySendStateEvent(EVENT_VISIBILITY_CHANGE_TYPE.name, client.getUserId()!)
) {
// We're a moderator (as indicated by prefixed event name), show the message.
return MessageModerationState.SEE_THROUGH_FOR_CURRENT_USER;
}
if (
EVENT_VISIBILITY_CHANGE_TYPE.altName &&
room?.currentState.maySendStateEvent(EVENT_VISIBILITY_CHANGE_TYPE.altName, client.getUserId()!)
) {
// We're a moderator (as indicated by unprefixed event name), show the message.
return MessageModerationState.SEE_THROUGH_FOR_CURRENT_USER;
}
// For everybody else, hide the message.
return MessageModerationState.HIDDEN_TO_CURRENT_USER;
}
export function isVoiceMessage(mxEvent: MatrixEvent): boolean {
const content = mxEvent.getContent();
// MSC2516 is a legacy identifier. See https://github.com/matrix-org/matrix-doc/pull/3245
return !!content["org.matrix.msc2516.voice"] || !!content["org.matrix.msc3245.voice"];
}
export async function fetchInitialEvent(
client: MatrixClient,
roomId: string,
eventId: string,
): Promise<MatrixEvent | null> {
let initialEvent: MatrixEvent | null;
try {
const eventData = await client.fetchRoomEvent(roomId, eventId);
initialEvent = new MatrixEvent(eventData);
} catch {
logger.warn("Could not find initial event: " + eventId);
initialEvent = null;
}
if (client.supportsThreads() && initialEvent?.isRelation(THREAD_RELATION_TYPE.name) && !initialEvent.getThread()) {
const threadId = initialEvent.threadRootId!;
const room = client.getRoom(roomId);
const mapper = client.getEventMapper();
const rootEvent = room?.findEventById(threadId) ?? mapper(await client.fetchRoomEvent(roomId, threadId));
try {
room?.createThread(threadId, rootEvent, [initialEvent], true);
} catch {
logger.warn("Could not find root event: " + threadId);
}
}
return initialEvent;
}
export function editEvent(
matrixClient: MatrixClient,
mxEvent: MatrixEvent,
timelineRenderingType: TimelineRenderingType,
getRelationsForEvent?: GetRelationsForEvent,
): void {
if (!canEditContent(matrixClient, mxEvent)) return;
if (M_POLL_START.matches(mxEvent.getType())) {
launchPollEditor(mxEvent, getRelationsForEvent);
} else {
defaultDispatcher.dispatch({
action: Action.EditEvent,
event: mxEvent,
timelineRenderingType: timelineRenderingType,
});
}
}
export function canCancel(status?: EventStatus | null): boolean {
return status === EventStatus.QUEUED || status === EventStatus.NOT_SENT || status === EventStatus.ENCRYPTING;
}
export const isLocationEvent = (event: MatrixEvent): boolean => {
const eventType = event.getType();
return (
M_LOCATION.matches(eventType) ||
(eventType === EventType.RoomMessage && M_LOCATION.matches(event.getContent().msgtype!))
);
};
export function hasThreadSummary(event: MatrixEvent): boolean {
return event.isThreadRoot && !!event.getThread()?.length && !!event.getThread()!.replyToEvent;
}
export const highlightEvent = (roomId: string, eventId: string): void => {
defaultDispatcher.dispatch<ViewRoomPayload>({
action: Action.ViewRoom,
event_id: eventId,
highlighted: true,
room_id: roomId,
metricsTrigger: undefined, // room doesn't change
});
};
+17
View File
@@ -0,0 +1,17 @@
/*
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 { BugReportEndpointURLLocal } from "../IConfigOptions";
import SdkConfig from "../SdkConfig";
import SettingsStore from "../settings/SettingsStore";
import { UIFeature } from "../settings/UIFeature";
export function shouldShowFeedback(): boolean {
const url = SdkConfig.get().bug_report_endpoint_url;
return !!url && url !== BugReportEndpointURLLocal && SettingsStore.getValue(UIFeature.Feedback);
}
+96
View File
@@ -0,0 +1,96 @@
/*
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.
*/
export type GetIframeFn = () => HTMLIFrameElement | null;
export const DEFAULT_STYLES = {
imgSrc: "",
imgStyle: null as string | null, // css props
style: "",
textContent: "",
};
type DownloadOptions = {
blob: Blob;
name: string;
autoDownload?: boolean;
opts?: typeof DEFAULT_STYLES;
};
// set up the iframe as a singleton so we don't have to figure out destruction of it down the line.
let managedIframe: HTMLIFrameElement;
let onLoadPromise: Promise<void>;
function getManagedIframe(): { iframe: HTMLIFrameElement; onLoadPromise: Promise<void> } {
if (managedIframe) return { iframe: managedIframe, onLoadPromise };
managedIframe = document.createElement("iframe");
// Need to append the iframe in order for the browser to load it.
document.body.appendChild(managedIframe);
// Dev note: the reassignment warnings are entirely incorrect here.
managedIframe.style.display = "none";
// @ts-ignore
// noinspection JSConstantReassignment
managedIframe.sandbox = "allow-scripts allow-downloads";
onLoadPromise = new Promise((resolve) => {
managedIframe.onload = () => {
resolve();
};
managedIframe.src = "usercontent/"; // XXX: Should come from the skin
});
return { iframe: managedIframe, onLoadPromise };
}
// TODO: If we decide to keep the download link behaviour, we should bring the style management into here.
/**
* Helper to handle safe file downloads. This operates off an iframe for reasons described
* by the blob helpers. By default, this will use a hidden iframe to manage the download
* through a user content wrapper, but can be given an iframe reference if the caller needs
* additional control over the styling/position of the iframe itself.
*/
export class FileDownloader {
private onLoadPromise?: Promise<void>;
/**
* Creates a new file downloader
* @param iframeFn Function to get a pre-configured iframe. Set to null to have the downloader
* use a generic, hidden, iframe.
*/
public constructor(private iframeFn?: GetIframeFn) {}
private get iframe(): HTMLIFrameElement {
const iframe = this.iframeFn?.();
if (!iframe) {
const managed = getManagedIframe();
this.onLoadPromise = managed.onLoadPromise;
return managed.iframe;
}
this.onLoadPromise = undefined;
return iframe;
}
public async download({ blob, name, autoDownload = true, opts = DEFAULT_STYLES }: DownloadOptions): Promise<void> {
const iframe = this.iframe; // get the iframe first just in case we need to await onload
if (this.onLoadPromise) await this.onLoadPromise;
iframe.contentWindow?.postMessage(
{
...opts,
blob: blob,
download: name,
auto: autoDownload,
},
"*",
);
}
}
+96
View File
@@ -0,0 +1,96 @@
/*
Copyright 2024 New Vector Ltd.
Copyright 2021 Šimon Brandner <simon.bra.ag@gmail.com>
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 { filesize, type FilesizeOptions, type FilesizeReturn } from "filesize";
import { type MediaEventContent } from "matrix-js-sdk/src/types";
import { _t } from "../languageHandler";
export function downloadLabelForFile(content: MediaEventContent, withSize = true): string {
let text = _t("action|download");
if (content.info?.size && withSize) {
// If we know the size of the file then add it as human-readable string to the end of the link text
// so that the user knows how big a file they are downloading.
text += " (" + fileSize(content.info.size, { base: 2, standard: "jedec" }) + ")";
}
return text;
}
/**
* Extracts a human-readable label for the file attachment to use as
* link text.
*
* @param {MediaEventContent} content The "content" key of the matrix event.
* @param {string} fallbackText The fallback text
* @param {boolean} withSize Whether to include size information. Default true.
* @param {boolean} shortened Ensure the extension of the file name is visible. Default false.
* @return {string} the human-readable link text for the attachment.
*/
export function presentableTextForFile(
content: MediaEventContent,
fallbackText = _t("common|attachment"),
withSize = true,
shortened = false,
): string {
let text = fallbackText;
if (content.filename?.length) {
text = content.filename;
} else if (content.body?.length) {
// The content body should be the name of the file including a
// file extension.
text = content.body;
}
// We shorten to 15 characters somewhat arbitrarily, and assume most files
// will have a 3 character (plus full stop) extension. The goal is to knock
// the label down to 15-25 characters, not perfect accuracy.
if (shortened && text.length > 19) {
const parts = text.split(".");
let fileName = parts
.slice(0, parts.length - 1)
.join(".")
.substring(0, 15);
const extension = parts[parts.length - 1];
// Trim off any full stops from the file name to avoid a case where we
// add an ellipsis that looks really funky.
fileName = fileName.replace(/\.*$/g, "");
text = `${fileName}...${extension}`;
}
if (content.info?.size && withSize) {
// If we know the size of the file then add it as human readable
// string to the end of the link text so that the user knows how
// big a file they are downloading.
// The content.info also contains a MIME-type but we don't display
// it since it is "ugly", users generally aren't aware what it
// means and the type of the attachment can usually be inferred
// from the file extension.
text += " (" + fileSize(content.info.size, { base: 2, standard: "jedec" }) + ")";
}
return text;
}
/**
* wrapper function to set default values for filesize function
*
* @param byteCount size of file
* @param options options to customize the response type or size type conversion e.g. 12kB, 12KB
* @returns {string | number | any[] | {
* value: any;
* symbol: any;
* exponent: number;
* unit: string;}} formatted file size with unit e.g. 12kB, 12KB
*/
export function fileSize<O extends FilesizeOptions>(byteCount: number, options?: O): FilesizeReturn<O> {
const defaultOption = { base: 2, standard: "jedec", ...options } as O;
return filesize<O>(byteCount, defaultOption);
}
+49
View File
@@ -0,0 +1,49 @@
/*
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 { arrayFastClone, arraySeed } from "./arrays";
/**
* An array which is of fixed length and accepts rolling values. Values will
* be inserted on the left, falling off the right.
*/
export class FixedRollingArray<T> {
private samples: T[] = [];
/**
* Creates a new fixed rolling array.
* @param width The width of the array.
* @param padValue The value to seed the array with.
*/
public constructor(
private width: number,
padValue: T,
) {
this.samples = arraySeed(padValue, this.width);
}
/**
* The array, as a fixed length.
*/
public get value(): T[] {
return this.samples;
}
/**
* Pushes a value to the array.
* @param value The value to push.
*/
public pushValue(value: T): void {
let swap = arrayFastClone(this.samples);
swap.splice(0, 0, value);
if (swap.length > this.width) {
swap = swap.slice(0, this.width);
}
this.samples = swap;
}
}
+101
View File
@@ -0,0 +1,101 @@
/*
Copyright 2024 New Vector Ltd.
Copyright 2019, 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 ReactElement, type ReactNode } from "react";
import { useIdColorHash } from "@vector-im/compound-web";
import { _t, getCurrentLanguage, getUserLanguage } from "../languageHandler";
import { jsxJoin } from "./ReactUtils";
export { formatBytes } from "@element-hq/web-shared-components";
const locale = getCurrentLanguage();
// It's quite costly to instanciate `Intl.NumberFormat`, hence why we do not do
// it in every function call
const compactFormatter = new Intl.NumberFormat(locale, {
notation: "compact",
});
/**
* formats and rounds numbers to fit into ~3 characters, suitable for badge counts
* e.g: 999, 10K, 99K, 1M, 10M, 99M, 1B, 10B, ...
*/
export function formatCount(count: number): string {
return compactFormatter.format(count);
}
// It's quite costly to instanciate `Intl.NumberFormat`, hence why we do not do
// it in every function call
const formatter = new Intl.NumberFormat(locale);
/**
* Format a count showing the whole number but making it a bit more readable.
* e.g: 1000 => 1,000
*/
export function formatCountLong(count: number): string {
return formatter.format(count);
}
export function getUserNameColorClass(userId: string): string {
// eslint-disable-next-line react-hooks/rules-of-hooks
const number = useIdColorHash(userId);
return `mx_Username_color${number}`;
}
/**
* Constructs a written English string representing `items`, with an optional
* limit on the number of items included in the result. If specified and if the
* length of `items` is greater than the limit, the string "and n others" will
* be appended onto the result. If `items` is empty, returns the empty string.
* If there is only one item, return it.
* @param {string[]} items the items to construct a string from.
* @param {number?} itemLimit the number by which to limit the list.
* @returns {string} a string constructed by joining `items` with a comma
* between each item, but with the last item appended as " and [lastItem]".
*/
export function formatList(items: string[], itemLimit?: number, includeCount?: boolean): string;
export function formatList(items: ReactElement[], itemLimit?: number, includeCount?: boolean): ReactElement;
export function formatList(items: ReactNode[], itemLimit?: number, includeCount?: boolean): ReactNode;
export function formatList(items: ReactNode[], itemLimit = items.length, includeCount = false): ReactNode {
let remaining = Math.max(items.length - itemLimit, 0);
if (items.length <= 1) {
return items[0] ?? "";
}
const formatter = new Intl.ListFormat(getUserLanguage(), { style: "long", type: "conjunction" });
if (remaining > 0) {
if (includeCount) {
itemLimit--;
remaining++;
}
items = items.slice(0, itemLimit);
let joinedItems: ReactNode;
if (items.every((e) => typeof e === "string")) {
joinedItems = items.join(", ");
} else {
joinedItems = jsxJoin(items, ", ");
}
return _t("items_and_n_others", { count: remaining }, { Items: () => joinedItems });
}
if (items.every((e) => typeof e === "string")) {
return formatter.format(items as string[]);
}
const parts = formatter.formatToParts(items.map((_, i) => `${i}`));
return jsxJoin(
parts.map((part) => {
if (part.type === "literal") return part.value;
return items[parseInt(part.value, 10)];
}),
);
}
+11
View File
@@ -0,0 +1,11 @@
/*
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 interface IDestroyable {
destroy(): void;
}
+45
View File
@@ -0,0 +1,45 @@
/*
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 { SERVICE_TYPES, HTTPError, type MatrixClient, type Terms } from "matrix-js-sdk/src/matrix";
import { logger } from "matrix-js-sdk/src/logger";
import SdkConfig from "../SdkConfig";
export function getDefaultIdentityServerUrl(): string | undefined {
return SdkConfig.get("validated_server_config")?.isUrl;
}
export function setToDefaultIdentityServer(matrixClient: MatrixClient): void {
const url = getDefaultIdentityServerUrl();
// Account data change will update localstorage, client, etc through dispatcher
matrixClient.setAccountData("m.identity_server", {
base_url: url ?? null,
});
}
export async function doesIdentityServerHaveTerms(matrixClient: MatrixClient, fullUrl: string): Promise<boolean> {
let terms: Partial<Terms> | null;
try {
terms = await matrixClient.getTerms(SERVICE_TYPES.IS, fullUrl);
} catch (e) {
logger.error(e);
if (e instanceof HTTPError && e.httpStatus === 404) {
terms = null;
} else {
throw e;
}
}
return !!terms?.["policies"] && Object.keys(terms["policies"]).length > 0;
}
export function doesAccountDataHaveIdentityServer(matrixClient: MatrixClient): boolean {
const event = matrixClient.getAccountData("m.identity_server");
return event?.getContent()["base_url"];
}
+125
View File
@@ -0,0 +1,125 @@
/*
* 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 { arrayHasDiff } from "./arrays";
export function mayBeAnimated(mimeType?: string): boolean {
return ["image/gif", "image/webp", "image/png", "image/apng", "image/avif"].includes(mimeType!);
}
function arrayBufferRead(arr: ArrayBuffer, start: number, len: number): Uint8Array {
return new Uint8Array(arr.slice(start, start + len));
}
function arrayBufferReadInt(arr: ArrayBuffer, start: number): number {
const dv = new DataView(arr, start, 4);
return dv.getUint32(0);
}
function arrayBufferReadStr(arr: ArrayBuffer, start: number, len: number): string {
return String.fromCharCode.apply(null, Array.from(arrayBufferRead(arr, start, len)));
}
/**
* Check if a Blob contains an animated image.
* @param blob The Blob to check.
* @returns True if the image is animated, false if not, or undefined if it could not be determined.
*/
export async function blobIsAnimated(blob: Blob): Promise<boolean | undefined> {
try {
// Try parse the image using ImageDecoder as this is the most coherent way of asserting whether a piece of media
// is or is not animated. Limited availability at time of writing, notably Safari lacks support.
// https://developer.mozilla.org/en-US/docs/Web/API/ImageDecoder
const data = await blob.arrayBuffer();
const decoder = new ImageDecoder({ data, type: blob.type });
await decoder.tracks.ready;
if ([...decoder.tracks].some((track) => track.animated)) {
return true;
}
} catch (e) {
console.warn("ImageDecoder not supported or failed to decode image", e);
// Not supported by this browser, fall through to manual checks
}
switch (blob.type) {
case "image/webp": {
// Only extended file format WEBP images support animation, so grab the expected data range and verify header.
// Based on https://developers.google.com/speed/webp/docs/riff_container#extended_file_format
const arr = await blob.slice(0, 21).arrayBuffer();
if (
arrayBufferReadStr(arr, 0, 4) === "RIFF" &&
arrayBufferReadStr(arr, 8, 4) === "WEBP" &&
arrayBufferReadStr(arr, 12, 4) === "VP8X"
) {
const [flags] = arrayBufferRead(arr, 20, 1);
// Flags: R R I L E X _A_ R (reversed)
const animationFlagMask = 1 << 1;
return (flags & animationFlagMask) != 0;
}
return false;
}
case "image/gif": {
// Based on https://gist.github.com/zakirt/faa4a58cec5a7505b10e3686a226f285
// More info at http://www.matthewflickinger.com/lab/whatsinagif/bits_and_bytes.asp
const dv = new DataView(await blob.arrayBuffer(), 10);
const globalColorTable = dv.getUint8(0);
let globalColorTableSize = 0;
// check first bit, if 0, then we don't have a Global Color Table
if (globalColorTable & 0x80) {
// grab the last 3 bits, to calculate the global color table size -> RGB * 2^(N+1)
// N is the value in the last 3 bits.
globalColorTableSize = 3 * Math.pow(2, (globalColorTable & 0x7) + 1);
}
// move on to the Graphics Control Extension
const offset = 3 + globalColorTableSize;
const extensionIntroducer = dv.getUint8(offset);
const graphicsControlLabel = dv.getUint8(offset + 1);
let delayTime = 0;
// Graphics Control Extension section is where GIF animation data is stored
// First 2 bytes must be 0x21 and 0xF9
if (extensionIntroducer & 0x21 && graphicsControlLabel & 0xf9) {
// skip to the 2 bytes with the delay time
delayTime = dv.getUint16(offset + 4);
}
return !!delayTime;
}
case "image/png":
case "image/apng": {
// Based on https://stackoverflow.com/a/68618296
const arr = await blob.arrayBuffer();
if (
arrayHasDiff([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a], Array.from(arrayBufferRead(arr, 0, 8)))
) {
return false;
}
for (let i = 8; i < blob.size; ) {
const length = arrayBufferReadInt(arr, i);
i += 4;
const type = arrayBufferReadStr(arr, i, 4);
i += 4;
switch (type) {
case "acTL":
return true;
case "IDAT":
return false;
}
i += length + 4;
}
return false;
}
}
}
+24
View File
@@ -0,0 +1,24 @@
/*
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.
*/
type StringifyReplacer = (this: any, key: string, value: any) => any;
// From https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Errors/Cyclic_object_value#circular_references
// Injects `<$ cycle-trimmed $>` wherever it cuts a cyclical object relationship
export const getCircularReplacer = (): StringifyReplacer => {
const seen = new WeakSet();
return (key: string, value: any): any => {
if (typeof value === "object" && value !== null) {
if (seen.has(value)) {
return "<$ cycle-trimmed $>";
}
seen.add(value);
}
return value;
};
};
@@ -0,0 +1,26 @@
/*
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 { type MatrixClient } from "matrix-js-sdk/src/matrix";
import { _t } from "../languageHandler";
export function getNameForEventRoom(matrixClient: MatrixClient, userId: string, roomId: string): string {
const room = matrixClient.getRoom(roomId);
const member = room && room.getMember(userId);
return member ? member.name : userId;
}
export function userLabelForEventRoom(matrixClient: MatrixClient, userId: string, roomId: string): string {
const name = getNameForEventRoom(matrixClient, userId, roomId);
if (name !== userId) {
return _t("name_and_id", { name, userId });
} else {
return userId;
}
}
+48
View File
@@ -0,0 +1,48 @@
/*
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.
*/
/**
* Utility class for lazily getting a variable.
*/
export class LazyValue<T> {
private val?: T;
private prom?: Promise<T>;
private done = false;
public constructor(private getFn: () => Promise<T>) {}
/**
* Whether or not a cached value is present.
*/
public get present(): boolean {
// we use a tracking variable just in case the final value is falsy
return this.done;
}
/**
* Gets the value without invoking a get. May be undefined until the
* value is fetched properly.
*/
public get cachedValue(): T | undefined {
return this.val;
}
/**
* Gets a promise which resolves to the value, eventually.
*/
public get value(): Promise<T> {
if (this.prom) return this.prom;
this.prom = this.getFn();
return this.prom.then((v) => {
this.val = v;
this.done = true;
return v;
});
}
}
+234
View File
@@ -0,0 +1,234 @@
/*
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 { logger } from "matrix-js-sdk/src/logger";
interface CacheItem<K, V> {
key: K;
value: V;
/** Next item in the list */
next: CacheItem<K, V> | null;
/** Previous item in the list */
prev: CacheItem<K, V> | null;
}
/**
* Least Recently Used cache.
* Can be initialised with a capacity and drops the least recently used items.
* This cache should be error robust: Cache miss on error.
*
* Implemented via a key lookup map and a double linked list:
* head tail
* a next → b next → c → next null
* null ← prev a ← prev b ← prev c
*
* @template K - Type of the key used to look up the values inside the cache
* @template V - Type of the values inside the cache
*/
export class LruCache<K, V> {
/** Head of the list. */
private head: CacheItem<K, V> | null = null;
/** Tail of the list */
private tail: CacheItem<K, V> | null = null;
/** Key lookup map */
private map: Map<K, CacheItem<K, V>>;
/**
* @param capacity - Cache capcity.
* @throws {Error} - Raises an error if the cache capacity is less than 1.
*/
public constructor(private capacity: number) {
if (this.capacity < 1) {
throw new Error("Cache capacity must be at least 1");
}
this.map = new Map();
}
/**
* Whether the cache contains an item under this key.
* Marks the item as most recently used.
*
* @param key - Key of the item
* @returns true: item in cache, else false
*/
public has(key: K): boolean {
try {
return this.getItem(key) !== undefined;
} catch (e) {
// Should not happen but makes it more robust to the unknown.
this.onError(e);
return false;
}
}
/**
* Returns an item from the cache.
* Marks the item as most recently used.
*
* @param key - Key of the item
* @returns The value if found, else undefined
*/
public get(key: K): V | undefined {
try {
return this.getItem(key)?.value;
} catch (e) {
// Should not happen but makes it more robust to the unknown.
this.onError(e);
return undefined;
}
}
/**
* Adds an item to the cache.
* A newly added item will be the set as the most recently used.
*
* @param key - Key of the item
* @param value - Item value
*/
public set(key: K, value: V): void {
try {
this.safeSet(key, value);
} catch (e) {
// Should not happen but makes it more robust to the unknown.
this.onError(e);
}
}
/**
* Deletes an item from the cache.
*
* @param key - Key of the item to be removed
*/
public delete(key: K): void {
const item = this.map.get(key);
// Unknown item.
if (!item) return;
try {
this.removeItemFromList(item);
this.map.delete(key);
} catch (e) {
// Should not happen but makes it more robust to the unknown.
this.onError(e);
}
}
/**
* Clears the cache.
*/
public clear(): void {
this.map = new Map();
this.head = null;
this.tail = null;
}
/**
* Returns an iterator over the cached values.
*/
public *values(): IterableIterator<V> {
for (const item of this.map.values()) {
yield item.value;
}
}
private safeSet(key: K, value: V): void {
const item = this.getItem(key);
if (item) {
// The item is already stored under this key. Update the value.
item.value = value;
return;
}
const newItem: CacheItem<K, V> = {
key,
value,
next: null,
prev: null,
};
if (this.head) {
// Put item in front of the list.
this.head.prev = newItem;
newItem.next = this.head;
}
this.setHeadTail(newItem);
// Store item in lookup map.
this.map.set(key, newItem);
if (this.tail && this.map.size > this.capacity) {
// Map size exceeded cache capcity. Drop tail item.
this.delete(this.tail.key);
}
}
private onError(e: unknown): void {
logger.warn("LruCache error", e);
this.clear();
}
private getItem(key: K): CacheItem<K, V> | undefined {
const item = this.map.get(key);
// Not in cache.
if (!item) return undefined;
// Item is already at the head of the list.
// No update required.
if (item === this.head) return item;
this.removeItemFromList(item);
// Put item to the front.
if (this.head) {
this.head.prev = item;
}
item.prev = null;
item.next = this.head;
this.setHeadTail(item);
return item;
}
private setHeadTail(item: CacheItem<K, V>): void {
if (item.prev === null) {
// Item has no previous item → head
this.head = item;
}
if (item.next === null) {
// Item has no next item → tail
this.tail = item;
}
}
private removeItemFromList(item: CacheItem<K, V>): void {
if (item === this.head) {
this.head = item.next;
}
if (item === this.tail) {
this.tail = item.prev;
}
if (item.prev) {
item.prev.next = item.next;
}
if (item.next) {
item.next.prev = item.prev;
}
}
}
+53
View File
@@ -0,0 +1,53 @@
/*
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.
*/
/**
* A utility to ensure that a function is only called once triggered with
* a mark applied. Multiple marks can be applied to the function, however
* the function will only be called once upon trigger().
*
* The function starts unmarked.
*/
export class MarkedExecution {
private marked = false;
/**
* Creates a MarkedExecution for the provided function.
* @param {Function} fn The function to be called upon trigger if marked.
* @param {Function} onMarkCallback A function that is called when a new mark is made. Not
* called if a mark is already flagged.
*/
public constructor(
private fn: () => void,
private onMarkCallback?: () => void,
) {}
/**
* Resets the mark without calling the function.
*/
public reset(): void {
this.marked = false;
}
/**
* Marks the function to be called upon trigger().
*/
public mark(): void {
if (!this.marked) this.onMarkCallback?.();
this.marked = true;
}
/**
* If marked, the function will be called, otherwise this does nothing.
*/
public trigger(): void {
if (!this.marked) return;
this.reset(); // reset first just in case the fn() causes a trigger()
this.fn();
}
}
+45
View File
@@ -0,0 +1,45 @@
/*
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 globToRegexp from "glob-to-regexp";
// Taken with permission from matrix-bot-sdk:
// https://github.com/turt2live/matrix-js-bot-sdk/blob/eb148c2ecec7bf3ade801d73deb43df042d55aef/src/MatrixGlob.ts
/**
* Represents a common Matrix glob. This is commonly used
* for server ACLs and similar functions.
*/
export class MatrixGlob {
private regex: RegExp;
/**
* Creates a new Matrix Glob
* @param {string} glob The glob to convert. Eg: "*.example.org"
*/
public constructor(glob: string) {
const globRegex = globToRegexp(glob, {
extended: false,
globstar: false,
});
// We need to convert `?` manually because globToRegexp's extended mode
// does more than we want it to.
const replaced = globRegex.toString().replace(/\\\?/g, ".");
this.regex = new RegExp(replaced.substring(1, replaced.length - 1));
}
/**
* Tests the glob against a value, returning true if it matches.
* @param {string} val The value to test.
* @returns {boolean} True if the value matches the glob, false otherwise.
*/
public test(val: string): boolean {
return this.regex.test(val);
}
}
+145
View File
@@ -0,0 +1,145 @@
/*
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 MatrixEvent, EventType, MsgType } from "matrix-js-sdk/src/matrix";
import { type FileContent, type ImageContent, type MediaEventContent } from "matrix-js-sdk/src/types";
import { logger } from "matrix-js-sdk/src/logger";
import { LazyValue } from "./LazyValue";
import { type Media, mediaFromContent } from "../customisations/Media";
import { decryptFile } from "./DecryptFile";
import { type IDestroyable } from "./IDestroyable";
import { getBlobSafeMimeType } from "./blobs.ts";
// TODO: We should consider caching the blobs. https://github.com/vector-im/element-web/issues/17192
export class MediaEventHelper implements IDestroyable {
// Either an HTTP or Object URL (when encrypted) to the media.
public readonly sourceUrl: LazyValue<string | null>;
public readonly thumbnailUrl: LazyValue<string | null>;
// Either the raw or decrypted (when encrypted) contents of the file.
public readonly sourceBlob: LazyValue<Blob>;
public readonly thumbnailBlob: LazyValue<Blob | null>;
public readonly media: Media;
public constructor(private event: MatrixEvent) {
this.sourceUrl = new LazyValue(this.prepareSourceUrl);
this.thumbnailUrl = new LazyValue(this.prepareThumbnailUrl);
this.sourceBlob = new LazyValue(this.fetchSource);
this.thumbnailBlob = new LazyValue(this.fetchThumbnail);
this.media = mediaFromContent(this.event.getContent());
}
public get fileName(): string {
return (
this.event.getContent<FileContent>().filename ||
this.event.getContent<MediaEventContent>().body ||
"download"
);
}
public destroy(): void {
if (this.media.isEncrypted) {
if (this.sourceUrl.cachedValue) URL.revokeObjectURL(this.sourceUrl.cachedValue);
if (this.thumbnailUrl.cachedValue) URL.revokeObjectURL(this.thumbnailUrl.cachedValue);
}
}
private prepareSourceUrl = async (): Promise<string | null> => {
if (this.media.isEncrypted) {
const blob = await this.sourceBlob.value;
return URL.createObjectURL(blob);
} else {
return this.media.srcHttp;
}
};
private prepareThumbnailUrl = async (): Promise<string | null> => {
if (this.media.isEncrypted) {
const blob = await this.thumbnailBlob.value;
if (blob === null) return null;
return URL.createObjectURL(blob);
} else {
return this.media.thumbnailHttp;
}
};
private fetchSource = (): Promise<Blob> => {
const content = this.event.getContent<MediaEventContent>();
if (this.media.isEncrypted) {
return decryptFile(content.file!, content.info);
}
return (
this.media
.downloadSource()
.then((r) => r.blob())
// Set the mime type from the event info on the blob
.then((blob) => blob.slice(0, blob.size, getBlobSafeMimeType(content.info?.mimetype ?? blob.type)))
);
};
private fetchThumbnail = (): Promise<Blob | null> => {
if (!this.media.hasThumbnail) return Promise.resolve(null);
const content = this.event.getContent<ImageContent>();
if (this.media.isEncrypted) {
if (content.info?.thumbnail_file) {
return decryptFile(content.info.thumbnail_file, content.info.thumbnail_info);
} else {
// "Should never happen"
logger.warn("Media claims to have thumbnail and is encrypted, but no thumbnail_file found");
return Promise.resolve(null);
}
}
const thumbnailHttp = this.media.thumbnailHttp;
if (!thumbnailHttp) return Promise.resolve(null);
return (
fetch(thumbnailHttp)
.then((r) => r.blob())
// Set the mime type from the event info on the blob
.then((blob) =>
blob.slice(0, blob.size, getBlobSafeMimeType(content.info?.thumbnail_info?.mimetype ?? blob.type)),
)
);
};
public static isEligible(event: MatrixEvent): boolean {
if (!event) return false;
if (event.isRedacted()) return false;
if (event.getType() === EventType.Sticker) return true;
if (event.getType() !== EventType.RoomMessage) return false;
const content = event.getContent();
const mediaMsgTypes: string[] = [MsgType.Video, MsgType.Audio, MsgType.Image, MsgType.File];
if (mediaMsgTypes.includes(content.msgtype!)) return true;
if (typeof content.url === "string") return true;
// Finally, it's probably not media
return false;
}
/**
* Determine if the media event in question supports being hidden in the timeline.
* @param event Any matrix event.
* @returns `true` if the media can be hidden, otherwise `false`.
*/
public static canHide(event: MatrixEvent): boolean {
if (!event) return false;
if (event.isRedacted()) return false;
const content = event.getContent();
const hideTypes: string[] = [MsgType.Video, MsgType.Image];
if (hideTypes.includes(content.msgtype!)) return true;
return false;
}
}
@@ -0,0 +1,356 @@
/*
Copyright 2024 New Vector Ltd.
Copyright 2020 The Matrix.org Foundation C.I.C.
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 { logger } from "matrix-js-sdk/src/logger";
import { _t } from "../languageHandler";
import SdkConfig from "../SdkConfig";
const subtleCrypto = window.crypto.subtle;
/**
* Make an Error object which has a friendlyText property which is already
* translated and suitable for showing to the user.
*
* @param {string} message message for the exception
* @param {string} friendlyText
* @returns {{message: string, friendlyText: string}}
*/
function friendlyError(message: string, friendlyText: string): { message: string; friendlyText: string } {
return { message, friendlyText };
}
function cryptoFailMsg(): string {
return _t("encryption|export_unsupported");
}
/**
* Decrypt a megolm key file
*
* @param {ArrayBuffer} data file to decrypt
* @param {String} password
* @return {Promise<String>} promise for decrypted output
*
*
*/
export async function decryptMegolmKeyFile(data: ArrayBuffer, password: string): Promise<string> {
const body = unpackMegolmKeyFile(data);
const brand = SdkConfig.get().brand;
// check we have a version byte
if (body.length < 1) {
throw friendlyError("Invalid file: too short", _t("encryption|import_invalid_keyfile", { brand }));
}
const version = body[0];
if (version !== 1) {
throw friendlyError("Unsupported version", _t("encryption|import_invalid_keyfile", { brand }));
}
const ciphertextLength = body.length - (1 + 16 + 16 + 4 + 32);
if (ciphertextLength < 0) {
throw friendlyError("Invalid file: too short", _t("encryption|import_invalid_keyfile", { brand }));
}
const salt = body.subarray(1, 1 + 16);
const iv = body.subarray(17, 17 + 16);
const iterations = (body[33] << 24) | (body[34] << 16) | (body[35] << 8) | body[36];
const ciphertext = body.subarray(37, 37 + ciphertextLength);
const hmac = body.subarray(-32);
const [aesKey, hmacKey] = await deriveKeys(salt, iterations, password);
const toVerify = body.subarray(0, -32);
let isValid;
try {
isValid = await subtleCrypto.verify({ name: "HMAC" }, hmacKey, hmac, toVerify);
} catch (e) {
throw friendlyError("subtleCrypto.verify failed: " + e, cryptoFailMsg());
}
if (!isValid) {
throw friendlyError("hmac mismatch", _t("encryption|import_invalid_passphrase"));
}
let plaintext;
try {
plaintext = await subtleCrypto.decrypt(
{
name: "AES-CTR",
counter: iv,
length: 64,
},
aesKey,
ciphertext,
);
} catch (e) {
throw friendlyError("subtleCrypto.decrypt failed: " + e, cryptoFailMsg());
}
return new TextDecoder().decode(new Uint8Array(plaintext));
}
/**
* Encrypt a megolm key file
*
* @param {String} data
* @param {String} password
* @param {Object=} options
* @param {Number=} options.kdf_rounds Number of iterations to perform of the
* key-derivation function.
* @return {Promise<ArrayBuffer>} promise for encrypted output
*/
export async function encryptMegolmKeyFile(
data: string,
password: string,
options?: { kdf_rounds?: number }, // eslint-disable-line camelcase
): Promise<ArrayBuffer> {
options = options || {};
const kdfRounds = options.kdf_rounds || 500000;
const salt = new Uint8Array(16);
window.crypto.getRandomValues(salt);
const iv = new Uint8Array(16);
window.crypto.getRandomValues(iv);
// clear bit 63 of the IV to stop us hitting the 64-bit counter boundary
// (which would mean we wouldn't be able to decrypt on Android). The loss
// of a single bit of iv is a price we have to pay.
iv[8] &= 0x7f;
const [aesKey, hmacKey] = await deriveKeys(salt, kdfRounds, password);
const encodedData = new TextEncoder().encode(data);
let ciphertext;
try {
ciphertext = await subtleCrypto.encrypt(
{
name: "AES-CTR",
counter: iv,
length: 64,
},
aesKey,
encodedData,
);
} catch (e) {
throw friendlyError("subtleCrypto.encrypt failed: " + e, cryptoFailMsg());
}
const cipherArray = new Uint8Array(ciphertext);
const bodyLength = 1 + salt.length + iv.length + 4 + cipherArray.length + 32;
const resultBuffer = new Uint8Array(bodyLength);
let idx = 0;
resultBuffer[idx++] = 1; // version
resultBuffer.set(salt, idx);
idx += salt.length;
resultBuffer.set(iv, idx);
idx += iv.length;
resultBuffer[idx++] = kdfRounds >> 24;
resultBuffer[idx++] = (kdfRounds >> 16) & 0xff;
resultBuffer[idx++] = (kdfRounds >> 8) & 0xff;
resultBuffer[idx++] = kdfRounds & 0xff;
resultBuffer.set(cipherArray, idx);
idx += cipherArray.length;
const toSign = resultBuffer.subarray(0, idx);
let hmac;
try {
hmac = await subtleCrypto.sign({ name: "HMAC" }, hmacKey, toSign);
} catch (e) {
throw friendlyError("subtleCrypto.sign failed: " + e, cryptoFailMsg());
}
const hmacArray = new Uint8Array(hmac);
resultBuffer.set(hmacArray, idx);
return packMegolmKeyFile(resultBuffer);
}
/**
* Derive the AES and HMAC-SHA-256 keys for the file
*
* @param {Unit8Array} salt salt for pbkdf
* @param {Number} iterations number of pbkdf iterations
* @param {String} password password
* @return {Promise<[CryptoKey, CryptoKey]>} promise for [aes key, hmac key]
*/
async function deriveKeys(
salt: Uint8Array<ArrayBuffer>,
iterations: number,
password: string,
): Promise<[CryptoKey, CryptoKey]> {
const start = new Date();
let key;
try {
key = await subtleCrypto.importKey("raw", new TextEncoder().encode(password), { name: "PBKDF2" }, false, [
"deriveBits",
]);
} catch (e) {
throw friendlyError("subtleCrypto.importKey failed: " + e, cryptoFailMsg());
}
let keybits;
try {
keybits = await subtleCrypto.deriveBits(
{
name: "PBKDF2",
salt: salt,
iterations: iterations,
hash: "SHA-512",
},
key,
512,
);
} catch (e) {
throw friendlyError("subtleCrypto.deriveBits failed: " + e, cryptoFailMsg());
}
const now = new Date();
logger.log("E2e import/export: deriveKeys took " + (now.getTime() - start.getTime()) + "ms");
const aesKey = keybits.slice(0, 32);
const hmacKey = keybits.slice(32);
const aesProm = subtleCrypto
.importKey("raw", aesKey, { name: "AES-CTR" }, false, ["encrypt", "decrypt"])
.catch((e) => {
throw friendlyError("subtleCrypto.importKey failed for AES key: " + e, cryptoFailMsg());
});
const hmacProm = subtleCrypto
.importKey(
"raw",
hmacKey,
{
name: "HMAC",
hash: { name: "SHA-256" },
},
false,
["sign", "verify"],
)
.catch((e) => {
throw friendlyError("subtleCrypto.importKey failed for HMAC key: " + e, cryptoFailMsg());
});
return Promise.all([aesProm, hmacProm]);
}
const HEADER_LINE = "-----BEGIN MEGOLM SESSION DATA-----";
const TRAILER_LINE = "-----END MEGOLM SESSION DATA-----";
/**
* Unbase64 an ascii-armoured megolm key file
*
* Strips the header and trailer lines, and unbase64s the content
*
* @param {ArrayBuffer} data input file
* @return {Uint8Array} unbase64ed content
*/
function unpackMegolmKeyFile(data: ArrayBuffer): Uint8Array<ArrayBuffer> {
// parse the file as a great big String. This should be safe, because there
// should be no non-ASCII characters, and it means that we can do string
// comparisons to find the header and footer, and feed it into window.atob.
const fileStr = new TextDecoder().decode(new Uint8Array(data));
// look for the start line
let lineStart = 0;
// eslint-disable-next-line no-constant-condition
while (1) {
const lineEnd = fileStr.indexOf("\n", lineStart);
if (lineEnd < 0) {
throw new Error("Header line not found");
}
const line = fileStr.slice(lineStart, lineEnd).trim();
// start the next line after the newline
lineStart = lineEnd + 1;
if (line === HEADER_LINE) {
break;
}
}
const dataStart = lineStart;
// look for the end line
// eslint-disable-next-line no-constant-condition
while (1) {
const lineEnd = fileStr.indexOf("\n", lineStart);
const line = fileStr.slice(lineStart, lineEnd < 0 ? undefined : lineEnd).trim();
if (line === TRAILER_LINE) {
break;
}
if (lineEnd < 0) {
throw new Error("Trailer line not found");
}
// start the next line after the newline
lineStart = lineEnd + 1;
}
const dataEnd = lineStart;
return decodeBase64(fileStr.slice(dataStart, dataEnd));
}
/**
* ascii-armour a megolm key file
*
* base64s the content, and adds header and trailer lines
*
* @param {Uint8Array} data raw data
* @return {ArrayBuffer} formatted file
*/
function packMegolmKeyFile(data: Uint8Array): ArrayBuffer {
// we split into lines before base64ing, because encodeBase64 doesn't deal
// terribly well with large arrays.
const LINE_LENGTH = (72 * 4) / 3;
const nLines = Math.ceil(data.length / LINE_LENGTH);
const lines = new Array(nLines + 3);
lines[0] = HEADER_LINE;
let o = 0;
let i;
for (i = 1; i <= nLines; i++) {
lines[i] = encodeBase64(data.subarray(o, o + LINE_LENGTH));
o += LINE_LENGTH;
}
lines[i++] = TRAILER_LINE;
lines[i] = "";
return new TextEncoder().encode(lines.join("\n")).buffer;
}
/**
* Encode a typed array of uint8 as base64.
* @param {Uint8Array} uint8Array The data to encode.
* @return {string} The base64.
*/
function encodeBase64(uint8Array: Uint8Array): string {
// Misinterpt the Uint8Array as Latin-1.
// window.btoa expects a unicode string with codepoints in the range 0-255.
const latin1String = String.fromCharCode.apply(null, Array.from(uint8Array));
// Use the builtin base64 encoder.
return window.btoa(latin1String);
}
/**
* Decode a base64 string to a typed array of uint8.
* @param {string} base64 The base64 to decode.
* @return {Uint8Array} The decoded data.
*/
function decodeBase64(base64: string): Uint8Array<ArrayBuffer> {
// window.atob returns a unicode string with codepoints in the range 0-255.
const latin1String = window.atob(base64);
// Encode the string as a Uint8Array
const uint8Array = new Uint8Array(latin1String.length);
for (let i = 0; i < latin1String.length; i++) {
uint8Array[i] = latin1String.charCodeAt(i);
}
return uint8Array;
}
+281
View File
@@ -0,0 +1,281 @@
/*
Copyright 2024 New Vector Ltd.
Copyright 2019-2021 , 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 React, { type JSX } from "react";
import classNames from "classnames";
import DiffMatchPatch from "diff-match-patch";
import { DiffDOM, type IDiff } from "diff-dom";
import { type IContent } from "matrix-js-sdk/src/matrix";
import { logger } from "matrix-js-sdk/src/logger";
import { unescape } from "lodash";
import { bodyToHtml, checkBlockNode, type EventRenderOpts } from "../HtmlUtils";
function textToHtml(text: string): string {
const container = document.createElement("div");
container.textContent = text;
return container.innerHTML;
}
function getSanitizedHtmlBody(content: IContent): string {
const opts: EventRenderOpts = {
stripReplyFallback: true,
};
if (content.format === "org.matrix.custom.html") {
return bodyToHtml(content, undefined, opts);
} else {
// convert the string to something that can be safely
// embedded in an html document, e.g. use html entities where needed
// This is also needed so that DiffDOM wouldn't interpret something
// as a tag when somebody types e.g. "</sarcasm>"
// as opposed to bodyToHtml, here we also render
// text messages with dangerouslySetInnerHTML, to unify
// the code paths and because we need html to show differences
return textToHtml(bodyToHtml(content, undefined, opts));
}
}
function wrapInsertion(child: Node): HTMLElement {
const wrapper = document.createElement(checkBlockNode(child) ? "div" : "span");
wrapper.className = "mx_EditHistoryMessage_insertion";
wrapper.appendChild(child);
return wrapper;
}
function wrapDeletion(child: Node): HTMLElement {
const wrapper = document.createElement(checkBlockNode(child) ? "div" : "span");
wrapper.className = "mx_EditHistoryMessage_deletion";
wrapper.appendChild(child);
return wrapper;
}
function findRefNodes(
root: Node,
route: number[],
isAddition = false,
): {
refNode: Node | undefined;
refParentNode: Node | undefined;
} {
let refNode: Node | undefined = root;
let refParentNode: Node | undefined;
const end = isAddition ? route.length - 1 : route.length;
for (let i = 0; i < end; ++i) {
refParentNode = refNode;
refNode = refNode?.childNodes[route[i]!];
}
return { refNode, refParentNode };
}
function isTextNode(node: Text | HTMLElement): node is Text {
return node.nodeName === "#text";
}
function diffTreeToDOM(desc: Text | HTMLElement): Node {
if (isTextNode(desc)) {
return stringAsTextNode(desc.data);
} else {
const node = document.createElement(desc.nodeName);
for (const [key, value] of Object.entries(desc.attributes)) {
node.setAttribute(key, value.value);
}
if (desc.childNodes) {
for (const childDesc of desc.childNodes) {
node.appendChild(diffTreeToDOM(childDesc as Text | HTMLElement));
}
}
return node;
}
}
function insertBefore(parent: Node, nextSibling: Node | undefined, child: Node): void {
if (nextSibling) {
parent.insertBefore(child, nextSibling);
} else {
parent.appendChild(child);
}
}
function isRouteOfNextSibling(route1: number[], route2: number[]): boolean {
// routes are arrays with indices,
// to be interpreted as a path in the dom tree
// ensure same parent
for (let i = 0; i < route1.length - 1; ++i) {
if (route1[i] !== route2[i]) {
return false;
}
}
// the route2 is only affected by the diff of route1
// inserting an element if the index at the level of the
// last element of route1 being larger
// (e.g. coming behind route1 at that level)
const lastD1Idx = route1.length - 1;
return route2[lastD1Idx]! >= route1[lastD1Idx]!;
}
function adjustRoutes(diff: IDiff, remainingDiffs: IDiff[]): void {
if (diff.action === "removeTextElement" || diff.action === "removeElement") {
// as removed text is not removed from the html, but marked as deleted,
// we need to readjust indices that assume the current node has been removed.
const advance = 1;
for (const rd of remainingDiffs) {
if (isRouteOfNextSibling(diff.route, rd.route)) {
rd.route[diff.route.length - 1] += advance;
}
}
}
}
function stringAsTextNode(string: string): Text {
return document.createTextNode(unescape(string));
}
function renderDifferenceInDOM(originalRootNode: Node, diff: IDiff, diffMathPatch: DiffMatchPatch): void {
const { refNode, refParentNode } = findRefNodes(originalRootNode, diff.route);
switch (diff.action) {
case "replaceElement": {
if (!refNode) {
console.warn("Unable to apply replaceElement operation due to missing node");
return;
}
const container = document.createElement("span");
const delNode = wrapDeletion(diffTreeToDOM(diff.oldValue as HTMLElement));
const insNode = wrapInsertion(diffTreeToDOM(diff.newValue as HTMLElement));
container.appendChild(delNode);
container.appendChild(insNode);
refNode.parentNode!.replaceChild(container, refNode);
break;
}
case "removeTextElement": {
if (!refNode) {
console.warn("Unable to apply removeTextElement operation due to missing node");
return;
}
const delNode = wrapDeletion(stringAsTextNode(diff.value as string));
refNode.parentNode!.replaceChild(delNode, refNode);
break;
}
case "removeElement": {
if (!refNode) {
console.warn("Unable to apply removeElement operation due to missing node");
return;
}
const delNode = wrapDeletion(diffTreeToDOM(diff.element as HTMLElement));
refNode.parentNode!.replaceChild(delNode, refNode);
break;
}
case "modifyTextElement": {
if (!refNode) {
console.warn("Unable to apply modifyTextElement operation due to missing node");
return;
}
const textDiffs = diffMathPatch.diff_main(diff.oldValue as string, diff.newValue as string);
diffMathPatch.diff_cleanupSemantic(textDiffs);
const container = document.createElement("span");
for (const [modifier, text] of textDiffs) {
let textDiffNode: Node = stringAsTextNode(text);
if (modifier < 0) {
textDiffNode = wrapDeletion(textDiffNode);
} else if (modifier > 0) {
textDiffNode = wrapInsertion(textDiffNode);
}
container.appendChild(textDiffNode);
}
refNode.parentNode!.replaceChild(container, refNode);
break;
}
case "addElement": {
if (!refParentNode) {
console.warn("Unable to apply addElement operation due to missing node");
return;
}
const insNode = wrapInsertion(diffTreeToDOM(diff.element as HTMLElement));
insertBefore(refParentNode, refNode, insNode);
break;
}
case "addTextElement": {
if (!refParentNode) {
console.warn("Unable to apply addTextElement operation due to missing node");
return;
}
// XXX: sometimes diffDOM says insert a newline when there shouldn't be one
// but we must insert the node anyway so that we don't break the route child IDs.
// See https://github.com/fiduswriter/diffDOM/issues/100
const insNode = wrapInsertion(stringAsTextNode(diff.value !== "\n" ? (diff.value as string) : ""));
insertBefore(refParentNode, refNode, insNode);
break;
}
// e.g. when changing a the href of a link,
// show the link with old href as removed and with the new href as added
case "removeAttribute":
case "addAttribute":
case "modifyAttribute": {
if (!refNode) {
console.warn(`Unable to apply ${diff.action} operation due to missing node`);
return;
}
const delNode = wrapDeletion(refNode.cloneNode(true));
const updatedNode = refNode.cloneNode(true) as HTMLElement;
if (diff.action === "addAttribute" || diff.action === "modifyAttribute") {
updatedNode.setAttribute(diff.name, diff.newValue as string);
} else {
updatedNode.removeAttribute(diff.name);
}
const insNode = wrapInsertion(updatedNode);
const container = document.createElement(checkBlockNode(refNode) ? "div" : "span");
container.appendChild(delNode);
container.appendChild(insNode);
refNode.parentNode!.replaceChild(container, refNode);
break;
}
default:
// Should not happen (modifyComment, ???)
logger.warn("MessageDiffUtils::editBodyDiffToHtml: diff action not supported atm", diff);
}
}
/**
* Renders a message with the changes made in an edit shown visually.
* @param {IContent} originalContent the content for the base message
* @param {IContent} editContent the content for the edit message
* @return {JSX.Element} a react element similar to what `bodyToHtml` returns
*/
export function editBodyDiffToHtml(originalContent: IContent, editContent: IContent): JSX.Element {
// wrap the body in a div, DiffDOM needs a root element
const originalBody = `<div>${getSanitizedHtmlBody(originalContent)}</div>`;
const editBody = `<div>${getSanitizedHtmlBody(editContent)}</div>`;
const dd = new DiffDOM();
// diffActions is an array of objects with at least a `action` and `route`
// property. `action` tells us what the diff object changes, and `route` where.
// `route` is a path on the DOM tree expressed as an array of indices.
const diffActions = dd.diff(originalBody, editBody);
// for diffing text fragments
const diffMathPatch = new DiffMatchPatch();
// parse the base html message as a DOM tree, to which we'll apply the differences found.
// fish out the div in which we wrapped the messages above with children[0].
const originalRootNode = new DOMParser().parseFromString(originalBody, "text/html").body.children[0]!;
for (let i = 0; i < diffActions.length; ++i) {
const diff = diffActions[i]!;
renderDifferenceInDOM(originalRootNode, diff, diffMathPatch);
// DiffDOM assumes in subsequent diffs route path that
// the action was applied (e.g. that a removeElement action removed the element).
// This is not the case for us. We render differences in the DOM tree, and don't apply them.
// So we need to adjust the routes of the remaining diffs to account for this.
adjustRoutes(diff, diffActions.slice(i + 1));
}
// take the html out of the modified DOM tree again
const safeBody = originalRootNode.innerHTML;
const className = classNames({
"mx_EventTile_body": true,
"markdown-body": true,
});
return <span key="body" className={className} dangerouslySetInnerHTML={{ __html: safeBody }} dir="auto" />;
}
+32
View File
@@ -0,0 +1,32 @@
/*
Copyright 2024 New Vector Ltd.
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.
*/
/**
* Different browsers use different deltaModes. This causes different behaviour.
* To avoid that we use this function to convert any event to pixels.
* @param {WheelEvent} event to normalize
* @returns {WheelEvent} normalized event event
*/
export function normalizeWheelEvent({ deltaMode, deltaX, deltaY, deltaZ, ...event }: WheelEvent): WheelEvent {
const LINE_HEIGHT = 18;
if (deltaMode === 1) {
// Units are lines
deltaX *= LINE_HEIGHT;
deltaY *= LINE_HEIGHT;
deltaZ *= LINE_HEIGHT;
}
return new WheelEvent("syntheticWheel", {
deltaMode: 0,
deltaY,
deltaX,
deltaZ,
...event,
});
}
+384
View File
@@ -0,0 +1,384 @@
/*
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 { MatrixError, type MatrixClient, EventType, type EmptyObject, type InviteOpts } from "matrix-js-sdk/src/matrix";
import { KnownMembership } from "matrix-js-sdk/src/types";
import { logger } from "matrix-js-sdk/src/logger";
import { AddressType, getAddressType } from "../UserAddress";
import { _t } from "../languageHandler";
import Modal from "../Modal";
import SettingsStore from "../settings/SettingsStore";
import AskInviteAnywayDialog from "../components/views/dialogs/AskInviteAnywayDialog";
import ConfirmUserActionDialog from "../components/views/dialogs/ConfirmUserActionDialog";
import { openInviteProgressDialog } from "../components/views/dialogs/InviteProgressDialog.tsx";
export enum InviteState {
Invited = "invited",
Error = "error",
}
interface IError {
errorText: string;
errcode: string;
}
export const UNKNOWN_PROFILE_ERRORS = [
"M_NOT_FOUND",
"M_USER_NOT_FOUND",
"M_PROFILE_UNDISCLOSED",
"M_PROFILE_NOT_FOUND",
];
export type CompletionStates = Record<string, InviteState>;
const USER_ALREADY_JOINED = "IO.ELEMENT.ALREADY_JOINED";
const USER_ALREADY_INVITED = "IO.ELEMENT.ALREADY_INVITED";
const USER_BANNED = "IO.ELEMENT.BANNED";
/** Options interface for {@link MultiInviter} */
export interface MultiInviterOptions {
/** Optional callback, fired after each invite */
progressCallback?: () => void;
/**
* By default, we will pop up a "Preparing invitations..." dialog while the invites are being sent. Set this to
* `true` to inhibit it (in which case, you probably want to implement another bit of feedback UI).
*/
inhibitProgressDialog?: boolean;
}
/**
* Invites multiple addresses to a room, handling rate limiting from the server
*/
export default class MultiInviter {
private addresses: string[] = [];
private _fatal = false;
private completionStates: CompletionStates = {}; // State of each address (invited or error)
private errors: Record<string, IError> = {}; // { address: {errorText, errcode} }
private reason: string | undefined;
/**
* @param matrixClient the client of the logged in user
* @param {string} roomId The ID of the room to invite to
* @param options Options object
*/
public constructor(
private readonly matrixClient: MatrixClient,
private roomId: string,
private readonly options: MultiInviterOptions = {},
) {}
public get fatal(): boolean {
return this._fatal;
}
/**
* Invite users to this room. This may only be called once per
* instance of the class.
*
* Any failures are returned via the {@link CompletionStates} in the result.
*
* @param {array} addresses Array of addresses to invite
* @param {string} reason Reason for inviting (optional)
* @returns {Promise} Resolved when all invitations in the queue are complete.
*/
public async invite(addresses: string[], reason?: string): Promise<CompletionStates> {
if (this.addresses.length > 0) {
throw new Error("Already inviting/invited");
}
this.addresses.push(...addresses);
this.reason = reason;
let closeDialog: (() => void) | undefined;
if (!this.options.inhibitProgressDialog) {
closeDialog = openInviteProgressDialog();
}
try {
for (const addr of this.addresses) {
if (getAddressType(addr) === null) {
this.completionStates[addr] = InviteState.Error;
this.errors[addr] = {
errcode: "M_INVALID",
errorText: _t("invite|invalid_address"),
};
}
}
for (const addr of this.addresses) {
// don't try to invite it if it's an invalid address
// (it will already be marked as an error though,
// so no need to do so again)
if (getAddressType(addr) === null) {
continue;
}
// don't re-invite (there's no way in the UI to do this, but
// for sanity's sake)
if (this.completionStates[addr] === InviteState.Invited) {
continue;
}
await this.doInvite(addr, false);
if (this._fatal) {
// `doInvite` suffered a fatal error. The error should have been recorded in `errors`; it's up
// to the caller to report back to the user.
return this.completionStates;
}
}
if (Object.keys(this.errors).length > 0) {
// There were problems inviting some people - see if we can invite them
// without caring if they exist or not.
const unknownProfileUsers = Object.keys(this.errors).filter((a) =>
UNKNOWN_PROFILE_ERRORS.includes(this.errors[a].errcode),
);
if (unknownProfileUsers.length > 0) {
await this.handleUnknownProfileUsers(unknownProfileUsers);
}
}
} finally {
// Remember to close the progress dialog, if we opened one.
closeDialog?.();
}
return this.completionStates;
}
public getCompletionState(addr: string): InviteState {
return this.completionStates[addr];
}
public getErrorText(addr: string): string | null {
return this.errors[addr]?.errorText ?? null;
}
private async inviteToRoom(roomId: string, addr: string, ignoreProfile = false): Promise<EmptyObject> {
const addrType = getAddressType(addr);
if (addrType === AddressType.Email) {
return this.matrixClient.inviteByEmail(roomId, addr);
} else if (addrType === AddressType.MatrixUserId) {
const room = this.matrixClient.getRoom(roomId);
if (!room) throw new Error("Room not found");
const member = room.getMember(addr);
if (member?.membership === KnownMembership.Join) {
throw new MatrixError({
errcode: USER_ALREADY_JOINED,
error: "Member already joined",
});
} else if (member?.membership === KnownMembership.Invite) {
throw new MatrixError({
errcode: USER_ALREADY_INVITED,
error: "Member already invited",
});
} else if (member?.membership === KnownMembership.Ban) {
let proceed = false;
// Check if we can unban the invitee.
// See https://spec.matrix.org/v1.7/rooms/v10/#authorization-rules, particularly 4.5.3 and 4.5.4.
const ourMember = room.getMember(this.matrixClient.getSafeUserId());
if (
!!ourMember &&
member.powerLevel < ourMember.powerLevel &&
room.currentState.hasSufficientPowerLevelFor("ban", ourMember.powerLevel) &&
room.currentState.hasSufficientPowerLevelFor("kick", ourMember.powerLevel)
) {
const { finished } = Modal.createDialog(ConfirmUserActionDialog, {
member,
action: _t("action|unban"),
title: _t("invite|unban_first_title"),
});
[proceed = false] = await finished;
if (proceed) {
await this.matrixClient.unban(roomId, member.userId);
}
}
if (!proceed) {
throw new MatrixError({
errcode: USER_BANNED,
error: "Member is banned",
});
}
}
if (!ignoreProfile && SettingsStore.getValue("promptBeforeInviteUnknownUsers", this.roomId)) {
try {
await this.matrixClient.getProfileInfo(addr);
} catch (err) {
// The error handling during the invitation process covers any API.
// Some errors must to me mapped from profile API errors to more specific ones to avoid collisions.
switch (err instanceof MatrixError ? err.errcode : err) {
case "M_FORBIDDEN":
throw new MatrixError({ errcode: "M_PROFILE_UNDISCLOSED" });
case "M_NOT_FOUND":
throw new MatrixError({ errcode: "M_USER_NOT_FOUND" });
default:
throw err;
}
}
}
const opts: InviteOpts = {
shareEncryptedHistory: true,
};
if (this.reason !== undefined) opts.reason = this.reason;
return this.matrixClient.invite(roomId, addr, opts);
} else {
throw new Error("Unsupported address");
}
}
/**
* Attempt to invite a user.
*
* Does not normally throw exceptions. If there was an error, this is reflected in {@link errors}.
* If the error was fatal and should prevent further invites from being done, {@link _fatal} is set.
*/
private doInvite(address: string, ignoreProfile: boolean): Promise<void> {
return new Promise<void>((resolve, reject) => {
logger.log(`Inviting ${address}`);
const doInvite = this.inviteToRoom(this.roomId, address, ignoreProfile);
doInvite
.then(() => {
this.completionStates[address] = InviteState.Invited;
delete this.errors[address];
resolve();
this.options.progressCallback?.();
})
.catch((err) => {
logger.error(err);
const room = this.roomId ? this.matrixClient.getRoom(this.roomId) : null;
const isSpace = room?.isSpaceRoom();
const isFederated = room?.currentState.getStateEvents(EventType.RoomCreate, "")?.getContent()[
"m.federate"
];
let errorText: string | undefined;
switch (err.errcode) {
case "M_FORBIDDEN":
if (isSpace) {
errorText =
isFederated === false
? _t("invite|error_unfederated_space")
: _t("invite|error_permissions_space");
} else {
errorText =
isFederated === false
? _t("invite|error_unfederated_room")
: _t("invite|error_permissions_room");
}
// No point doing further invites.
this._fatal = true;
break;
case USER_ALREADY_INVITED:
if (isSpace) {
errorText = _t("invite|error_already_invited_space");
} else {
errorText = _t("invite|error_already_invited_room");
}
break;
case USER_ALREADY_JOINED:
if (isSpace) {
errorText = _t("invite|error_already_joined_space");
} else {
errorText = _t("invite|error_already_joined_room");
}
break;
case "M_LIMIT_EXCEEDED":
// we're being throttled so wait a bit & try again
window.setTimeout(() => {
this.doInvite(address, ignoreProfile).then(resolve, reject);
}, 5000);
return;
case "M_NOT_FOUND":
case "M_USER_NOT_FOUND":
errorText = _t("invite|error_user_not_found");
break;
case "M_PROFILE_UNDISCLOSED":
errorText = _t("invite|error_profile_undisclosed");
break;
case "M_PROFILE_NOT_FOUND":
if (!ignoreProfile) {
// Invite without the profile check
logger.warn(`User ${address} does not have a profile - inviting anyways automatically`);
this.doInvite(address, true).then(resolve, reject);
return;
}
break;
case "M_BAD_STATE":
case USER_BANNED:
errorText = _t("invite|error_bad_state");
break;
case "M_UNSUPPORTED_ROOM_VERSION":
if (isSpace) {
errorText = _t("invite|error_version_unsupported_space");
} else {
errorText = _t("invite|error_version_unsupported_room");
}
break;
case "ORG.MATRIX.JSSDK_MISSING_PARAM":
if (getAddressType(address) === AddressType.Email) {
errorText = _t("cannot_invite_without_identity_server");
}
}
if (!errorText) {
errorText = _t("invite|error_unknown");
}
this.completionStates[address] = InviteState.Error;
this.errors[address] = { errorText, errcode: err.errcode };
resolve();
});
});
}
/** Handle users which failed with an error code which indicated that their profile was unknown.
*
* Depending on the `promptBeforeInviteUnknownUsers` setting, we either prompt the user for how to proceed, or
* send the invites anyway.
*/
private handleUnknownProfileUsers(unknownProfileUsers: string[]): Promise<void> {
return new Promise<void>((resolve) => {
const inviteUnknowns = (): void => {
const promises = unknownProfileUsers.map((u) => this.doInvite(u, true));
Promise.all(promises).then(() => resolve());
};
if (!SettingsStore.getValue("promptBeforeInviteUnknownUsers", this.roomId)) {
inviteUnknowns();
return;
}
logger.log("Showing failed to invite dialog...");
Modal.createDialog(AskInviteAnywayDialog, {
unknownProfileUsers: unknownProfileUsers.map((u) => ({
userId: u,
errorText: this.errors[u].errorText,
})),
onInviteAnyways: () => inviteUnknowns(),
onGiveUp: () => {
// Fake all the completion states because we already warned the user
for (const addr of unknownProfileUsers) {
this.completionStates[addr] = InviteState.Invited;
}
resolve();
},
});
});
}
}
+18
View File
@@ -0,0 +1,18 @@
/*
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 React from "react";
// Wrap DOM event handlers with stopPropagation and preventDefault
export const preventDefaultWrapper =
<T extends React.BaseSyntheticEvent = React.BaseSyntheticEvent>(callback: () => void) =>
(e?: T) => {
e?.stopPropagation();
e?.preventDefault();
callback();
};
+108
View File
@@ -0,0 +1,108 @@
/*
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.
*/
import { zxcvbn, zxcvbnOptions, type ZxcvbnResult, type TranslationKeys } from "@zxcvbn-ts/core";
import * as zxcvbnCommonPackage from "@zxcvbn-ts/language-common";
import * as zxcvbnEnPackage from "@zxcvbn-ts/language-en";
import { type MatrixClient } from "matrix-js-sdk/src/matrix";
import { _t } from "../languageHandler";
import SdkConfig from "../SdkConfig";
zxcvbnOptions.setOptions({
dictionary: {
...zxcvbnCommonPackage.dictionary,
...zxcvbnEnPackage.dictionary,
userInputs: ["riot", "matrix", "element", SdkConfig.get().brand],
},
graphs: zxcvbnCommonPackage.adjacencyGraphs,
useLevenshteinDistance: true,
});
function getTranslations(): TranslationKeys {
return {
warnings: {
straightRow: _t("zxcvbn|warnings|straightRow"),
keyPattern: _t("zxcvbn|warnings|keyPattern"),
simpleRepeat: _t("zxcvbn|warnings|simpleRepeat"),
extendedRepeat: _t("zxcvbn|warnings|extendedRepeat"),
sequences: _t("zxcvbn|warnings|sequences"),
recentYears: _t("zxcvbn|warnings|recentYears"),
dates: _t("zxcvbn|warnings|dates"),
topTen: _t("zxcvbn|warnings|topTen"),
topHundred: _t("zxcvbn|warnings|topHundred"),
common: _t("zxcvbn|warnings|common"),
similarToCommon: _t("zxcvbn|warnings|similarToCommon"),
wordByItself: _t("zxcvbn|warnings|wordByItself"),
namesByThemselves: _t("zxcvbn|warnings|namesByThemselves"),
commonNames: _t("zxcvbn|warnings|commonNames"),
userInputs: _t("zxcvbn|warnings|userInputs"),
pwned: _t("zxcvbn|warnings|pwned"),
},
suggestions: {
l33t: _t("zxcvbn|suggestions|l33t"),
reverseWords: _t("zxcvbn|suggestions|reverseWords"),
allUppercase: _t("zxcvbn|suggestions|allUppercase"),
capitalization: _t("zxcvbn|suggestions|capitalization"),
dates: _t("zxcvbn|suggestions|dates"),
recentYears: _t("zxcvbn|suggestions|recentYears"),
associatedYears: _t("zxcvbn|suggestions|associatedYears"),
sequences: _t("zxcvbn|suggestions|sequences"),
repeated: _t("zxcvbn|suggestions|repeated"),
longerKeyboardPattern: _t("zxcvbn|suggestions|longerKeyboardPattern"),
anotherWord: _t("zxcvbn|suggestions|anotherWord"),
useWords: _t("zxcvbn|suggestions|useWords"),
noNeed: _t("zxcvbn|suggestions|noNeed"),
pwned: _t("zxcvbn|suggestions|pwned"),
},
// We don't utilise the time estimation at this time so just pass through the English translations here
timeEstimation: zxcvbnEnPackage.translations.timeEstimation,
};
}
/**
* Wrapper around zxcvbn password strength estimation
* Include this only from async components: it pulls in zxcvbn
* (obviously) which is large.
*
* @param {string} password Password to score
* @param matrixClient the client of the logged-in user, if any
* @param userInputs additional strings such as the user's name which should be considered a bad password component
* @returns {object} Score result with `score` and `feedback` properties
*/
export function scorePassword(
matrixClient: MatrixClient | null,
password: string,
userInputs: string[] = [],
): ZxcvbnResult | null {
if (password.length === 0) return null;
// copy the supplied array before modifying it
const inputs = [...userInputs];
if (matrixClient) {
inputs.push(matrixClient.getUserIdLocalpart()!);
try {
const domain = matrixClient.getDomain()!;
inputs.push(domain);
} catch {
// This is fine
}
}
zxcvbnOptions.setTranslations(getTranslations());
let zxcvbnResult = zxcvbn(password, inputs);
// Work around https://github.com/dropbox/zxcvbn/issues/216
if (password.includes(" ")) {
const resultNoSpaces = zxcvbn(password.replace(/ /g, ""), inputs);
if (resultNoSpaces.score < zxcvbnResult.score) zxcvbnResult = resultNoSpaces;
}
return zxcvbnResult;
}
@@ -0,0 +1,54 @@
/*
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.
*/
import { xxHash32 } from "js-xxhash";
/**
* The PhasedRolloutFeature class is used to manage the phased rollout of a new feature.
*
* It uses a hash of the user's identifier and the feature name to determine if a feature is enabled for a specific user.
* The rollout percentage determines the probability that a user will be enabled for the feature.
* The feature will be enabled for all users if the rollout percentage is 100, and for no users if the percentage is 0.
* If a user is enabled for a feature at x% rollout, it will also be for any greater than x percent.
*
* The process ensures a uniform distribution of enabled features across users.
*
* @property featureName - The name of the feature to be rolled out.
* @property rolloutPercentage - The int percentage (0..100) of users for whom the feature should be enabled.
*/
export class PhasedRolloutFeature {
public readonly featureName: string;
private readonly rolloutPercentage: number;
private readonly seed: number;
public constructor(featureName: string, rolloutPercentage: number) {
this.featureName = featureName;
if (!Number.isInteger(rolloutPercentage) || rolloutPercentage < 0 || rolloutPercentage > 100) {
throw new Error("Rollout percentage must be an integer between 0 and 100");
}
this.rolloutPercentage = rolloutPercentage;
// We add the feature name for the seed to ensure that the hash is different for each feature
this.seed = Array.from(featureName).reduce((sum, char) => sum + char.charCodeAt(0), 0);
}
/**
* Returns true if the feature should be enabled for the given user.
* @param userIdentifier - Some unique identifier for the user, e.g. their user ID or device ID.
*/
public isFeatureEnabled(userIdentifier: string): boolean {
/*
* We use a hash function to convert the unique user ID string into an integer.
* This integer can then be used as a basis for deciding whether the user should have access to the new feature.
* We need some hash with good uniform distribution properties, security is not a concern here.
* We use xxHash32, which is fast and has good distribution properties.
*/
const hash = xxHash32(userIdentifier, this.seed);
// We use the hash modulo 100 to get a number between 0 and 99.
// Modulo is simple and effective and the distribution should be uniform enough for our purposes.
return hash % 100 < this.rolloutPercentage;
}
}
+169
View File
@@ -0,0 +1,169 @@
/*
Copyright 2024 New Vector Ltd.
Copyright 2017 Travis Ralston
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,
EventType,
M_POLL_START,
type MatrixClient,
EventTimeline,
type Room,
type EmptyObject,
} from "matrix-js-sdk/src/matrix";
import { isContentActionable } from "./EventUtils";
import { ReadPinsEventId } from "../components/views/right_panel/types";
export default class PinningUtils {
/**
* Event types that may be pinned.
*/
public static readonly PINNABLE_EVENT_TYPES: (EventType | string)[] = [
EventType.RoomMessage,
M_POLL_START.name,
M_POLL_START.altName,
];
/**
* Determines if the given event can be pinned.
* This is a simple check to see if the event is of a type that can be pinned.
* @param {MatrixEvent} event The event to check.
* @return {boolean} True if the event may be pinned, false otherwise.
*/
public static isPinnable(event: MatrixEvent): boolean {
if (event.isRedacted()) return false;
return PinningUtils.isUnpinnable(event);
}
/**
* Determines if the given event may be unpinned.
* @param {MatrixEvent} event The event to check.
* @return {boolean} True if the event may be unpinned, false otherwise.
*/
public static isUnpinnable(event: MatrixEvent): boolean {
if (!event) return false;
if (event.isRedacted()) return true;
return this.PINNABLE_EVENT_TYPES.includes(event.getType());
}
/**
* Determines if the given event is pinned.
* @param matrixClient
* @param mxEvent
*/
public static isPinned(matrixClient: MatrixClient, mxEvent: MatrixEvent): boolean {
const room = matrixClient.getRoom(mxEvent.getRoomId());
if (!room) return false;
const pinnedEvent = room
.getLiveTimeline()
.getState(EventTimeline.FORWARDS)
?.getStateEvents(EventType.RoomPinnedEvents, "");
if (!pinnedEvent) return false;
const content = pinnedEvent.getContent();
return content.pinned && Array.isArray(content.pinned) && content.pinned.includes(mxEvent.getId());
}
/**
* Determines if the given event may be pinned by the current user.
* This checks if the user has the necessary permissions to pin or unpin the event, and if the event is pinnable.
* @param matrixClient
* @param mxEvent
*/
public static canPin(matrixClient: MatrixClient, mxEvent: MatrixEvent): boolean {
if (!isContentActionable(mxEvent)) return false;
const room = matrixClient.getRoom(mxEvent.getRoomId());
if (!room) return false;
// Should have a non-local event id
if (mxEvent.status !== null) return false;
return PinningUtils.userHasPinOrUnpinPermission(matrixClient, room) && PinningUtils.isPinnable(mxEvent);
}
/**
* Determines if the given event may be unpinned by the current user.
* This checks if the user has the necessary permissions to pin or unpin the event, and if the event is unpinnable.
* @param matrixClient
* @param mxEvent
*/
public static canUnpin(matrixClient: MatrixClient, mxEvent: MatrixEvent): boolean {
const room = matrixClient.getRoom(mxEvent.getRoomId());
if (!room) return false;
// Should have a non-local event id
if (mxEvent.status !== null) return false;
return PinningUtils.userHasPinOrUnpinPermission(matrixClient, room) && PinningUtils.isUnpinnable(mxEvent);
}
/**
* Determines if the current user has permission to pin or unpin events in the given room.
* @param matrixClient
* @param room
*/
public static userHasPinOrUnpinPermission(matrixClient: MatrixClient, room: Room): boolean {
return Boolean(
room
.getLiveTimeline()
.getState(EventTimeline.FORWARDS)
?.mayClientSendStateEvent(EventType.RoomPinnedEvents, matrixClient),
);
}
/**
* Pin or unpin the given event.
* @param matrixClient
* @param mxEvent
*/
public static async pinOrUnpinEvent(matrixClient: MatrixClient, mxEvent: MatrixEvent): Promise<void> {
const room = matrixClient.getRoom(mxEvent.getRoomId());
if (!room) return;
const eventId = mxEvent.getId();
if (!eventId) return;
// Should have a non-local event id
if (mxEvent.status !== null) return;
// Get the current pinned events of the room
const pinnedIds: Array<string> =
room
.getLiveTimeline()
.getState(EventTimeline.FORWARDS)
?.getStateEvents(EventType.RoomPinnedEvents, "")
?.getContent().pinned || [];
let roomAccountDataPromise: Promise<EmptyObject | void> = Promise.resolve();
// If the event is already pinned, unpin it
if (pinnedIds.includes(eventId)) {
pinnedIds.splice(pinnedIds.indexOf(eventId), 1);
} else {
// Otherwise, pin it
pinnedIds.push(eventId);
// We don't want to wait for the roomAccountDataPromise to resolve before sending the state event
roomAccountDataPromise = matrixClient.setRoomAccountData(room.roomId, ReadPinsEventId, {
event_ids: [...(room.getAccountData(ReadPinsEventId)?.getContent()?.event_ids || []), eventId],
});
}
await Promise.all([
matrixClient.sendStateEvent(room.roomId, EventType.RoomPinnedEvents, { pinned: pinnedIds }, ""),
roomAccountDataPromise,
]);
}
/**
* Unpin all events in the given room.
* @param matrixClient
* @param roomId
*/
public static async unpinAllEvents(matrixClient: MatrixClient, roomId: string): Promise<void> {
await matrixClient.sendStateEvent(roomId, EventType.RoomPinnedEvents, { pinned: [] }, "");
}
}
@@ -0,0 +1,51 @@
/*
Copyright 2024 New Vector Ltd.
Copyright 2022 The Matrix.org Foundation C.I.C.
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE files in the repository root for full details.
*/
/**
* The preferred room versions for various features within the app. The
* room versions here are selected based on the client's support for the
* possible room versions in combination with server support in the
* ecosystem.
*
* Loosely follows https://spec.matrix.org/latest/rooms/#feature-matrix
*/
export class PreferredRoomVersions {
/**
* The room version to use when creating "knock" rooms.
*/
public static readonly KnockRooms = "7";
/**
* The room version to use when creating "restricted" rooms.
*/
public static readonly RestrictedRooms = "9";
private constructor() {
// readonly, static, class
}
}
/**
* Determines if a room version supports the given feature using heuristics
* for how Matrix works.
* @param roomVer The room version to check support within.
* @param featureVer The room version of the feature. Should be from PreferredRoomVersions.
* @see PreferredRoomVersions
*/
export function doesRoomVersionSupport(roomVer: string, featureVer: string): boolean {
// Assumption: all unstable room versions don't support the feature. Calling code can check for unstable
// room versions explicitly if it wants to. The spec reserves [0-9] and `.` for its room versions.
if (!roomVer.match(/[\d.]+/)) {
return false;
}
// Dev note: While the spec says room versions are not linear, we can make reasonable assumptions
// until the room versions prove themselves to be non-linear in the spec. We should see this coming
// from a mile away and can course-correct this function if needed.
return Number(roomVer) >= Number(featureVer);
}
+28
View File
@@ -0,0 +1,28 @@
/*
Copyright 2024 New Vector Ltd.
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.
*/
import React, { type JSX, type ReactNode } from "react";
/**
* Joins an array into one value with a joiner. E.g. join(["hello", "world"], " ") -> <>hello world</>
* @param array the array of element to join
* @param joiner the string/JSX.Element to join with
* @returns the joined array
*/
export function jsxJoin(array: ReactNode[], joiner?: string | JSX.Element): JSX.Element {
return (
<>
{array.map((element, index) => (
<React.Fragment key={index}>
{element}
{index === array.length - 1 ? null : joiner}
</React.Fragment>
))}
</>
);
}
+91
View File
@@ -0,0 +1,91 @@
/*
* Copyright 2026 Element Creations Ltd.
* Copyright 2024 New Vector Ltd.
* Copyright 2023 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.
*/
import { type IContent, type IEventRelation, type MatrixEvent, THREAD_RELATION_TYPE } from "matrix-js-sdk/src/matrix";
import sanitizeHtml from "sanitize-html";
import { PERMITTED_URL_SCHEMES } from "@element-hq/web-shared-components";
export function getParentEventId(ev?: MatrixEvent): string | undefined {
if (!ev || ev.isRedacted()) return;
if (ev.replyEventId) {
return ev.replyEventId;
}
}
// Part of Replies fallback support
export function stripPlainReply(body: string): string {
// Removes lines beginning with `> ` until you reach one that doesn't.
const lines = body.split("\n");
while (lines.length && lines[0].startsWith("> ")) lines.shift();
// Reply fallback has a blank line after it, so remove it to prevent leading newline
if (lines[0] === "") lines.shift();
return lines.join("\n");
}
// Part of Replies fallback support - MUST NOT BE RENDERED DIRECTLY - UNSAFE HTML
export function stripHTMLReply(html: string): string {
// Sanitize the original HTML for inclusion in <mx-reply>. We allow
// any HTML, since the original sender could use special tags that we
// don't recognize, but want to pass along to any recipients who do
// recognize them -- recipients should be sanitizing before displaying
// anyways. However, we sanitize to 1) remove any mx-reply, so that we
// don't generate a nested mx-reply, and 2) make sure that the HTML is
// properly formatted (e.g. tags are closed where necessary)
return sanitizeHtml(html, {
allowedTags: false, // false means allow everything
allowedAttributes: false,
allowVulnerableTags: true, // silence xss warning, we won't be rendering directly this, so it is safe to do
// we somehow can't allow all schemes, so we allow all that we
// know of and mxc (for img tags)
allowedSchemes: [...PERMITTED_URL_SCHEMES, "mxc"],
exclusiveFilter: (frame) => frame.tag === "mx-reply",
});
}
export function makeReplyMixIn(ev?: MatrixEvent): IEventRelation {
if (!ev) return {};
const mixin: IEventRelation = {
"m.in_reply_to": {
event_id: ev.getId(),
},
};
if (ev.threadRootId) {
mixin.is_falling_back = false;
}
return mixin;
}
export function shouldDisplayReply(event: MatrixEvent): boolean {
if (event.isRedacted()) {
return false;
}
const inReplyTo = event.getWireContent()?.["m.relates_to"]?.["m.in_reply_to"];
if (!inReplyTo) {
return false;
}
const relation = event.getRelation();
if (relation?.rel_type === THREAD_RELATION_TYPE.name && relation?.is_falling_back) {
return false;
}
return !!inReplyTo.event_id;
}
export function addReplyToMessageContent(content: IContent, replyToEvent: MatrixEvent): void {
content["m.relates_to"] = {
...(content["m.relates_to"] || {}),
...makeReplyMixIn(replyToEvent),
};
}
+70
View File
@@ -0,0 +1,70 @@
/*
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.
*/
/**
* Fires when the middle panel has been resized (throttled).
* @event module:utils~ResizeNotifier#"middlePanelResized"
*/
/**
* Fires when the middle panel has been resized by a pixel.
* @event module:utils~ResizeNotifier#"middlePanelResizedNoisy"
*/
import { EventEmitter } from "events";
import { throttle } from "lodash";
export default class ResizeNotifier extends EventEmitter {
private _isResizing = false;
// with default options, will call fn once at first call, and then every x ms
// if there was another call in that timespan
private throttledMiddlePanel = throttle(() => this.emit("middlePanelResized"), 200);
public get isResizing(): boolean {
return this._isResizing;
}
public startResizing(): void {
this._isResizing = true;
this.emit("isResizing", true);
}
public stopResizing(): void {
this._isResizing = false;
this.emit("isResizing", false);
}
private noisyMiddlePanel(): void {
this.emit("middlePanelResizedNoisy");
}
private updateMiddlePanel(): void {
this.throttledMiddlePanel();
this.noisyMiddlePanel();
}
// can be called in quick succession
public notifyLeftHandleResized(): void {
// don't emit event for own region
this.updateMiddlePanel();
}
// can be called in quick succession
public notifyRightHandleResized(): void {
this.updateMiddlePanel();
}
public notifyTimelineHeightChanged(): void {
this.updateMiddlePanel();
}
// can be called in quick succession
public notifyWindowResized(): void {
this.updateMiddlePanel();
}
}
+165
View File
@@ -0,0 +1,165 @@
/*
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 { ClientEvent, EventType, type MatrixClient, type Room } from "matrix-js-sdk/src/matrix";
import { KnownMembership } from "matrix-js-sdk/src/types";
import { logger } from "matrix-js-sdk/src/logger";
import { showAnyInviteErrors } from "../RoomInvite";
import Modal, { type IHandle } from "../Modal";
import { _t } from "../languageHandler";
import ErrorDialog from "../components/views/dialogs/ErrorDialog";
import SpaceStore from "../stores/spaces/SpaceStore";
import Spinner from "../components/views/elements/Spinner";
import MultiInviter, { type MultiInviterOptions } from "./MultiInviter";
export interface RoomUpgradeProgress {
roomUpgraded: boolean;
roomSynced?: boolean;
inviteUsersProgress?: number;
inviteUsersTotal: number;
updateSpacesProgress?: number;
updateSpacesTotal: number;
}
export async function awaitRoomDownSync(cli: MatrixClient, roomId: string): Promise<Room> {
const room = cli.getRoom(roomId);
if (room) return room; // already have the room
return new Promise<Room>((resolve) => {
// We have to wait for the js-sdk to give us the room back so
// we can more effectively abuse the MultiInviter behaviour
// which heavily relies on the Room object being available.
const checkForRoomFn = (room: Room): void => {
if (room.roomId !== roomId) return;
resolve(room);
cli.off(ClientEvent.Room, checkForRoomFn);
};
cli.on(ClientEvent.Room, checkForRoomFn);
});
}
export async function upgradeRoom(
room: Room,
targetVersion: string,
inviteUsers = false,
handleError = true,
updateSpaces = true,
awaitRoom = false,
progressCallback?: (progress: RoomUpgradeProgress) => void,
inhibitInviteProgressDialog = false,
additionalCreators?: string[],
): Promise<string> {
const cli = room.client;
let spinnerModal: IHandle<any> | undefined;
if (!progressCallback) {
spinnerModal = Modal.createDialog(Spinner, undefined, "mx_Dialog_spinner");
}
let toInvite: string[] = [];
if (inviteUsers) {
toInvite = [
...room.getMembersWithMembership(KnownMembership.Join),
...room.getMembersWithMembership(KnownMembership.Invite),
]
.map((m) => m.userId)
.filter((m) => m !== cli.getUserId());
}
let parentsToRelink: Room[] = [];
if (updateSpaces) {
parentsToRelink = Array.from(SpaceStore.instance.getKnownParents(room.roomId))
.map((roomId) => cli.getRoom(roomId))
.filter((parent) =>
parent?.currentState.maySendStateEvent(EventType.SpaceChild, cli.getUserId()!),
) as Room[];
}
const progress: RoomUpgradeProgress = {
roomUpgraded: false,
roomSynced: awaitRoom || inviteUsers ? false : undefined,
inviteUsersProgress: inviteUsers ? 0 : undefined,
inviteUsersTotal: toInvite.length,
updateSpacesProgress: updateSpaces ? 0 : undefined,
updateSpacesTotal: parentsToRelink.length,
};
progressCallback?.(progress);
let newRoomId: string;
try {
({ replacement_room: newRoomId } = await cli.upgradeRoom(room.roomId, targetVersion, additionalCreators));
} catch (e) {
if (!handleError) throw e;
logger.error(e);
Modal.createDialog(ErrorDialog, {
title: _t("room|upgrade_error_title"),
description: _t("room|upgrade_error_description"),
});
throw e;
}
progress.roomUpgraded = true;
progressCallback?.(progress);
if (awaitRoom || inviteUsers) {
await awaitRoomDownSync(room.client, newRoomId);
progress.roomSynced = true;
progressCallback?.(progress);
}
if (toInvite.length > 0) {
// Errors are handled internally to this function
await inviteUsersToRoom(cli, newRoomId, toInvite, {
progressCallback: () => {
progress.inviteUsersProgress!++;
progressCallback?.(progress);
},
inhibitProgressDialog: inhibitInviteProgressDialog,
});
}
if (parentsToRelink.length > 0) {
try {
for (const parent of parentsToRelink) {
const currentEv = parent.currentState.getStateEvents(EventType.SpaceChild, room.roomId);
await cli.sendStateEvent(
parent.roomId,
EventType.SpaceChild,
{
...(currentEv?.getContent() || {}), // copy existing attributes like suggested
via: [cli.getDomain()!],
},
newRoomId,
);
await cli.sendStateEvent(parent.roomId, EventType.SpaceChild, {}, room.roomId);
progress.updateSpacesProgress!++;
progressCallback?.(progress);
}
} catch (e) {
// These errors are not critical to the room upgrade itself
logger.warn("Failed to update parent spaces during room upgrade", e);
}
}
spinnerModal?.close();
return newRoomId;
}
async function inviteUsersToRoom(
client: MatrixClient,
roomId: string,
userIds: string[],
inviteOptions: MultiInviterOptions,
): Promise<void> {
const inviter = new MultiInviter(client, roomId, inviteOptions);
const states = await inviter.invite(userIds);
const room = client.getRoom(roomId)!;
showAnyInviteErrors(states, room, inviter);
}
+22
View File
@@ -0,0 +1,22 @@
/*
Copyright 2024 New Vector Ltd.
Copyright 2023 Boluwatife Omosowon <boluomosowon@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 { parsePermalink } from "./permalinks/Permalinks";
/**
* Returns the primaryEntityId(roomIdOrAlias or userId) if the search term
* is a permalink and the primaryEntityId is not null. Otherwise, it returns
* the original search term.
* E.g https://matrix.to/#/#element-dev:matrix.org returns #element-dev:matrix.org
* @param {string} searchTerm The search term.
* @returns {string} The roomId, alias, userId, or the original search term
*/
export function transformSearchTerm(searchTerm: string): string {
const parseLink = parsePermalink(searchTerm);
return parseLink?.primaryEntityId ?? searchTerm;
}
+288
View File
@@ -0,0 +1,288 @@
/*
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 { logger } from "matrix-js-sdk/src/logger";
/*
* Functionality for checking that only one instance is running at once
*
* The algorithm here is twofold.
*
* First, we "claim" a lock by periodically writing to `STORAGE_ITEM_PING`. On shutdown, we clear that item. So,
* a new instance starting up can check if the lock is free by inspecting `STORAGE_ITEM_PING`. If it is unset,
* or is stale, the new instance can assume the lock is free and claim it for itself. Otherwise, the new instance
* has to wait for the ping to be stale, or the item to be cleared.
*
* Secondly, we need a mechanism for proactively telling existing instances to shut down. We do this by writing a
* unique value to `STORAGE_ITEM_CLAIMANT`. Other instances of the app are supposed to monitor for writes to
* `STORAGE_ITEM_CLAIMANT` and initiate shutdown when it happens.
*
* There is slight complexity in `STORAGE_ITEM_CLAIMANT` in that we need to watch out for yet another instance
* starting up and staking a claim before we even get a chance to take the lock. When that happens we just bail out
* and let the newer instance get the lock.
*
* `STORAGE_ITEM_OWNER` has no functional role in the lock mechanism; it exists solely as a diagnostic indicator
* of which instance is writing to `STORAGE_ITEM_PING`.
*/
export const SESSION_LOCK_CONSTANTS = {
/**
* LocalStorage key for an item which indicates we have the lock.
*
* The instance which holds the lock writes the current time to this key every few seconds, to indicate it is still
* alive and holds the lock.
*/
STORAGE_ITEM_PING: "react_sdk_session_lock_ping",
/**
* LocalStorage key for an item which holds the unique "session ID" of the instance which currently holds the lock.
*
* This property doesn't actually form a functional part of the locking algorithm; it is purely diagnostic.
*/
STORAGE_ITEM_OWNER: "react_sdk_session_lock_owner",
/**
* LocalStorage key for the session ID of the most recent claimant to the lock.
*
* Each instance writes to this key on startup, so existing instances can detect new ones starting up.
*/
STORAGE_ITEM_CLAIMANT: "react_sdk_session_lock_claimant",
/**
* The number of milliseconds after which we consider a lock claim stale
*/
LOCK_EXPIRY_TIME_MS: 15000,
};
/**
* See if any instances are currently running
*
* @returns true if any instance is currently active
*/
export function checkSessionLockFree(): boolean {
const prefixedLogger = logger.getChild(`checkSessionLockFree`);
const lastPingTime = window.localStorage.getItem(SESSION_LOCK_CONSTANTS.STORAGE_ITEM_PING);
if (lastPingTime === null) {
// no other holder
prefixedLogger.info("No other session has the lock");
return true;
}
const lockHolder = window.localStorage.getItem(SESSION_LOCK_CONSTANTS.STORAGE_ITEM_OWNER);
// see if it has expired
const timeAgo = Date.now() - parseInt(lastPingTime);
const remaining = SESSION_LOCK_CONSTANTS.LOCK_EXPIRY_TIME_MS - timeAgo;
if (remaining <= 0) {
// another session claimed the lock, but it is stale.
prefixedLogger.info(`Last ping (from ${lockHolder}) was ${timeAgo}ms ago: lock is free`);
return true;
}
prefixedLogger.info(`Last ping (from ${lockHolder}) was ${timeAgo}ms ago: lock is taken`);
return false;
}
/**
* Ensure that only one instance of the application is running at once.
*
* If there are any other running instances, tells them to stop, and waits for them to do so.
*
* Once we are the sole instance, sets a background job going to service a lock. Then, if another instance starts up,
* `onNewInstance` is called: it should shut the app down to make sure we aren't doing any more work.
*
* @param onNewInstance - callback to handle another instance starting up. NOTE: this may be called before
* `getSessionLock` returns if the lock is stolen before we get a chance to start.
*
* @returns true if we successfully claimed the lock; false if another instance stole it from under our nose
* (in which `onNewInstance` will have been called)
*/
export async function getSessionLock(onNewInstance: () => Promise<void>): Promise<boolean> {
/** unique ID for this session */
const sessionIdentifier = window.crypto.randomUUID();
const prefixedLogger = logger.getChild(`getSessionLock[${sessionIdentifier}]`);
/** The ID of our regular task to service the lock.
*
* Non-null while we hold the lock; null if we have not yet claimed it, or have released it. */
let lockServicer: number | null = null;
/**
* See if the lock is free.
*
* @returns
* - `>0`: the number of milliseconds before the current claim on the lock can be considered stale.
* - `0`: the lock is free for the taking
* - `<0`: someone else has staked a claim for the lock, so we are no longer in line for it.
*/
function checkLock(): number {
// first of all, check that we are still the active claimant (ie, another instance hasn't come along while we were waiting.
const claimant = window.localStorage.getItem(SESSION_LOCK_CONSTANTS.STORAGE_ITEM_CLAIMANT);
if (claimant !== sessionIdentifier) {
prefixedLogger.warn(`Lock was claimed by ${claimant} while we were waiting for it: aborting startup`);
return -1;
}
const lastPingTime = window.localStorage.getItem(SESSION_LOCK_CONSTANTS.STORAGE_ITEM_PING);
const lockHolder = window.localStorage.getItem(SESSION_LOCK_CONSTANTS.STORAGE_ITEM_OWNER);
if (lastPingTime === null) {
prefixedLogger.info("No other session has the lock: proceeding with startup");
return 0;
}
const timeAgo = Date.now() - parseInt(lastPingTime);
// If the last ping time is in the future (i.e., timeAgo is negative), the chances are that the system clock has
// been wound back since the ping. Rather than waiting hours/days/millenia for us to get there, treat a future
// ping as "just now" by clipping to 0.
const remaining = SESSION_LOCK_CONSTANTS.LOCK_EXPIRY_TIME_MS - Math.max(timeAgo, 0);
if (remaining <= 0) {
// another session claimed the lock, but it is stale.
prefixedLogger.info(`Last ping (from ${lockHolder}) was ${timeAgo}ms ago: proceeding with startup`);
return 0;
}
prefixedLogger.info(`Last ping (from ${lockHolder}) was ${timeAgo}ms ago, waiting ${remaining}ms`);
return remaining;
}
function serviceLock(): void {
window.localStorage.setItem(SESSION_LOCK_CONSTANTS.STORAGE_ITEM_OWNER, sessionIdentifier);
window.localStorage.setItem(SESSION_LOCK_CONSTANTS.STORAGE_ITEM_PING, Date.now().toString());
}
// handler for storage events, used later
function onStorageEvent(event: StorageEvent): void {
if (event.key === SESSION_LOCK_CONSTANTS.STORAGE_ITEM_CLAIMANT) {
// It's possible that the event was delayed, and this update actually predates our claim on the lock.
// (In particular: suppose tab A and tab B start concurrently and both attempt to set STORAGE_ITEM_CLAIMANT.
// Each write queues up a `storage` event for all other tabs. So both tabs see the `storage` event from the
// other, even though by the time it arrives we may have overwritten it.)
//
// To resolve any doubt, we check the *actual* state of the storage.
const claimingSession = window.localStorage.getItem(SESSION_LOCK_CONSTANTS.STORAGE_ITEM_CLAIMANT);
if (claimingSession === sessionIdentifier) {
return;
}
prefixedLogger.info(`Session ${claimingSession} is waiting for the lock`);
window.removeEventListener("storage", onStorageEvent);
releaseLock().catch((err) => {
prefixedLogger.error("Error releasing session lock", err);
});
}
}
// handler for pagehide and unload events, used later
function onPagehideEvent(): void {
// only remove the ping if we still think we're the owner. Otherwise we could be removing someone else's claim!
if (lockServicer !== null) {
prefixedLogger.debug("page hide: clearing our claim");
window.clearInterval(lockServicer);
window.localStorage.removeItem(SESSION_LOCK_CONSTANTS.STORAGE_ITEM_PING);
window.localStorage.removeItem(SESSION_LOCK_CONSTANTS.STORAGE_ITEM_OWNER);
lockServicer = null;
}
// It's worth noting that, according to the spec, the page might come back to life again after a pagehide.
//
// In practice that's unlikely because Element is unlikely to qualify for the bfcache, but if it does,
// this is probably the best we can do: we certainly don't want to stop the user loading any new tabs because
// Element happens to be in a bfcache somewhere.
//
// So, we just hope that we aren't in the middle of any crypto operations, and rely on `onStorageEvent` kicking
// in soon enough after we resume to tell us if another tab woke up while we were asleep.
}
async function releaseLock(): Promise<void> {
// tell the app to shut down
await onNewInstance();
// and, once it has done so, stop pinging the lock.
if (lockServicer !== null) {
window.clearInterval(lockServicer);
}
window.localStorage.removeItem(SESSION_LOCK_CONSTANTS.STORAGE_ITEM_PING);
window.localStorage.removeItem(SESSION_LOCK_CONSTANTS.STORAGE_ITEM_OWNER);
lockServicer = null;
}
// first of all, stake a claim for the lock. This tells anyone else holding the lock that we want it.
window.localStorage.setItem(SESSION_LOCK_CONSTANTS.STORAGE_ITEM_CLAIMANT, sessionIdentifier);
// now, wait for the lock to be free.
// eslint-disable-next-line no-constant-condition
while (true) {
const remaining = checkLock();
if (remaining == 0) {
// ok, the lock is free, and nobody else has staked a more recent claim.
break;
} else if (remaining < 0) {
// someone else staked a claim for the lock; we bail out.
await onNewInstance();
return false;
}
// someone else has the lock.
// wait for either the ping to expire, or a storage event.
let onStorageUpdate: (event: StorageEvent) => void;
const storageUpdatePromise = new Promise((resolve) => {
onStorageUpdate = (event: StorageEvent) => {
if (
event.key === SESSION_LOCK_CONSTANTS.STORAGE_ITEM_PING ||
event.key === SESSION_LOCK_CONSTANTS.STORAGE_ITEM_CLAIMANT
)
resolve(event);
};
});
// We construct our own promise here rather than using the `sleep` utility, to make it easier to test the
// SessionLock in a separate Window.
const sleepPromise = new Promise((resolve) => {
setTimeout(resolve, remaining, undefined);
});
window.addEventListener("storage", onStorageUpdate!);
const winner = await Promise.race([sleepPromise, storageUpdatePromise]);
window.removeEventListener("storage", onStorageUpdate!);
// If we got through the whole of the sleep without any writes to the store, we know that the
// ping is now stale. There's no point in going round and calling `checkLock` again: we know that
// nothing has changed since last time.
if (!(winner instanceof StorageEvent)) {
prefixedLogger.info("Existing claim went stale: proceeding with startup");
break;
}
}
// If we get here, we know the lock is ours for the taking.
// CRITICAL SECTION
//
// The following code, up to the end of the function, must all be synchronous (ie, no `await` calls), to ensure that
// we get our listeners in place and all the writes to localStorage done before other tabs run again.
// claim the lock, and kick off a background process to service it every 5 seconds
serviceLock();
lockServicer = window.setInterval(serviceLock, 5000);
// Now add a listener for other claimants to the lock.
window.addEventListener("storage", onStorageEvent);
// also add a listener to clear our claims when our tab closes or navigates away
window.addEventListener("pagehide", onPagehideEvent);
// The pagehide event is called unreliably on Firefox, so additionally add an unload handler.
// https://bugzilla.mozilla.org/show_bug.cgi?id=1854492
window.addEventListener("unload", onPagehideEvent);
return true;
}
+82
View File
@@ -0,0 +1,82 @@
/*
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 { ClientStoppedError, type MatrixClient, type Room } from "matrix-js-sdk/src/matrix";
import { logger } from "matrix-js-sdk/src/logger";
import DMRoomMap from "./DMRoomMap";
import { asyncSome } from "./arrays";
export enum E2EStatus {
Warning = "warning",
Verified = "verified",
Normal = "normal",
}
export async function shieldStatusForRoom(client: MatrixClient, room: Room): Promise<E2EStatus> {
const crypto = client.getCrypto();
if (!crypto) {
return E2EStatus.Warning;
}
try {
const members = (await room.getEncryptionTargetMembers()).map(({ userId }) => userId);
const inDMMap = !!DMRoomMap.shared().getUserIdForRoomId(room.roomId);
const verified: string[] = [];
const unverified: string[] = [];
for (const userId of members) {
if (userId === client.getUserId()) continue;
const userTrust = await crypto.getUserVerificationStatus(userId);
/* Alarm if any unverified users were verified before. */
if (userTrust.wasCrossSigningVerified() && !userTrust.isCrossSigningVerified()) {
return E2EStatus.Warning;
}
(userTrust.isCrossSigningVerified() ? verified : unverified).push(userId);
}
/* Check all verified user devices. */
/* Don't alarm if no other users are verified */
const includeUser =
(verified.length > 0 && // Don't alarm for self in rooms where nobody else is verified
!inDMMap && // Don't alarm for self in DMs with other users
members.length !== 2) || // Don't alarm for self in 1:1 chats with other users
members.length === 1; // Do alarm for self if we're alone in a room
const targets = includeUser ? [...verified, client.getUserId()!] : verified;
const devicesByUser = await crypto.getUserDeviceInfo(targets);
for (const userId of targets) {
const devices = devicesByUser.get(userId);
if (!devices) {
// getUserDeviceInfo returned nothing about this user, which means we know nothing about their device list.
// That seems odd, so treat it as a warning.
logger.warn(`No device info for user ${userId}`);
return E2EStatus.Warning;
}
const anyDeviceNotVerified = await asyncSome(devices.keys(), async (deviceId) => {
const verificationStatus = await crypto.getDeviceVerificationStatus(userId, deviceId);
return !verificationStatus?.isVerified();
});
if (anyDeviceNotVerified) {
return E2EStatus.Warning;
}
}
return unverified.length === 0 ? E2EStatus.Verified : E2EStatus.Normal;
} catch (e) {
if (!(e instanceof ClientStoppedError)) {
throw e;
}
// The client has been stopped while we were figuring out what to do. Catch the exception to stop it being
// logged. It probably doesn't really matter what we return.
logger.warn("shieldStatusForRoom: client stopped");
return E2EStatus.Normal;
}
}
+119
View File
@@ -0,0 +1,119 @@
/*
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 { EnhancedMap } from "./maps";
// Inspired by https://pkg.go.dev/golang.org/x/sync/singleflight
const keyMap = new EnhancedMap<object, EnhancedMap<string, unknown>>();
/**
* Access class to get a singleflight context. Singleflights execute a
* function exactly once, unless instructed to forget about a result.
*
* Typically this is used to de-duplicate an action, such as a save button
* being pressed, without having to track state internally for an operation
* already being in progress. This doesn't expose a flag which can be used
* to disable a button, however it would be capable of returning a Promise
* from the first call.
*
* The result of the function call is cached indefinitely, just in case a
* second call comes through late. There are various functions named "forget"
* to have the cache be cleared of a result.
*
* Singleflights in our use case are tied to an instance of something, combined
* with a string key to differentiate between multiple possible actions. This
* means that a "save" key will be scoped to the instance which defined it and
* not leak between other instances. This is done to avoid having to concatenate
* variables to strings to essentially namespace the field, for most cases.
*/
export class Singleflight {
private constructor() {}
/**
* A void marker to help with returning a value in a singleflight context.
* If your code doesn't return anything, return this instead.
*/
public static Void = Symbol("void");
/**
* Acquire a singleflight context.
* @param {Object} instance An instance to associate the context with. Can be any object.
* @param {string} key A string key relevant to that instance to namespace under.
* @returns {SingleflightContext} Returns the context to execute the function.
*/
public static for(instance?: object | null, key?: string | null): SingleflightContext {
if (!instance || !key) throw new Error("An instance and key must be supplied");
return new SingleflightContext(instance, key);
}
/**
* Forgets all results for a given instance.
* @param {Object} instance The instance to forget about.
*/
public static forgetAllFor(instance: object): void {
keyMap.delete(instance);
}
/**
* Forgets all cached results for all instances. Intended for use by tests.
*/
public static forgetAll(): void {
for (const k of keyMap.keys()) {
keyMap.remove(k);
}
}
}
class SingleflightContext {
public constructor(
private instance: object,
private key: string,
) {}
/**
* Forget this particular instance and key combination, discarding the result.
*/
public forget(): void {
const map = keyMap.get(this.instance);
if (!map) return;
map.remove(this.key);
if (!map.size) keyMap.remove(this.instance);
}
/**
* Execute a function. If a result is already known, that will be returned instead
* of executing the provided function. However, if no result is known then the function
* will be called, with its return value cached. The function must return a value
* other than `undefined` - take a look at Singleflight.Void if you don't have a return
* to make.
*
* Note that this technically allows the caller to provide a different function each time:
* this is largely considered a bad idea and should not be done. Singleflights work off the
* premise that something needs to happen once, so duplicate executions will be ignored.
*
* For ideal performance and behaviour, functions which return promises are preferred. If
* a function is not returning a promise, it should return as soon as possible to avoid a
* second call potentially racing it. The promise returned by this function will be that
* of the first execution of the function, even on duplicate calls.
* @param {Function} fn The function to execute.
* @returns The recorded value.
*/
public do<T>(fn: () => T): T {
const map = keyMap.getOrCreate(this.instance, new EnhancedMap<string, unknown>());
// We have to manually getOrCreate() because we need to execute the fn
let val = <T>map.get(this.key);
if (val === undefined) {
val = fn();
map.set(this.key, val);
}
return val;
}
}
+38
View File
@@ -0,0 +1,38 @@
/*
Copyright 2024 New Vector Ltd.
Copyright 2022 The Matrix.org Foundation C.I.C.
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE files in the repository root for full details.
*/
export function snakeToCamel(s: string): string {
return s.replace(/._./g, (v) => `${v[0]}${v[2].toUpperCase()}`);
}
export class SnakedObject<T = Record<string, any>> {
private fallbackWarnings = new Set<string>();
public constructor(private obj: T) {}
public get<K extends string & keyof T>(key: K, altCaseName?: string): T[K] {
const val = this.obj[key];
if (val !== undefined) return val;
const fallbackKey = altCaseName ?? snakeToCamel(key);
const fallback = this.obj[<K>fallbackKey];
if (!!fallback && !this.fallbackWarnings.has(fallbackKey)) {
this.fallbackWarnings.add(fallbackKey);
console.warn(`Using deprecated camelCase config ${fallbackKey}`);
console.warn(
"See https://github.com/vector-im/element-web/blob/develop/docs/config.md#-deprecation-notice",
);
}
return fallback;
}
// Make JSON.stringify() pretend that everything is fine
public toJSON(): T {
return this.obj;
}
}
+112
View File
@@ -0,0 +1,112 @@
/*
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 { groupBy, mapValues, maxBy, minBy, sumBy, takeRight } from "lodash";
import { type MatrixClient, type Room, type RoomMember } from "matrix-js-sdk/src/matrix";
import { KnownMembership } from "matrix-js-sdk/src/types";
import { type Member } from "./direct-messages";
import DMRoomMap from "./DMRoomMap";
export const compareMembers =
(
activityScores: Record<string, IActivityScore | undefined>,
memberScores: Record<string, IMemberScore | undefined>,
) =>
(a: Member | RoomMember, b: Member | RoomMember): number => {
const aActivityScore = activityScores[a.userId]?.score ?? 0;
const aMemberScore = memberScores[a.userId]?.score ?? 0;
const aScore = aActivityScore + aMemberScore;
const aNumRooms = memberScores[a.userId]?.numRooms ?? 0;
const bActivityScore = activityScores[b.userId]?.score ?? 0;
const bMemberScore = memberScores[b.userId]?.score ?? 0;
const bScore = bActivityScore + bMemberScore;
const bNumRooms = memberScores[b.userId]?.numRooms ?? 0;
if (aScore === bScore) {
if (aNumRooms === bNumRooms) {
// If there is no activity between members,
// keep the order received from the user directory search results
return 0;
}
return bNumRooms - aNumRooms;
}
return bScore - aScore;
};
function joinedRooms(cli: MatrixClient): Room[] {
return (
cli
.getRooms()
.filter((r) => r.getMyMembership() === KnownMembership.Join)
// Skip low priority rooms and DMs
.filter((r) => !DMRoomMap.shared().getUserIdForRoomId(r.roomId))
.filter((r) => !Object.keys(r.tags).includes("m.lowpriority"))
);
}
interface IActivityScore {
lastSpoke: number;
score: number;
}
// Score people based on who have sent messages recently, as a way to improve the quality of suggestions.
// We do this by checking every room to see who has sent a message in the last few hours, and giving them
// a score which correlates to the freshness of their message. In theory, this results in suggestions
// which are closer to "continue this conversation" rather than "this person exists".
export function buildActivityScores(cli: MatrixClient): { [userId: string]: IActivityScore } {
const now = new Date().getTime();
const earliestAgeConsidered = now - 60 * 60 * 1000; // 1 hour ago
const maxMessagesConsidered = 50; // so we don't iterate over a huge amount of traffic
const events = joinedRooms(cli)
.flatMap((room) => takeRight(room.getLiveTimeline().getEvents(), maxMessagesConsidered))
.filter((ev) => ev.getTs() > earliestAgeConsidered);
const senderEvents = groupBy(events, (ev) => ev.getSender());
// If the iteratee in mapValues returns undefined that key will be removed from the resultant object
return mapValues(senderEvents, (events) => {
if (!events.length) return;
const lastEvent = maxBy(events, (ev) => ev.getTs())!;
const distanceFromNow = Math.abs(now - lastEvent.getTs()); // abs to account for slight future messages
const inverseTime = now - earliestAgeConsidered - distanceFromNow;
return {
lastSpoke: lastEvent.getTs(),
// Scores from being in a room give a 'good' score of about 1.0-1.5, so for our
// score we'll try and award at least 1.0 for making the list, with 4.0 being
// an approximate maximum for being selected.
score: Math.max(1, inverseTime / (15 * 60 * 1000)), // 15min segments to keep scores sane
};
}) as { [key: string]: IActivityScore };
}
interface IMemberScore {
member: RoomMember;
score: number;
numRooms: number;
}
export function buildMemberScores(cli: MatrixClient): { [userId: string]: IMemberScore } {
const maxConsideredMembers = 200;
const consideredRooms = joinedRooms(cli).filter((room) => room.getJoinedMemberCount() < maxConsideredMembers);
const memberPeerEntries = consideredRooms.flatMap((room) =>
room.getJoinedMembers().map((member) => ({ member, roomSize: room.getJoinedMemberCount() })),
);
const userMeta = groupBy(memberPeerEntries, ({ member }) => member.userId);
// If the iteratee in mapValues returns undefined that key will be removed from the resultant object
return mapValues(userMeta, (roomMemberships) => {
if (!roomMemberships.length) return;
const maximumPeers = maxConsideredMembers * roomMemberships.length;
const totalPeers = sumBy(roomMemberships, (entry) => entry.roomSize);
return {
member: minBy(roomMemberships, (entry) => entry.roomSize)!.member,
numRooms: roomMemberships.length,
score: Math.max(0, Math.pow(1 - totalPeers / maximumPeers, 5)),
};
}) as { [userId: string]: IMemberScore };
}
+130
View File
@@ -0,0 +1,130 @@
/*
Copyright 2024 New Vector Ltd.
Copyright 2019-2021 , 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.
*/
/**
* Retrieves the IndexedDB factory object.
*
* @returns {IDBFactory | undefined} The IndexedDB factory object if available, or undefined if it is not supported.
*/
export function getIDBFactory(): IDBFactory | undefined {
// IndexedDB loading is lazy for easier testing.
// just *accessing* _indexedDB throws an exception in firefox with
// indexeddb disabled.
try {
// `self` is preferred for service workers, which access this file's functions.
// We check `self` first because `window` returns something which doesn't work for service workers.
// Note: `self?.indexedDB ?? window.indexedDB` breaks in service workers for unknown reasons.
return self?.indexedDB ? self.indexedDB : window.indexedDB;
} catch {}
}
let idb: IDBDatabase | null = null;
async function idbInit(): Promise<void> {
if (!getIDBFactory()) {
throw new Error("IndexedDB not available");
}
idb = await new Promise((resolve, reject) => {
const request = getIDBFactory()!.open("matrix-react-sdk", 1);
request.onerror = reject;
request.onsuccess = (): void => {
resolve(request.result);
};
request.onupgradeneeded = (): void => {
const db = request.result;
db.createObjectStore("pickleKey");
db.createObjectStore("account");
};
});
}
async function idbTransaction(
table: string,
mode: IDBTransactionMode,
fn: (objectStore: IDBObjectStore) => IDBRequest<any>,
): Promise<any> {
if (!idb) {
await idbInit();
}
return new Promise((resolve, reject) => {
const txn = idb!.transaction([table], mode);
txn.onerror = reject;
const objectStore = txn.objectStore(table);
const request = fn(objectStore);
request.onerror = reject;
request.onsuccess = (): void => {
resolve(request.result);
};
});
}
/**
* Loads an item from an IndexedDB table within the underlying `matrix-react-sdk` database.
*
* If IndexedDB access is not supported in the environment, an error is thrown.
*
* @param {string} table The name of the object store in IndexedDB.
* @param {string | string[]} key The key where the data is stored.
* @returns {Promise<any>} A promise that resolves with the retrieved item from the table.
*/
export async function idbLoad(table: string, key: string | string[]): Promise<any> {
if (!idb) {
await idbInit();
}
return idbTransaction(table, "readonly", (objectStore) => objectStore.get(key));
}
/**
* Saves data to an IndexedDB table within the underlying `matrix-react-sdk` database.
*
* If IndexedDB access is not supported in the environment, an error is thrown.
*
* @param {string} table The name of the object store in the IndexedDB.
* @param {string|string[]} key The key to use for storing the data.
* @param {*} data The data to be saved.
* @returns {Promise<void>} A promise that resolves when the data is saved successfully.
*/
export async function idbSave(table: string, key: string | string[], data: any): Promise<void> {
if (!idb) {
await idbInit();
}
return idbTransaction(table, "readwrite", (objectStore) => objectStore.put(data, key));
}
/**
* Deletes a record from an IndexedDB table within the underlying `matrix-react-sdk` database.
*
* If IndexedDB access is not supported in the environment, an error is thrown.
*
* @param {string} table The name of the object store where the record is stored.
* @param {string|string[]} key The key of the record to be deleted.
* @returns {Promise<void>} A Promise that resolves when the record(s) have been successfully deleted.
*/
export async function idbDelete(table: string, key: string | string[]): Promise<void> {
if (!idb) {
await idbInit();
}
return idbTransaction(table, "readwrite", (objectStore) => objectStore.delete(key));
}
/**
* Clears all records from an IndexedDB table within the underlying `matrix-react-sdk` database.
*
* If IndexedDB access is not supported in the environment, an error is thrown.
*
* @param {string} table The name of the object store where the records are stored.
* @returns {Promise<void>} A Promise that resolves when the record(s) have been successfully deleted.
*/
export async function idbClear(table: string): Promise<void> {
if (!idb) {
await idbInit();
}
return idbTransaction(table, "readwrite", (objectStore) => objectStore.clear());
}
+176
View File
@@ -0,0 +1,176 @@
/*
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 { IndexedDBStore, IndexedDBCryptoStore } from "matrix-js-sdk/src/matrix";
import { logger } from "matrix-js-sdk/src/logger";
import { getIDBFactory } from "./StorageAccess";
const localStorage = window.localStorage;
// The JS SDK will add a prefix of "matrix-js-sdk:" to the sync store name.
const SYNC_STORE_NAME = "riot-web-sync";
const LEGACY_CRYPTO_STORE_NAME = "matrix-js-sdk:crypto";
const RUST_CRYPTO_STORE_NAME = "matrix-js-sdk::matrix-sdk-crypto";
function log(msg: string): void {
logger.log(`StorageManager: ${msg}`);
}
function error(msg: string, ...args: any[]): void {
logger.error(`StorageManager: ${msg}`, ...args);
}
export function tryPersistStorage(): void {
if (navigator.storage && navigator.storage.persist) {
navigator.storage.persist().then((persistent) => {
logger.log("StorageManager: Persistent?", persistent);
});
} else if (document.requestStorageAccess) {
// Safari
document.requestStorageAccess().then(
() => logger.log("StorageManager: Persistent?", true),
() => logger.log("StorageManager: Persistent?", false),
);
} else {
logger.log("StorageManager: Persistence unsupported");
}
}
export async function checkConsistency(): Promise<{
healthy: boolean;
cryptoInited: boolean;
dataInCryptoStore: boolean;
dataInLocalStorage: boolean;
}> {
log("Checking storage consistency");
log(`Local storage supported? ${!!localStorage}`);
log(`IndexedDB supported? ${!!getIDBFactory()}`);
let dataInLocalStorage = false;
let dataInCryptoStore = false;
let cryptoInited = false;
let healthy = true;
if (localStorage) {
dataInLocalStorage = localStorage.length > 0;
log(`Local storage contains data? ${dataInLocalStorage}`);
cryptoInited = !!localStorage.getItem("mx_crypto_initialised");
log(`Crypto initialised? ${cryptoInited}`);
} else {
healthy = false;
error("Local storage cannot be used on this browser");
}
if (getIDBFactory() && localStorage) {
const results = await checkSyncStore();
if (!results.healthy) {
healthy = false;
}
} else {
healthy = false;
error("Sync store cannot be used on this browser");
}
if (getIDBFactory()) {
const results = await checkCryptoStore();
dataInCryptoStore = results.exists;
if (!results.healthy) {
healthy = false;
}
} else {
healthy = false;
error("Crypto store cannot be used on this browser");
}
if (dataInLocalStorage && cryptoInited && !dataInCryptoStore) {
healthy = false;
error(
"Data exists in local storage and crypto is marked as initialised but no data found in crypto store. " +
"IndexedDB storage has likely been evicted by the browser!",
);
}
if (healthy) {
log("Storage consistency checks passed");
} else {
error("Storage consistency checks failed");
}
return {
dataInLocalStorage,
dataInCryptoStore,
cryptoInited,
healthy,
};
}
interface StoreCheck {
exists: boolean;
healthy: boolean;
}
async function checkSyncStore(): Promise<StoreCheck> {
let exists = false;
try {
exists = await IndexedDBStore.exists(getIDBFactory()!, SYNC_STORE_NAME);
log(`Sync store using IndexedDB contains data? ${exists}`);
return { exists, healthy: true };
} catch (e) {
error("Sync store using IndexedDB inaccessible", e);
}
log("Sync store using memory only");
return { exists, healthy: false };
}
async function checkCryptoStore(): Promise<StoreCheck> {
// check first if there is a rust crypto store
try {
const rustDbExists = await IndexedDBCryptoStore.exists(getIDBFactory()!, RUST_CRYPTO_STORE_NAME);
log(`Rust Crypto store using IndexedDB contains data? ${rustDbExists}`);
if (rustDbExists) {
// There was an existing rust database, so consider it healthy.
return { exists: true, healthy: true };
} else {
// No rust store, so let's check if there is a legacy store not yet migrated.
try {
const legacyIdbExists = await IndexedDBCryptoStore.existsAndIsNotMigrated(
getIDBFactory()!,
LEGACY_CRYPTO_STORE_NAME,
);
log(`Legacy Crypto store using IndexedDB contains non migrated data? ${legacyIdbExists}`);
return { exists: legacyIdbExists, healthy: true };
} catch (e) {
error("Legacy crypto store using IndexedDB inaccessible", e);
}
// No need to check local storage or memory as rust stack doesn't support them.
// Given that rust stack requires indexeddb, set healthy to false.
return { exists: false, healthy: false };
}
} catch (e) {
error("Rust crypto store using IndexedDB inaccessible", e);
return { exists: false, healthy: false };
}
}
/**
* Sets whether crypto has ever been successfully
* initialised on this client.
* StorageManager uses this to determine whether indexeddb
* has been wiped by the browser: this flag is saved to localStorage
* and if it is true and not crypto data is found, an error is
* presented to the user.
*
* @param {boolean} cryptoInited True if crypto has been set up
*/
export function setCryptoInitialised(cryptoInited: boolean): void {
localStorage.setItem("mx_crypto_initialised", String(cryptoInited));
}
+117
View File
@@ -0,0 +1,117 @@
/*
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.
*/
/**
A countdown timer, exposing a promise api.
A timer starts in a non-started state,
and needs to be started by calling `start()`` on it first.
Timers can be `abort()`-ed which makes the promise reject prematurely.
Once a timer is finished or aborted, it can't be started again
(because the promise should not be replaced). Instead, create
a new one through `clone()` or `cloneIfRun()`.
*/
export default class Timer {
private timerHandle?: number;
private startTs?: number;
private deferred!: PromiseWithResolvers<void>;
public constructor(private timeout: number) {
this.setNotStarted();
}
private setNotStarted(): void {
this.timerHandle = undefined;
this.startTs = undefined;
this.deferred = Promise.withResolvers();
this.deferred.promise = this.deferred.promise.finally(() => {
this.timerHandle = undefined;
});
}
private onTimeout = (): void => {
const now = Date.now();
const elapsed = now - this.startTs!;
if (elapsed >= this.timeout) {
this.deferred.resolve();
this.setNotStarted();
} else {
const delta = this.timeout - elapsed;
this.timerHandle = window.setTimeout(this.onTimeout, delta);
}
};
public changeTimeout(timeout: number): void {
if (timeout === this.timeout) {
return;
}
const isSmallerTimeout = timeout < this.timeout;
this.timeout = timeout;
if (this.isRunning() && isSmallerTimeout) {
clearTimeout(this.timerHandle);
this.onTimeout();
}
}
/**
* if not started before, starts the timer.
* @returns {Timer} the same timer
*/
public start(): Timer {
if (!this.isRunning()) {
this.startTs = Date.now();
this.timerHandle = window.setTimeout(this.onTimeout, this.timeout);
}
return this;
}
/**
* (re)start the timer. If it's running, reset the timeout. If not, start it.
* @returns {Timer} the same timer
*/
public restart(): Timer {
if (this.isRunning()) {
// don't clearTimeout here as this method
// can be called in fast succession,
// instead just take note and compare
// when the already running timeout expires
this.startTs = Date.now();
return this;
} else {
return this.start();
}
}
/**
* if the timer is running, abort it,
* and reject the promise for this timer.
* @returns {Timer} the same timer
*/
public abort(): Timer {
if (this.isRunning()) {
clearTimeout(this.timerHandle);
this.deferred.reject(new Error("Timer was aborted."));
this.setNotStarted();
}
return this;
}
/**
*promise that will resolve when the timer elapses,
*or is rejected when abort is called
*@return {Promise}
*/
public finished(): Promise<void> {
return this.deferred.promise;
}
public isRunning(): boolean {
return this.timerHandle !== undefined;
}
}
+51
View File
@@ -0,0 +1,51 @@
/*
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.
*/
/**
* If a url has no path component, etc. abbreviate it to just the hostname
*
* @param {string} u The url to be abbreviated
* @returns {string} The abbreviated url
*/
export function abbreviateUrl(u?: string): string {
if (!u) return "";
let parsedUrl: URL;
try {
parsedUrl = parseUrl(u);
} catch (e) {
console.error(e);
// if it's something we can't parse as a url then just return it
return u;
}
if (parsedUrl.pathname === "/") {
// we ignore query / hash parts: these aren't relevant for IS server URLs
return parsedUrl.host || "";
}
return u;
}
export function unabbreviateUrl(u?: string): string {
if (!u) return "";
let longUrl = u;
if (!u.startsWith("https://")) longUrl = "https://" + u;
const parsed = parseUrl(longUrl);
if (!parsed.hostname) return u;
return longUrl;
}
export function parseUrl(u: string): URL {
if (!u.includes(":")) {
u = window.location.protocol + u;
}
return new URL(u);
}
@@ -0,0 +1,31 @@
/*
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 OidcClientConfig } from "matrix-js-sdk/src/matrix";
export interface ValidatedServerConfig {
hsUrl: string;
hsName: string;
hsNameIsDifferent: boolean;
isUrl: string;
isDefault: boolean;
// when the server config is based on static URLs the hsName is not resolvable and things may wish to use hsUrl
isNameResolvable: boolean;
warning: string | Error;
/**
* Config related to delegated authentication
* Included when delegated auth is configured and valid, otherwise undefined.
* From issuer's .well-known/openid-configuration.
* Used for OIDC native flow authentication.
*/
delegatedAuthentication?: OidcClientConfig;
}
+74
View File
@@ -0,0 +1,74 @@
/*
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 IClientWellKnown, type MatrixClient } from "matrix-js-sdk/src/matrix";
import { UnstableValue } from "matrix-js-sdk/src/NamespacedValue";
const CALL_BEHAVIOUR_WK_KEY = "io.element.call_behaviour";
const E2EE_WK_KEY = "io.element.e2ee";
const E2EE_WK_KEY_DEPRECATED = "im.vector.riot.e2ee";
export const TILE_SERVER_WK_KEY = new UnstableValue("m.tile_server", "org.matrix.msc3488.tile_server");
const EMBEDDED_PAGES_WK_PROPERTY = "io.element.embedded_pages";
/* eslint-disable camelcase */
export interface ICallBehaviourWellKnown {
widget_build_url?: string;
ignore_dm?: boolean;
}
export interface IE2EEWellKnown {
default?: boolean;
/**
* Forces the encryption to disabled for all new rooms
* When true, overrides configured 'default' behaviour
* Hides the option to enable encryption on room creation
* Disables the option to enable encryption in room settings for all new and existing rooms
*/
force_disable?: boolean;
}
export interface ITileServerWellKnown {
map_style_url?: string;
}
export interface IEmbeddedPagesWellKnown {
home_url?: string;
}
/* eslint-enable camelcase */
export function getCallBehaviourWellKnown(matrixClient: MatrixClient): ICallBehaviourWellKnown {
const clientWellKnown = matrixClient.getClientWellKnown();
return clientWellKnown?.[CALL_BEHAVIOUR_WK_KEY];
}
export function getE2EEWellKnown(matrixClient: MatrixClient): IE2EEWellKnown | null {
const clientWellKnown = matrixClient.getClientWellKnown();
if (clientWellKnown?.[E2EE_WK_KEY]) {
return clientWellKnown[E2EE_WK_KEY];
}
if (clientWellKnown?.[E2EE_WK_KEY_DEPRECATED]) {
return clientWellKnown[E2EE_WK_KEY_DEPRECATED];
}
return null;
}
export function getTileServerWellKnown(matrixClient: MatrixClient): ITileServerWellKnown | undefined {
return tileServerFromWellKnown(matrixClient.getClientWellKnown());
}
export function tileServerFromWellKnown(clientWellKnown?: IClientWellKnown | undefined): ITileServerWellKnown {
return clientWellKnown?.[TILE_SERVER_WK_KEY.name] ?? clientWellKnown?.[TILE_SERVER_WK_KEY.altName];
}
export function getEmbeddedPagesWellKnown(matrixClient: MatrixClient | undefined): IEmbeddedPagesWellKnown | undefined {
return embeddedPagesFromWellKnown(matrixClient?.getClientWellKnown());
}
export function embeddedPagesFromWellKnown(clientWellKnown?: IClientWellKnown): IEmbeddedPagesWellKnown {
return clientWellKnown?.[EMBEDDED_PAGES_WK_PROPERTY];
}
+79
View File
@@ -0,0 +1,79 @@
/*
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 { logger } from "matrix-js-sdk/src/logger";
import { type IDestroyable } from "./IDestroyable";
import { arrayFastClone } from "./arrays";
export type WhenFn<T extends string | number> = (w: Whenable<T>) => void;
/**
* Whenables are a cheap way to have Observable patterns mixed with typical
* usage of Promises, without having to tear down listeners or calls. Whenables
* are intended to be used when a condition will be met multiple times and
* the consumer needs to know *when* that happens.
*/
export abstract class Whenable<T extends string | number> implements IDestroyable {
private listeners: { condition: T | null; fn: WhenFn<T> }[] = [];
/**
* Sets up a call to `fn` *when* the `condition` is met.
* @param condition The condition to match.
* @param fn The function to call.
* @returns This.
*/
public when(condition: T, fn: WhenFn<T>): Whenable<T> {
this.listeners.push({ condition, fn });
return this;
}
/**
* Sets up a call to `fn` *when* any of the `conditions` are met.
* @param conditions The conditions to match.
* @param fn The function to call.
* @returns This.
*/
public whenAnyOf(conditions: T[], fn: WhenFn<T>): Whenable<T> {
for (const condition of conditions) {
this.when(condition, fn);
}
return this;
}
/**
* Sets up a call to `fn` *when* any condition is met.
* @param fn The function to call.
* @returns This.
*/
public whenAnything(fn: WhenFn<T>): Whenable<T> {
this.listeners.push({ condition: null, fn });
return this;
}
/**
* Notifies all the listeners of a given condition.
* @param condition The new condition that has been met.
*/
protected notifyCondition(condition: T): void {
const listeners = arrayFastClone(this.listeners); // clone just in case the handler modifies us
for (const listener of listeners) {
if (listener.condition === null || listener.condition === condition) {
try {
listener.fn(this);
} catch (e) {
logger.error(`Error calling whenable listener for ${condition}:`, e);
}
}
}
}
public destroy(): void {
this.listeners = [];
}
}
+32
View File
@@ -0,0 +1,32 @@
/*
Copyright 2024 New Vector Ltd.
Copyright 2017-2020 The Matrix.org Foundation C.I.C.
Copyright 2019 Travis Ralston
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";
export interface IApp extends IWidget {
"roomId": string;
"eventId"?: string; // not present on virtual widgets
// eslint-disable-next-line camelcase
"avatar_url"?: string; // MSC2765 https://github.com/matrix-org/matrix-doc/pull/2765
// Whether the widget was created from `widget_build_url` and thus is a call widget of some kind
"io.element.managed_hybrid"?: boolean;
}
export interface IWidgetEvent {
id: string;
type: string;
sender: string;
// eslint-disable-next-line camelcase
state_key: string;
content: IApp;
}
export interface UserWidget extends Omit<IWidgetEvent, "content"> {
content: IWidget & Partial<IApp>;
}
+572
View File
@@ -0,0 +1,572 @@
/*
Copyright 2024 New Vector Ltd.
Copyright 2017-2020 The Matrix.org Foundation C.I.C.
Copyright 2019 Travis Ralston
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 { useCallback, useEffect, useState } from "react";
import { base32 } from "rfc4648";
import { capitalize } from "lodash";
import { type IWidget, type IWidgetData } from "matrix-widget-api";
import { type Room, ClientEvent, type MatrixClient, RoomStateEvent, type MatrixEvent } from "matrix-js-sdk/src/matrix";
import { KnownMembership } from "matrix-js-sdk/src/types";
import { logger } from "matrix-js-sdk/src/logger";
import { CallType } from "matrix-js-sdk/src/webrtc/call";
import { LOWERCASE, secureRandomString, secureRandomStringFrom } from "matrix-js-sdk/src/randomstring";
import PlatformPeg from "../PlatformPeg";
import SdkConfig from "../SdkConfig";
import dis from "../dispatcher/dispatcher";
import WidgetEchoStore from "../stores/WidgetEchoStore";
import { IntegrationManagers } from "../integrations/IntegrationManagers";
import { WidgetType } from "../widgets/WidgetType";
import { Jitsi } from "../widgets/Jitsi";
import { objectClone } from "./objects";
import { _t } from "../languageHandler";
import WidgetStore, { type IApp, isAppWidget } from "../stores/WidgetStore";
import { parseUrl } from "./UrlUtils";
import { useEventEmitter } from "../hooks/useEventEmitter";
import { WidgetLayoutStore } from "../stores/widgets/WidgetLayoutStore";
import { type IWidgetEvent, type UserWidget } from "./WidgetUtils-types";
// How long we wait for the state event echo to come back from the server
// before waitFor[Room/User]Widget rejects its promise
const WIDGET_WAIT_TIME = 20000;
export type { IWidgetEvent, UserWidget };
export default class WidgetUtils {
/**
* Returns true if user is able to send state events to modify widgets in this room
* (Does not apply to non-room-based / user widgets)
* @param client The matrix client of the logged-in user
* @param roomId -- The ID of the room to check
* @return Boolean -- true if the user can modify widgets in this room
* @throws Error -- specifies the error reason
*/
public static canUserModifyWidgets(client: MatrixClient, roomId?: string): boolean {
if (!roomId) {
logger.warn("No room ID specified");
return false;
}
if (!client) {
logger.warn("User must be be logged in");
return false;
}
const room = client.getRoom(roomId);
if (!room) {
logger.warn(`Room ID ${roomId} is not recognised`);
return false;
}
const me = client.getUserId();
if (!me) {
logger.warn("Failed to get user ID");
return false;
}
if (room.getMyMembership() !== KnownMembership.Join) {
logger.warn(`User ${me} is not in room ${roomId}`);
return false;
}
// TODO: Enable support for m.widget event type (https://github.com/vector-im/element-web/issues/13111)
return room.currentState.maySendStateEvent("im.vector.modular.widgets", me);
}
// TODO: Generify the name of this function. It's not just scalar.
/**
* Returns true if specified url is a scalar URL, typically https://scalar.vector.im/api
* @param matrixClient The matrix client of the logged-in user
* @param {[type]} testUrlString URL to check
* @return {Boolean} True if specified URL is a scalar URL
*/
public static isScalarUrl(testUrlString?: string): boolean {
if (!testUrlString) {
logger.error("Scalar URL check failed. No URL specified");
return false;
}
const testUrl = parseUrl(testUrlString);
let scalarUrls = SdkConfig.get().integrations_widgets_urls;
if (!scalarUrls || scalarUrls.length === 0) {
const defaultManager = IntegrationManagers.sharedInstance().getPrimaryManager();
if (defaultManager) {
scalarUrls = [defaultManager.apiUrl];
} else {
scalarUrls = [];
}
}
for (let i = 0; i < scalarUrls.length; i++) {
const scalarUrl = parseUrl(scalarUrls[i]);
if (testUrl && scalarUrl) {
if (
testUrl.protocol === scalarUrl.protocol &&
testUrl.host === scalarUrl.host &&
scalarUrl.pathname &&
testUrl.pathname?.startsWith(scalarUrl.pathname)
) {
return true;
}
}
}
return false;
}
/**
* Returns a promise that resolves when a widget with the given
* ID has been added as a user widget (ie. the accountData event
* arrives) or rejects after a timeout
*
* @param client The matrix client of the logged-in user
* @param widgetId The ID of the widget to wait for
* @param add True to wait for the widget to be added,
* false to wait for it to be deleted.
* @returns {Promise} that resolves when the widget is in the
* requested state according to the `add` param
*/
public static waitForUserWidget(client: MatrixClient, widgetId: string, add: boolean): Promise<void> {
return new Promise((resolve, reject) => {
// Tests an account data event, returning true if it's in the state
// we're waiting for it to be in
function eventInIntendedState(ev?: MatrixEvent): boolean {
if (!ev) return false;
if (add) {
return ev.getContent()[widgetId] !== undefined;
} else {
return ev.getContent()[widgetId] === undefined;
}
}
const startingAccountDataEvent = client.getAccountData("m.widgets");
if (eventInIntendedState(startingAccountDataEvent)) {
resolve();
return;
}
function onAccountData(ev: MatrixEvent): void {
const currentAccountDataEvent = client.getAccountData("m.widgets");
if (eventInIntendedState(currentAccountDataEvent)) {
client.removeListener(ClientEvent.AccountData, onAccountData);
clearTimeout(timerId);
resolve();
}
}
const timerId = window.setTimeout(() => {
client.removeListener(ClientEvent.AccountData, onAccountData);
reject(new Error("Timed out waiting for widget ID " + widgetId + " to appear"));
}, WIDGET_WAIT_TIME);
client.on(ClientEvent.AccountData, onAccountData);
});
}
/**
* Returns a promise that resolves when a widget with the given
* ID has been added as a room widget in the given room (ie. the
* room state event arrives) or rejects after a timeout
*
* @param client The matrix client of the logged-in user
* @param {string} widgetId The ID of the widget to wait for
* @param {string} roomId The ID of the room to wait for the widget in
* @param {boolean} add True to wait for the widget to be added,
* false to wait for it to be deleted.
* @returns {Promise} that resolves when the widget is in the
* requested state according to the `add` param
*/
public static waitForRoomWidget(
client: MatrixClient,
widgetId: string,
roomId: string,
add: boolean,
): Promise<void> {
return new Promise((resolve, reject) => {
// Tests a list of state events, returning true if it's in the state
// we're waiting for it to be in
function eventsInIntendedState(evList?: MatrixEvent[]): boolean {
const widgetPresent = evList?.some((ev) => {
return ev.getContent() && ev.getContent()["id"] === widgetId;
});
if (add) {
return !!widgetPresent;
} else {
return !widgetPresent;
}
}
const room = client.getRoom(roomId);
// TODO: Enable support for m.widget event type (https://github.com/vector-im/element-web/issues/13111)
const startingWidgetEvents = room?.currentState.getStateEvents("im.vector.modular.widgets");
if (eventsInIntendedState(startingWidgetEvents)) {
resolve();
return;
}
function onRoomStateEvents(ev: MatrixEvent): void {
if (ev.getRoomId() !== roomId || ev.getType() !== "im.vector.modular.widgets") return;
// TODO: Enable support for m.widget event type (https://github.com/vector-im/element-web/issues/13111)
const currentWidgetEvents = room?.currentState.getStateEvents("im.vector.modular.widgets");
if (eventsInIntendedState(currentWidgetEvents)) {
client.removeListener(RoomStateEvent.Events, onRoomStateEvents);
clearTimeout(timerId);
resolve();
}
}
const timerId = window.setTimeout(() => {
client.removeListener(RoomStateEvent.Events, onRoomStateEvents);
reject(new Error("Timed out waiting for widget ID " + widgetId + " to appear"));
}, WIDGET_WAIT_TIME);
client.on(RoomStateEvent.Events, onRoomStateEvents);
});
}
public static setUserWidget(
client: MatrixClient,
widgetId: string,
widgetType: WidgetType,
widgetUrl: string,
widgetName: string,
widgetData: IWidgetData,
): Promise<void> {
// Get the current widgets and clone them before we modify them, otherwise
// we'll modify the content of the old event.
const userWidgets = objectClone(WidgetUtils.getUserWidgets(client));
// Delete existing widget with ID
try {
delete userWidgets[widgetId];
} catch {
logger.error(`$widgetId is non-configurable`);
}
const addingWidget = Boolean(widgetUrl);
const userId = client.getSafeUserId();
const content = {
id: widgetId,
type: widgetType.preferred,
url: widgetUrl,
name: widgetName,
data: widgetData,
creatorUserId: userId,
};
// Add new widget / update
if (addingWidget) {
userWidgets[widgetId] = {
content: content,
sender: userId,
state_key: widgetId,
type: "m.widget",
id: widgetId,
};
}
// This starts listening for when the echo comes back from the server
// since the widget won't appear added until this happens. If we don't
// wait for this, the action will complete but if the user is fast enough,
// the widget still won't actually be there.
return client
.setAccountData("m.widgets", userWidgets)
.then(() => {
return WidgetUtils.waitForUserWidget(client, widgetId, addingWidget);
})
.then(() => {
dis.dispatch({ action: "user_widget_updated" });
});
}
public static setRoomWidget(
client: MatrixClient,
roomId: string,
widgetId: string,
widgetType?: WidgetType,
widgetUrl?: string,
widgetName?: string,
widgetData?: IWidgetData,
widgetAvatarUrl?: string,
): Promise<void> {
let content: Partial<IWidget> & { avatar_url?: string };
const addingWidget = Boolean(widgetUrl);
if (addingWidget) {
content = {
// TODO: Enable support for m.widget event type (https://github.com/vector-im/element-web/issues/13111)
// For now we'll send the legacy event type for compatibility with older apps/elements
type: widgetType?.legacy,
url: widgetUrl,
name: widgetName,
data: widgetData,
avatar_url: widgetAvatarUrl,
};
} else {
content = {};
}
return WidgetUtils.setRoomWidgetContent(client, roomId, widgetId, content as IWidget);
}
public static setRoomWidgetContent(
client: MatrixClient,
roomId: string,
widgetId: string,
content: IWidget & Record<string, any>,
): Promise<void> {
const addingWidget = !!content.url;
WidgetEchoStore.setRoomWidgetEcho(roomId, widgetId, content);
// TODO: Enable support for m.widget event type (https://github.com/vector-im/element-web/issues/13111)
return client
.sendStateEvent(roomId, "im.vector.modular.widgets", content, widgetId)
.then(() => {
return WidgetUtils.waitForRoomWidget(client, widgetId, roomId, addingWidget);
})
.finally(() => {
WidgetEchoStore.removeRoomWidgetEcho(roomId, widgetId);
});
}
/**
* Get room specific widgets
* @param {Room} room The room to get widgets force
* @return {[object]} Array containing current / active room widgets
*/
public static getRoomWidgets(room: Room): MatrixEvent[] {
// TODO: Enable support for m.widget event type (https://github.com/vector-im/element-web/issues/13111)
const appsStateEvents = room.currentState.getStateEvents("im.vector.modular.widgets");
if (!appsStateEvents) {
return [];
}
return appsStateEvents.filter((ev) => {
return ev.getContent().type && ev.getContent().url;
});
}
/**
* Get user specific widgets (not linked to a specific room)
* @param client The matrix client of the logged-in user
* @return {object} Event content object containing current / active user widgets
*/
public static getUserWidgets(client: MatrixClient | undefined): Record<string, UserWidget> {
if (!client) {
throw new Error("User not logged in");
}
const userWidgets = client.getAccountData("m.widgets");
if (userWidgets && userWidgets.getContent()) {
return userWidgets.getContent();
}
return {};
}
/**
* Get user specific widgets (not linked to a specific room) as an array
* @param client The matrix client of the logged-in user
* @return {[object]} Array containing current / active user widgets
*/
public static getUserWidgetsArray(client: MatrixClient | undefined): UserWidget[] {
return Object.values(WidgetUtils.getUserWidgets(client));
}
/**
* Get active stickerpicker widgets (stickerpickers are user widgets by nature)
* @param client The matrix client of the logged-in user
* @return {[object]} Array containing current / active stickerpicker widgets
*/
public static getStickerpickerWidgets(client: MatrixClient | undefined): UserWidget[] {
const widgets = WidgetUtils.getUserWidgetsArray(client);
return widgets.filter((widget) => widget.content?.type === "m.stickerpicker");
}
/**
* Get all integration manager widgets for this user.
* @param client The matrix client of the logged-in user
* @returns {Object[]} An array of integration manager user widgets.
*/
public static getIntegrationManagerWidgets(client: MatrixClient | undefined): UserWidget[] {
const widgets = WidgetUtils.getUserWidgetsArray(client);
return widgets.filter((w) => w.content?.type === "m.integration_manager");
}
/**
* Remove all stickerpicker widgets (stickerpickers are user widgets by nature)
* @param client The matrix client of the logged-in user
* @return {Promise} Resolves on account data updated
*/
public static async removeStickerpickerWidgets(client: MatrixClient | undefined): Promise<void> {
if (!client) {
throw new Error("User not logged in");
}
const widgets = client.getAccountData("m.widgets");
if (!widgets) return;
const userWidgets: Record<string, IWidgetEvent> = widgets.getContent() || {};
Object.entries(userWidgets).forEach(([key, widget]) => {
if (widget.content && widget.content.type === "m.stickerpicker") {
delete userWidgets[key];
}
});
await client.setAccountData("m.widgets", userWidgets);
}
public static async addJitsiWidget(
client: MatrixClient,
roomId: string,
type: CallType,
name: string,
isVideoChannel: boolean,
oobRoomName?: string,
): Promise<void> {
const domain = Jitsi.getInstance().preferredDomain;
const auth = (await Jitsi.getInstance().getJitsiAuth()) ?? undefined;
// Must be globally unique, although predicatablity is not important, the js-sdk has functions to generate
// secure ranom strings, and speed is not important here.
const widgetId = secureRandomString(24);
let confId: string;
if (auth === "openidtoken-jwt") {
// Create conference ID from room ID
// For compatibility with Jitsi, use base32 without padding.
// More details here:
// https://github.com/matrix-org/prosody-mod-auth-matrix-user-verification
confId = base32.stringify(new TextEncoder().encode(roomId), { pad: false });
} else {
// Create a random conference ID (capitalised so the name looks sensible in Jitsi)
confId = `Jitsi${capitalize(secureRandomStringFrom(24, LOWERCASE))}`;
}
// TODO: Remove URL hacks when the mobile clients eventually support v2 widgets
const widgetUrl = new URL(WidgetUtils.getLocalJitsiWrapperUrl({ auth }));
widgetUrl.search = ""; // Causes the URL class use searchParams instead
widgetUrl.searchParams.set("confId", confId);
await WidgetUtils.setRoomWidget(client, roomId, widgetId, WidgetType.JITSI, widgetUrl.toString(), name, {
conferenceId: confId,
roomName: oobRoomName ?? client.getRoom(roomId)?.name,
isAudioOnly: type === CallType.Voice,
isVideoChannel,
domain,
auth,
});
}
public static makeAppConfig(
appId: string,
app: Partial<IApp>,
senderUserId: string,
roomId: string | undefined,
eventId: string | undefined,
): IApp {
if (!senderUserId) {
throw new Error("Widgets must be created by someone - provide a senderUserId");
}
app.creatorUserId = senderUserId;
app.id = appId;
app.roomId = roomId;
app.eventId = eventId;
app.name = app.name || app.type;
return app as IApp;
}
public static getLocalJitsiWrapperUrl(opts: { forLocalRender?: boolean; auth?: string } = {}): string {
// NB. we can't just encodeURIComponent all of these because the $ signs need to be there
const queryStringParts = [
"conferenceDomain=$domain",
"conferenceId=$conferenceId",
"isAudioOnly=$isAudioOnly",
"startWithAudioMuted=$startWithAudioMuted",
"startWithVideoMuted=$startWithVideoMuted",
"isVideoChannel=$isVideoChannel",
"displayName=$matrix_display_name",
"avatarUrl=$matrix_avatar_url",
"userId=$matrix_user_id",
"roomId=$matrix_room_id",
"theme=$theme",
"roomName=$roomName",
`supportsScreensharing=${PlatformPeg.get()?.supportsJitsiScreensharing()}`,
"language=$org.matrix.msc2873.client_language",
];
if (opts.auth) {
queryStringParts.push(`auth=${opts.auth}`);
}
const queryString = queryStringParts.join("&");
let baseUrl = window.location.href;
if (window.location.protocol !== "https:" && !opts.forLocalRender) {
// Use an external wrapper if we're not locally rendering the widget. This is usually
// the URL that will end up in the widget event, so we want to make sure it's relatively
// safe to send.
// We'll end up using a local render URL when we see a Jitsi widget anyways, so this is
// really just for backwards compatibility and to appease the spec.
baseUrl = PlatformPeg.get()!.baseUrl;
}
const url = new URL("jitsi.html#" + queryString, baseUrl); // this strips hash fragment from baseUrl
return url.href;
}
public static getWidgetName(app?: IWidget): string {
return app?.name?.trim() || _t("widget|no_name");
}
public static getWidgetDataTitle(app?: IWidget): string {
return app?.data?.title?.trim() || "";
}
public static getWidgetUid(app?: IApp | IWidget): string {
return app ? WidgetUtils.calcWidgetUid(app.id, isAppWidget(app) ? app.roomId : undefined) : "";
}
public static calcWidgetUid(widgetId: string, roomId?: string): string {
return roomId ? `room_${roomId}_${widgetId}` : `user_${widgetId}`;
}
public static editWidget(room: Room, app: IWidget): void {
// noinspection JSIgnoredPromiseFromCall
IntegrationManagers.sharedInstance()
.getPrimaryManager()
?.open(room, "type_" + app.type, app.id);
}
public static isManagedByManager(app: IWidget): boolean {
if (WidgetUtils.isScalarUrl(app.url)) {
const managers = IntegrationManagers.sharedInstance();
if (managers.hasManager()) {
// TODO: Pick the right manager for the widget
const defaultManager = managers.getPrimaryManager();
return WidgetUtils.isScalarUrl(defaultManager?.apiUrl);
}
}
return false;
}
}
/**
* Hook to get the widgets for a room and update when they change
* @param room the room to get widgets for
*/
export const useWidgets = (room: Room): IApp[] => {
const [apps, setApps] = useState<IApp[]>(() => WidgetStore.instance.getApps(room.roomId));
const updateApps = useCallback(() => {
// Copy the array so that we always trigger a re-render, as some updates mutate the array of apps/settings
setApps([...WidgetStore.instance.getApps(room.roomId)]);
}, [room]);
useEffect(updateApps, [room, updateApps]);
useEventEmitter(WidgetStore.instance, room.roomId, updateApps);
useEventEmitter(WidgetLayoutStore.instance, WidgetLayoutStore.emissionForRoom(room), updateApps);
return apps;
};
+364
View File
@@ -0,0 +1,364 @@
/*
Copyright 2024 New Vector Ltd.
Copyright 2020, 2021 The Matrix.org Foundation C.I.C.
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE files in the repository root for full details.
*/
import { percentageOf, percentageWithin } from "@element-hq/web-shared-components";
/**
* Quickly resample an array to have less/more data points. If an input which is larger
* than the desired size is provided, it will be downsampled. Similarly, if the input
* is smaller than the desired size then it will be upsampled.
* @param {number[]} input The input array to resample.
* @param {number} points The number of samples to end up with.
* @returns {number[]} The resampled array.
*/
export function arrayFastResample(input: number[], points: number): number[] {
if (input.length === points) return input; // short-circuit a complicated call
// Heavily inspired by matrix-media-repo (used with permission)
// https://github.com/turt2live/matrix-media-repo/blob/abe72c87d2e29/util/util_audio/fastsample.go#L10
const samples: number[] = [];
if (input.length > points) {
// Danger: this loop can cause out of memory conditions if the input is too small.
const everyNth = Math.round(input.length / points);
for (let i = 0; i < input.length; i += everyNth) {
samples.push(input[i]);
}
} else {
// Smaller inputs mean we have to spread the values over the desired length. We
// end up overshooting the target length in doing this, but we're not looking to
// be super accurate so we'll let the sanity trims do their job.
const spreadFactor = Math.ceil(points / input.length);
for (const val of input) {
samples.push(...arraySeed(val, spreadFactor));
}
}
// Trim to size & return
return arrayTrimFill(samples, points, arraySeed(input[input.length - 1], points));
}
/**
* Attempts a smooth resample of the given array. This is functionally similar to arrayFastResample
* though can take longer due to the smoothing of data.
* @param {number[]} input The input array to resample.
* @param {number} points The number of samples to end up with.
* @returns {number[]} The resampled array.
*/
export function arraySmoothingResample(input: number[], points: number): number[] {
if (input.length === points) return input; // short-circuit a complicated call
let samples: number[] = [];
if (input.length > points) {
// We're downsampling. To preserve the curve we'll actually reduce our sample
// selection and average some points between them.
// All we're doing here is repeatedly averaging the waveform down to near our
// target value. We don't average down to exactly our target as the loop might
// never end, and we can over-average the data. Instead, we'll get as far as
// we can and do a followup fast resample (the neighbouring points will be close
// to the actual waveform, so we can get away with this safely).
while (samples.length > points * 2 || samples.length === 0) {
samples = [];
for (let i = 1; i < input.length - 1; i += 2) {
const prevPoint = input[i - 1];
const nextPoint = input[i + 1];
const currPoint = input[i];
const average = (prevPoint + nextPoint + currPoint) / 3;
samples.push(average);
}
input = samples;
}
return arrayFastResample(samples, points);
} else {
// In practice there's not much purpose in burning CPU for short arrays only to
// end up with a result that can't possibly look much different than the fast
// resample, so just skip ahead to the fast resample.
return arrayFastResample(input, points);
}
}
/**
* Rescales the input array to have values that are inclusively within the provided
* minimum and maximum.
* @param {number[]} input The array to rescale.
* @param {number} newMin The minimum value to scale to.
* @param {number} newMax The maximum value to scale to.
* @returns {number[]} The rescaled array.
*/
export function arrayRescale(input: number[], newMin: number, newMax: number): number[] {
const min: number = Math.min(...input);
const max: number = Math.max(...input);
return input.map((v) => percentageWithin(percentageOf(v, min, max), newMin, newMax));
}
/**
* Creates an array of the given length, seeded with the given value.
* @param {T} val The value to seed the array with.
* @param {number} length The length of the array to create.
* @returns {T[]} The array.
*/
export function arraySeed<T>(val: T, length: number): T[] {
// Size the array up front for performance, and use `fill` to let the browser
// optimize the operation better than we can with a `for` loop, if it wants.
return new Array<T>(length).fill(val);
}
/**
* Trims or fills the array to ensure it meets the desired length. The seed array
* given is pulled from to fill any missing slots - it is recommended that this be
* at least `len` long. The resulting array will be exactly `len` long, either
* trimmed from the source or filled with the some/all of the seed array.
* @param {T[]} a The array to trim/fill.
* @param {number} len The length to trim or fill to, as needed.
* @param {T[]} seed Values to pull from if the array needs filling.
* @returns {T[]} The resulting array of `len` length.
*/
export function arrayTrimFill<T>(a: T[], len: number, seed: T[]): T[] {
// Dev note: we do length checks because the spread operator can result in some
// performance penalties in more critical code paths. As a utility, it should be
// as fast as possible to not cause a problem for the call stack, no matter how
// critical that stack is.
if (a.length === len) return a;
if (a.length > len) return a.slice(0, len);
return a.concat(seed.slice(0, len - a.length));
}
/**
* Clones an array as fast as possible, retaining references of the array's values.
* @param a The array to clone. Must be defined.
* @returns A copy of the array.
*/
export function arrayFastClone<T>(a: T[]): T[] {
return a.slice(0, a.length);
}
/**
* Determines if the two arrays are different either in length, contents,
* or order of those contents.
* @param a The first array. Must be defined.
* @param b The second array. Must be defined.
* @returns True if they are different, false otherwise.
*/
export function arrayHasOrderChange(a: any[], b: any[]): boolean {
if (a.length === b.length) {
for (let i = 0; i < a.length; i++) {
if (a[i] !== b[i]) return true;
}
return false;
} else {
return true; // like arrayHasDiff, a difference in length is a natural change
}
}
/**
* Determines if two arrays are different through a shallow comparison.
* @param a The first array. Must be defined.
* @param b The second array. Must be defined.
* @returns True if they are different, false otherwise.
*/
export function arrayHasDiff(a: any[], b: any[]): boolean {
if (a.length === b.length) {
// When the lengths are equal, check to see if either array is missing
// an element from the other.
if (b.some((i) => !a.includes(i))) return true;
if (a.some((i) => !b.includes(i))) return true;
// if all the keys are common, say so
return false;
} else {
return true; // different lengths means they are naturally diverged
}
}
export type Diff<T> = { added: T[]; removed: T[] };
/**
* Performs a diff on two arrays. The result is what is different with the
* first array (`added` in the returned object means objects in B that aren't
* in A). Shallow comparisons are used to perform the diff.
* @param a The first array. Must be defined.
* @param b The second array. Must be defined.
* @returns The diff between the arrays.
*/
export function arrayDiff<T>(a: T[], b: T[]): Diff<T> {
return {
added: b.filter((i) => !a.includes(i)),
removed: a.filter((i) => !b.includes(i)),
};
}
/**
* Returns the intersection of two arrays.
* @param a The first array. Must be defined.
* @param b The second array. Must be defined.
* @returns The intersection of the arrays.
*/
export function arrayIntersection<T>(a: T[], b: T[]): T[] {
return a.filter((i) => b.includes(i));
}
/**
* Unions arrays, deduping contents using a Set.
* @param a The arrays to merge.
* @returns The union of all given arrays.
*/
export function arrayUnion<T>(...a: T[][]): T[] {
return Array.from(
a.reduce((c, v) => {
v.forEach((i) => c.add(i));
return c;
}, new Set<T>()),
);
}
/**
* Moves a single element from fromIndex to toIndex.
* @param {array} list the list from which to construct the new list.
* @param {number} fromIndex the index of the element to move.
* @param {number} toIndex the index of where to put the element.
* @returns {array} A new array with the requested value moved.
*/
export function moveElement<T>(list: T[], fromIndex: number, toIndex: number): T[] {
const result = Array.from(list);
const [removed] = result.splice(fromIndex, 1);
result.splice(toIndex, 0, removed);
return result;
}
/**
* Helper functions to perform LINQ-like queries on arrays.
*/
export class ArrayUtil<T> {
/**
* Create a new array helper.
* @param a The array to help. Can be modified in-place.
*/
public constructor(private a: T[]) {}
/**
* The value of this array, after all appropriate alterations.
*/
public get value(): T[] {
return this.a;
}
/**
* Groups an array by keys.
* @param fn The key-finding function.
* @returns This.
*/
public groupBy<K>(fn: (a: T) => K): GroupedArray<K, T> {
const obj = this.a.reduce((rv: Map<K, T[]>, val: T) => {
const k = fn(val);
if (!rv.has(k)) rv.set(k, []);
rv.get(k)!.push(val);
return rv;
}, new Map<K, T[]>());
return new GroupedArray(obj);
}
}
/**
* Helper functions to perform LINQ-like queries on groups (maps).
*/
export class GroupedArray<K, T> {
/**
* Creates a new group helper.
* @param val The group to help. Can be modified in-place.
*/
public constructor(private val: Map<K, T[]>) {}
/**
* The value of this group, after all applicable alterations.
*/
public get value(): Map<K, T[]> {
return this.val;
}
/**
* Orders the grouping into an array using the provided key order.
* @param keyOrder The key order.
* @returns An array helper of the result.
*/
public orderBy(keyOrder: K[]): ArrayUtil<T> {
const a: T[] = [];
for (const k of keyOrder) {
if (!this.val.has(k)) continue;
a.push(...this.val.get(k)!);
}
return new ArrayUtil(a);
}
}
export const concat = (...arrays: Uint8Array<ArrayBuffer>[]): Uint8Array<ArrayBuffer> => {
return arrays.reduce((concatenatedSoFar: Uint8Array<ArrayBuffer>, toBeConcatenated: Uint8Array<ArrayBuffer>) => {
const concatenated = new Uint8Array(concatenatedSoFar.length + toBeConcatenated.length);
concatenated.set(concatenatedSoFar, 0);
concatenated.set(toBeConcatenated, concatenatedSoFar.length);
return concatenated;
}, new Uint8Array(0));
};
/**
* Async version of Array.every.
*/
export async function asyncEvery<T>(values: Iterable<T>, predicate: (value: T) => Promise<boolean>): Promise<boolean> {
for (const value of values) {
if (!(await predicate(value))) return false;
}
return true;
}
/**
* Async version of Array.some.
*/
export async function asyncSome<T>(values: Iterable<T>, predicate: (value: T) => Promise<boolean>): Promise<boolean> {
for (const value of values) {
if (await predicate(value)) return true;
}
return false;
}
/**
* Async version of Array.some that runs all promises in parallel.
* @param values
* @param predicate
*/
export async function asyncSomeParallel<T>(
values: Array<T>,
predicate: (value: T) => Promise<boolean>,
): Promise<boolean> {
try {
return await Promise.any<boolean>(
values.map((value) =>
predicate(value).then((result) => (result ? Promise.resolve(true) : Promise.reject(false))),
),
);
} catch (e) {
// If the array is empty or all the promises are false, Promise.any will reject an AggregateError
if (e instanceof AggregateError) return false;
throw e;
}
}
/**
* Async version of Array.filter.
* If one of the promises rejects, the whole operation will reject.
* @param values
* @param predicate
*/
export async function asyncFilter<T>(values: Array<T>, predicate: (value: T) => Promise<boolean>): Promise<Array<T>> {
const results = await Promise.all(values.map(predicate));
return values.filter((_, i) => results[i]);
}
export function filterBoolean<T>(values: Array<T | null | undefined>): T[] {
return values.filter(Boolean) as T[];
}
+54
View File
@@ -0,0 +1,54 @@
/*
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 Beacon } from "matrix-js-sdk/src/matrix";
import { filterBoolean } from "../arrays";
import { parseGeoUri } from "../location";
export type Bounds = {
north: number;
east: number;
west: number;
south: number;
};
/**
* Get the geo bounds of given list of beacons
*
* Latitude:
* equator: 0, North pole: 90, South pole -90
* Longitude:
* Prime Meridian (Greenwich): 0
* east of Greenwich has a positive longitude, max 180
* west of Greenwich has a negative longitude, min -180
*/
export const getBeaconBounds = (beacons: Beacon[]): Bounds | undefined => {
const coords = filterBoolean<GeolocationCoordinates>(
beacons.map((beacon) =>
!!beacon.latestLocationState?.uri ? parseGeoUri(beacon.latestLocationState.uri) : undefined,
),
);
if (!coords.length) {
return;
}
// sort descending
const sortedByLat = [...coords].sort((left, right) => right.latitude - left.latitude);
const sortedByLong = [...coords].sort((left, right) => right.longitude - left.longitude);
if (sortedByLat.length < 1 || sortedByLong.length < 1) return;
return {
north: sortedByLat[0]!.latitude,
south: sortedByLat[sortedByLat.length - 1]!.latitude,
east: sortedByLong[0]!.longitude,
west: sortedByLong[sortedByLong.length - 1]!.longitude,
};
};
+42
View File
@@ -0,0 +1,42 @@
/*
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 Beacon, type ContentHelpers } from "matrix-js-sdk/src/matrix";
/**
* Get ms until expiry
* Returns 0 when expiry is already passed
* @param startTimestamp
* @param durationMs
* @returns remainingMs
*/
export const msUntilExpiry = (startTimestamp: number, durationMs: number): number =>
Math.max(0, startTimestamp + durationMs - Date.now());
export const getBeaconMsUntilExpiry = (beaconInfo: ContentHelpers.BeaconInfoState): number =>
msUntilExpiry(beaconInfo.timestamp || 0, beaconInfo.timeout);
export const getBeaconExpiryTimestamp = (beacon: Beacon): number =>
(beacon.beaconInfo.timestamp || 0) + beacon.beaconInfo.timeout;
export const sortBeaconsByLatestExpiry = (left: Beacon, right: Beacon): number =>
getBeaconExpiryTimestamp(right) - getBeaconExpiryTimestamp(left);
// aka sort by timestamp descending
export const sortBeaconsByLatestCreation = (left: Beacon, right: Beacon): number =>
(right.beaconInfo.timestamp || 0) - (left.beaconInfo.timestamp || 0);
// a beacon's starting timestamp can be in the future
// (either from small deviations in system clock times, or on purpose from another client)
// a beacon is only live between its start timestamp and expiry
// detect when a beacon is waiting to become live
export const isBeaconWaitingToStart = (beacon: Beacon): boolean =>
!beacon.isLive &&
!!beacon.beaconInfo.timestamp &&
beacon.beaconInfo.timestamp > Date.now() &&
getBeaconExpiryTimestamp(beacon) > Date.now();
+136
View File
@@ -0,0 +1,136 @@
/*
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 { logger } from "matrix-js-sdk/src/logger";
// map GeolocationPositionError codes
// https://developer.mozilla.org/en-US/docs/Web/API/GeolocationPositionError
export enum GeolocationError {
// no navigator.geolocation
Unavailable = "Unavailable",
// The acquisition of the geolocation information failed because the page didn't have the permission to do it.
PermissionDenied = "PermissionDenied",
// The acquisition of the geolocation failed because at least one internal source of position returned an internal error.
PositionUnavailable = "PositionUnavailable",
// The time allowed to acquire the geolocation was reached before the information was obtained.
Timeout = "Timeout",
// other unexpected failure
Default = "Default",
}
const GeolocationOptions = {
timeout: 10000,
maximumAge: 60000,
};
const isGeolocationPositionError = (error: unknown): error is GeolocationPositionError =>
typeof error === "object" && !!(error as GeolocationPositionError)["PERMISSION_DENIED"];
/**
* Maps GeolocationPositionError to our GeolocationError enum
*/
export const mapGeolocationError = (error: GeolocationPositionError | Error | unknown): GeolocationError => {
logger.error("Geolocation failed", error);
if (isGeolocationPositionError(error)) {
switch (error?.code) {
case error.PERMISSION_DENIED:
return GeolocationError.PermissionDenied;
case error.POSITION_UNAVAILABLE:
return GeolocationError.PositionUnavailable;
case error.TIMEOUT:
return GeolocationError.Timeout;
default:
return GeolocationError.Default;
}
} else if (error instanceof Error && error.message === GeolocationError.Unavailable) {
return GeolocationError.Unavailable;
} else {
return GeolocationError.Default;
}
};
const getGeolocation = (): Geolocation => {
if (!navigator.geolocation) {
throw new Error(GeolocationError.Unavailable);
}
return navigator.geolocation;
};
export type GenericPosition = {
latitude: number;
longitude: number;
altitude?: number;
accuracy?: number;
timestamp: number;
};
export type TimedGeoUri = {
geoUri: string;
timestamp: number;
};
export const genericPositionFromGeolocation = (geoPosition: GeolocationPosition): GenericPosition => {
const { latitude, longitude, altitude, accuracy } = geoPosition.coords;
return {
// safari reports geolocation timestamps as Apple Cocoa Core Data timestamp
// or ms since 1/1/2001 instead of the regular epoch
// they also use local time, not utc
// to simplify, just use Date.now()
timestamp: Date.now(),
latitude,
longitude,
altitude: altitude ?? undefined,
accuracy,
};
};
export const getGeoUri = (position: GenericPosition): string => {
const lat = position.latitude;
const lon = position.longitude;
const alt = Number.isFinite(position.altitude) ? `,${position.altitude}` : "";
const acc = Number.isFinite(position.accuracy) ? `;u=${position.accuracy}` : "";
return `geo:${lat},${lon}${alt}${acc}`;
};
export const mapGeolocationPositionToTimedGeo = (position: GeolocationPosition): TimedGeoUri => {
const genericPosition = genericPositionFromGeolocation(position);
return { timestamp: genericPosition.timestamp, geoUri: getGeoUri(genericPosition) };
};
/**
* Gets current position, returns a promise
* @returns Promise<GeolocationPosition>
*/
export const getCurrentPosition = async (): Promise<GeolocationPosition> => {
try {
const position = await new Promise((resolve: PositionCallback, reject) => {
getGeolocation().getCurrentPosition(resolve, reject, GeolocationOptions);
});
return position;
} catch (error) {
throw new Error(mapGeolocationError(error));
}
};
export type ClearWatchCallback = () => void;
export const watchPosition = (
onWatchPosition: PositionCallback,
onWatchPositionError: (error: GeolocationError) => void,
): ClearWatchCallback => {
try {
const onError = (error: GeolocationPositionError): void => onWatchPositionError(mapGeolocationError(error));
const watchId = getGeolocation().watchPosition(onWatchPosition, onError, GeolocationOptions);
const clearWatch = (): void => {
getGeolocation().clearWatch(watchId);
};
return clearWatch;
} catch (error) {
throw new Error(mapGeolocationError(error));
}
};
@@ -0,0 +1,25 @@
/*
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 MatrixClient, type MatrixEvent, getBeaconInfoIdentifier } from "matrix-js-sdk/src/matrix";
/**
* Beacons should only have shareable locations (open in external mapping tool, forward)
* when they are live and have a location
* If not live, returns null
*/
export const getShareableLocationEventForBeacon = (event: MatrixEvent, cli: MatrixClient): MatrixEvent | null => {
const room = cli.getRoom(event.getRoomId());
const beacon = room?.currentState.beacons?.get(getBeaconInfoIdentifier(event));
const latestLocationEvent = beacon?.latestLocationEvent;
if (beacon?.isLive && latestLocationEvent) {
return latestLocationEvent;
}
return null;
};
+12
View File
@@ -0,0 +1,12 @@
/*
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.
*/
export * from "./duration";
export * from "./geolocation";
export * from "./useBeacon";
export * from "./useOwnLiveBeacons";
+19
View File
@@ -0,0 +1,19 @@
/*
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 MatrixEvent, M_BEACON_INFO } from "matrix-js-sdk/src/matrix";
/**
* beacon_info events without live property set to true
* should be displayed in the timeline
*/
export const shouldDisplayAsBeaconTile = (event: MatrixEvent): boolean =>
M_BEACON_INFO.matches(event.getType()) &&
(event.getContent()?.live ||
// redacted beacons should show 'message deleted' tile
event.isRedacted());
+61
View File
@@ -0,0 +1,61 @@
/*
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 { useContext, useEffect, useState } from "react";
import { type Beacon, BeaconEvent, type MatrixEvent, getBeaconInfoIdentifier } from "matrix-js-sdk/src/matrix";
import MatrixClientContext from "../../contexts/MatrixClientContext";
import { useEventEmitterState } from "../../hooks/useEventEmitter";
export const useBeacon = (beaconInfoEvent: MatrixEvent): Beacon | undefined => {
const matrixClient = useContext(MatrixClientContext);
const [beacon, setBeacon] = useState<Beacon>();
useEffect(() => {
const roomId = beaconInfoEvent.getRoomId();
const beaconIdentifier = getBeaconInfoIdentifier(beaconInfoEvent);
const room = matrixClient?.getRoom(roomId);
const beaconInstance = room?.currentState.beacons.get(beaconIdentifier);
// TODO could this be less stupid?
// Beacons are identified by their `state_key`,
// where `state_key` is always owner mxid for access control.
// Thus, only one beacon is allowed per-user per-room.
// See https://github.com/matrix-org/matrix-spec-proposals/pull/3672
// When a user creates a new beacon any previous
// beacon is replaced and should assume a 'stopped' state
// Here we check that this event is the latest beacon for this user
// If it is not the beacon instance is set to undefined.
// Retired beacons don't get a beacon instance.
if (beaconInstance?.beaconInfoId === beaconInfoEvent.getId()) {
setBeacon(beaconInstance);
} else {
setBeacon(undefined);
}
}, [beaconInfoEvent, matrixClient]);
// beacon update will fire when this beacon is superseded
// check the updated event id for equality to the matrix event
const beaconInstanceEventId = useEventEmitterState(beacon, BeaconEvent.Update, () => beacon?.beaconInfoId);
useEffect(() => {
if (beaconInstanceEventId && beaconInstanceEventId !== beaconInfoEvent.getId()) {
setBeacon(undefined);
}
}, [beaconInstanceEventId, beaconInfoEvent]);
useEffect(() => {
if (beacon) {
beacon.monitorLiveness();
}
}, [beacon]);
return beacon;
};
@@ -0,0 +1,31 @@
/*
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 Beacon, type Room, RoomStateEvent, type MatrixClient } from "matrix-js-sdk/src/matrix";
import { useEventEmitterState } from "../../hooks/useEventEmitter";
/**
* Returns an array of all live beacon ids for a given room
*
* Beacons are removed from array when they become inactive
*/
export const useLiveBeacons = (roomId: Room["roomId"], matrixClient: MatrixClient): Beacon[] => {
const room = matrixClient.getRoom(roomId);
const liveBeacons = useEventEmitterState(
room?.currentState,
RoomStateEvent.BeaconLiveness,
() =>
room?.currentState?.liveBeaconIds.map(
(beaconIdentifier) => room.currentState.beacons.get(beaconIdentifier)!,
) || [],
);
return liveBeacons;
};
@@ -0,0 +1,87 @@
/*
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 { useEffect, useState } from "react";
import { type Beacon, type BeaconIdentifier } from "matrix-js-sdk/src/matrix";
import { useEventEmitterState } from "../../hooks/useEventEmitter";
import { OwnBeaconStore, OwnBeaconStoreEvent } from "../../stores/OwnBeaconStore";
import { sortBeaconsByLatestExpiry } from "./duration";
type LiveBeaconsState = {
beacon?: Beacon;
onStopSharing: () => void;
onResetLocationPublishError: () => void;
stoppingInProgress: boolean;
hasStopSharingError: boolean;
hasLocationPublishError: boolean;
};
/**
* Monitor the current users own beacons
* While current implementation only allows one live beacon per user per room
* In future it will be possible to have multiple live beacons in one room
* Select the latest expiry to display,
* and kill all beacons on stop sharing
*/
export const useOwnLiveBeacons = (liveBeaconIds: BeaconIdentifier[]): LiveBeaconsState => {
const [stoppingInProgress, setStoppingInProgress] = useState(false);
const hasLocationPublishError = useEventEmitterState(
OwnBeaconStore.instance,
OwnBeaconStoreEvent.LocationPublishError,
() => liveBeaconIds.some(OwnBeaconStore.instance.beaconHasLocationPublishError),
);
const hasStopSharingError = useEventEmitterState(
OwnBeaconStore.instance,
OwnBeaconStoreEvent.BeaconUpdateError,
() => liveBeaconIds.some((id) => OwnBeaconStore.instance.beaconUpdateErrors.has(id)),
);
useEffect(() => {
if (hasStopSharingError) {
setStoppingInProgress(false);
}
}, [hasStopSharingError]);
// reset stopping in progress on change in live ids
useEffect(() => {
setStoppingInProgress(false);
}, [liveBeaconIds]);
// select the beacon with latest expiry to display expiry time
const beacon = liveBeaconIds
.map((beaconId) => OwnBeaconStore.instance.getBeaconById(beaconId)!)
.sort(sortBeaconsByLatestExpiry)
.shift();
const onStopSharing = async (): Promise<void> => {
setStoppingInProgress(true);
try {
await Promise.all(liveBeaconIds.map((beaconId) => OwnBeaconStore.instance.stopBeacon(beaconId)));
} catch {
setStoppingInProgress(false);
}
};
const onResetLocationPublishError = (): void => {
liveBeaconIds.forEach((beaconId) => {
OwnBeaconStore.instance.resetLocationPublishError(beaconId);
});
};
return {
onStopSharing,
onResetLocationPublishError,
beacon,
stoppingInProgress,
hasLocationPublishError,
hasStopSharingError,
};
};
+86
View File
@@ -0,0 +1,86 @@
/*
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.
*/
// WARNING: We have to be very careful about what mime-types we allow into blobs,
// as for performance reasons these are now rendered via URL.createObjectURL()
// rather than by converting into data: URIs.
//
// This means that the content is rendered using the origin of the script which
// called createObjectURL(), and so if the content contains any scripting then it
// will pose a XSS vulnerability when the browser renders it. This is particularly
// bad if the user right-clicks the URI and pastes it into a new window or tab,
// as the blob will then execute with access to Element's full JS environment(!)
//
// See https://github.com/matrix-org/matrix-react-sdk/pull/1820#issuecomment-385210647
// for details.
//
// We mitigate this by only allowing mime-types into blobs which we know don't
// contain any scripting, and instantiate all others as application/octet-stream
// regardless of what mime-type the event claimed. Even if the payload itself
// is some malicious HTML, the fact we instantiate it with a media mimetype or
// application/octet-stream means the browser doesn't try to render it as such.
//
// One interesting edge case is image/svg+xml, which empirically *is* rendered
// correctly if the blob is set to the src attribute of an img tag (for thumbnails)
// *even if the mimetype is application/octet-stream*. However, empirically JS
// in the SVG isn't executed in this scenario, so we seem to be okay.
//
// Tested on Chrome 65 and Firefox 60
//
// The list below is taken mainly from
// https://developer.mozilla.org/en-US/docs/Web/HTML/Supported_media_formats
// N.B. Matrix doesn't currently specify which mimetypes are valid in given
// events, so we pick the ones which HTML5 browsers should be able to display
//
// For the record, mime-types which must NEVER enter this list below include:
// text/html, text/xhtml, image/svg, image/svg+xml, image/pdf, and similar.
const ALLOWED_BLOB_MIMETYPES = [
"image/jpeg",
"image/gif",
"image/png",
"image/apng",
"image/webp",
"image/avif",
"video/mp4",
"video/webm",
"video/ogg",
"video/quicktime",
"audio/mp4",
"audio/webm",
"audio/aac",
"audio/mpeg",
"audio/ogg",
"audio/wave",
"audio/wav",
"audio/x-wav",
"audio/x-pn-wav",
"audio/flac",
"audio/x-flac",
];
/**
* Checks whether the given mime type is in the allowed mimetype list
* @param mimetype - the mimetype to check
*/
export function isMimeTypeAllowed(mimetype: string): boolean {
return ALLOWED_BLOB_MIMETYPES.includes(mimetype);
}
/**
* Returns the input mimetype if it is allowed, `application/octet-stream` otherwise
* @param mimetype - the mimetype to check
*/
export function getBlobSafeMimeType(mimetype: string): string {
if (!isMimeTypeAllowed(mimetype)) {
return "application/octet-stream";
}
return mimetype;
}
+82
View File
@@ -0,0 +1,82 @@
/*
Copyright 2024 New Vector Ltd.
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 { split } from "lodash";
export function textToHtmlRainbow(str: string): string {
const frequency = (2 * Math.PI) / str.length;
return split(str, "")
.map((c, i) => {
if (c === " ") {
return c;
}
const [a, b] = generateAB(i * frequency, 1);
const [red, green, blue] = labToRGB(75, a, b);
return (
'<span data-mx-color="#' +
red.toString(16).padStart(2, "0") +
green.toString(16).padStart(2, "0") +
blue.toString(16).padStart(2, "0") +
'">' +
c +
"</span>"
);
})
.join("");
}
function generateAB(hue: number, chroma: number): [number, number] {
const a = chroma * 127 * Math.cos(hue);
const b = chroma * 127 * Math.sin(hue);
return [a, b];
}
function labToRGB(l: number, a: number, b: number): [number, number, number] {
// https://en.wikipedia.org/wiki/CIELAB_color_space#Reverse_transformation
// https://en.wikipedia.org/wiki/SRGB#The_forward_transformation_(CIE_XYZ_to_sRGB)
// Convert CIELAB to CIEXYZ (D65)
let y = (l + 16) / 116;
const x = adjustXYZ(y + a / 500) * 0.9505;
const z = adjustXYZ(y - b / 200) * 1.089;
y = adjustXYZ(y);
// Linear transformation from CIEXYZ to RGB
const red = 3.24096994 * x - 1.53738318 * y - 0.49861076 * z;
const green = -0.96924364 * x + 1.8759675 * y + 0.04155506 * z;
const blue = 0.05563008 * x - 0.20397696 * y + 1.05697151 * z;
return [adjustRGB(red), adjustRGB(green), adjustRGB(blue)];
}
function adjustXYZ(v: number): number {
if (v > 0.2069) {
return Math.pow(v, 3);
}
return 0.1284 * v - 0.01771;
}
function gammaCorrection(v: number): number {
// Non-linear transformation to sRGB
if (v <= 0.0031308) {
return 12.92 * v;
}
return 1.055 * Math.pow(v, 1 / 2.4) - 0.055;
}
function adjustRGB(v: number): number {
const corrected = gammaCorrection(v);
// Limits number between 0 and 1
const limited = Math.min(Math.max(corrected, 0), 1);
return Math.round(limited * 255);
}
+23
View File
@@ -0,0 +1,23 @@
/*
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 ClientEvent, type ClientEventHandlerMap, SyncState } from "matrix-js-sdk/src/matrix";
/**
* Creates a MatrixClient event listener function that can be used to get notified about reconnects.
* @param callback The callback to be called on reconnect
*/
export const createReconnectedListener = (callback: () => void): ClientEventHandlerMap[ClientEvent.Sync] => {
return (syncState: SyncState, prevState: SyncState | null) => {
if (syncState !== SyncState.Error && prevState !== syncState) {
// Consider the client reconnected if there is no error with syncing.
// This means the state could be RECONNECTING, SYNCING, PREPARED or CATCHUP.
callback();
}
};
};
+67
View File
@@ -0,0 +1,67 @@
/*
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 {
type MatrixClient,
createClient,
type ICreateClientOpts,
MemoryCryptoStore,
MemoryStore,
IndexedDBCryptoStore,
IndexedDBStore,
LocalStorageCryptoStore,
} from "matrix-js-sdk/src/matrix";
import indexeddbWorkerFactory from "../workers/indexeddbWorkerFactory";
const localStorage = window.localStorage;
// just *accessing* indexedDB throws an exception in firefox with
// indexeddb disabled.
let indexedDB: IDBFactory;
try {
indexedDB = window.indexedDB;
} catch {}
/**
* Create a new matrix client, with the persistent stores set up appropriately
* (using localstorage/indexeddb, etc)
*
* @param {Object} opts options to pass to Matrix.createClient. This will be
* extended with `sessionStore` and `store` members.
*
* @returns {MatrixClient} the newly-created MatrixClient
*/
export default function createMatrixClient(opts: ICreateClientOpts): MatrixClient {
const storeOpts: Partial<ICreateClientOpts> = {
useAuthorizationHeader: true,
};
if (indexedDB && localStorage) {
storeOpts.store = new IndexedDBStore({
indexedDB: indexedDB,
dbName: "riot-web-sync",
localStorage,
workerFactory: indexeddbWorkerFactory,
});
} else if (localStorage) {
storeOpts.store = new MemoryStore({ localStorage });
}
if (indexedDB) {
storeOpts.cryptoStore = new IndexedDBCryptoStore(indexedDB, "matrix-js-sdk:crypto");
} else if (localStorage) {
storeOpts.cryptoStore = new LocalStorageCryptoStore(localStorage);
} else {
storeOpts.cryptoStore = new MemoryCryptoStore();
}
return createClient({
...storeOpts,
...opts,
});
}
@@ -0,0 +1,57 @@
/*
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 { MsgType } from "matrix-js-sdk/src/matrix";
import { type EncryptedFile, type RoomMessageEventContent } from "matrix-js-sdk/src/types";
/**
* @param {string} mxc MXC URL of the file
* @param {string} mimetype
* @param {number} duration Duration in milliseconds
* @param {number} size
* @param {number[]} [waveform]
* @param {EncryptedFile} [file] Encrypted file
*/
export const createVoiceMessageContent = (
mxc: string | undefined,
mimetype: string,
duration: number,
size: number,
file?: EncryptedFile,
waveform?: number[],
): RoomMessageEventContent => {
return {
"body": "Voice message",
//"msgtype": "org.matrix.msc2516.voice",
"msgtype": MsgType.Audio,
"url": mxc,
"file": file,
"info": {
duration,
mimetype,
size,
},
// MSC1767 + Ideals of MSC2516 as MSC3245
// https://github.com/matrix-org/matrix-doc/pull/3245
"org.matrix.msc1767.text": "Voice message",
"org.matrix.msc1767.file": {
url: mxc,
file,
name: "Voice message.ogg",
mimetype,
size,
},
"org.matrix.msc1767.audio": {
duration,
// https://github.com/matrix-org/matrix-doc/pull/3246
waveform,
},
"org.matrix.msc3245.voice": {}, // No content, this is a rendering hint
};
};
+59
View File
@@ -0,0 +1,59 @@
/*
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 Device, type MatrixClient } from "matrix-js-sdk/src/matrix";
/**
* Get crypto information on a specific device.
*
* Only devices with Crypto support are returned. If the MatrixClient doesn't support cryptography, `undefined` is
* returned.
*
* @param client - Matrix Client.
* @param userId - ID of the user owning the device.
* @param deviceId - ID of the device.
* @param downloadUncached - If true, download the device list for users whose device list we are not
* currently tracking. Defaults to false.
*
* @returns Information on the device if it is known.
*/
export async function getDeviceCryptoInfo(
client: MatrixClient,
userId: string,
deviceId: string,
downloadUncached?: boolean,
): Promise<Device | undefined> {
const crypto = client.getCrypto();
if (!crypto) {
// no crypto support, no device.
return undefined;
}
const deviceMap = await crypto.getUserDeviceInfo([userId], downloadUncached);
return deviceMap.get(userId)?.get(deviceId);
}
/**
* Get the IDs of the given user's devices.
*
* Only devices with Crypto support are returned. If the MatrixClient doesn't support cryptography, an empty Set is
* returned.
*
* @param client - Matrix Client.
* @param userId - ID of the user to query.
*/
export async function getUserDeviceIds(client: MatrixClient, userId: string): Promise<Set<string>> {
const crypto = client.getCrypto();
if (!crypto) {
return new Set();
}
const deviceMap = await crypto.getUserDeviceInfo([userId]);
return new Set(deviceMap.get(userId)?.keys() ?? []);
}
+11
View File
@@ -0,0 +1,11 @@
/*
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.
*/
/** The `algorithm` property used in `m.room.encrypted` state events for encrypted rooms.
*/
export const MEGOLM_ENCRYPTION_ALGORITHM = "m.megolm.v1.aes-sha2";
@@ -0,0 +1,20 @@
/*
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 CryptoApi } from "matrix-js-sdk/src/crypto-api";
/**
* Creates a new key backup version, and wait until it is enabled.
*
* This is typically used within a {@link DeviceListener.pause()} call, to
* ensure that the device listener doesn't check the backup status until after the
* key backup is active.
*/
export async function resetKeyBackupAndWait(crypto: CryptoApi): Promise<void> {
await crypto.resetKeyBackup();
await crypto.checkKeyBackupAndEnable();
}
@@ -0,0 +1,31 @@
/*
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 MatrixClient } from "matrix-js-sdk/src/matrix";
import { getE2EEWellKnown } from "../WellKnownUtils";
/**
* Check e2ee io.element.e2ee setting
* Returns true when .well-known e2ee config force_disable is TRUE
* When true all new rooms should be created with encryption disabled
* Can be overriden by synapse option encryption_enabled_by_default_for_room_type ( :/ )
* https://matrix-org.github.io/synapse/latest/usage/configuration/config_documentation.html#encryption_enabled_by_default_for_room_type
*
* @param client
* @returns whether well-known config forces encryption to DISABLED
*/
export function shouldForceDisableEncryption(client: MatrixClient): boolean {
const e2eeWellKnown = getE2EEWellKnown(client);
if (e2eeWellKnown) {
const shouldForceDisable = e2eeWellKnown["force_disable"] === true;
return shouldForceDisable;
}
return false;
}
@@ -0,0 +1,29 @@
/*
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 MatrixClient } from "matrix-js-sdk/src/matrix";
import { shouldForceDisableEncryption } from "./shouldForceDisableEncryption";
import { asyncSomeParallel } from "../arrays.ts";
/**
* If encryption is force disabled AND the user is not in any encrypted rooms
* skip setting up encryption
* @param client
* @returns {boolean} true when we can skip settings up encryption
*/
export const shouldSkipSetupEncryption = async (client: MatrixClient): Promise<boolean> => {
const isEncryptionForceDisabled = shouldForceDisableEncryption(client);
const crypto = client.getCrypto();
if (!crypto) return true;
return (
isEncryptionForceDisabled &&
!(await asyncSomeParallel(client.getRooms(), ({ roomId }) => crypto.isEncryptionEnabledInRoom(roomId)))
);
};
@@ -0,0 +1,107 @@
/*
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 AccountDataEvents, type MatrixClient } from "matrix-js-sdk/src/matrix";
import type BasePlatform from "../../BasePlatform";
import { type IConfigOptions } from "../../IConfigOptions";
import { type DeepReadonly } from "../../@types/common";
import { type DeviceClientInformation } from "./types";
export type { DeviceClientInformation };
const formatUrl = (): string | undefined => {
// don't record url for electron clients
if (window.electron) {
return undefined;
}
// strip query-string and fragment from uri
const url = new URL(window.location.href);
return [
url.host,
url.pathname.replace(/\/$/, ""), // Remove trailing slash if present
].join("");
};
const clientInformationEventPrefix = "io.element.matrix_client_information.";
export const getClientInformationEventType = (deviceId: string): `${typeof clientInformationEventPrefix}${string}` =>
`${clientInformationEventPrefix}${deviceId}`;
/**
* Record extra client information for the current device
* https://github.com/vector-im/element-meta/blob/develop/spec/matrix_client_information.md
*/
export const recordClientInformation = async (
matrixClient: MatrixClient,
sdkConfig: DeepReadonly<IConfigOptions>,
platform?: BasePlatform,
): Promise<void> => {
const deviceId = matrixClient.getDeviceId()!;
const { brand } = sdkConfig;
const version = await platform?.getAppVersion();
const type = getClientInformationEventType(deviceId);
const url = formatUrl();
await matrixClient.setAccountData(type, {
name: brand,
version,
url,
});
};
/**
* Remove client information events for devices that no longer exist
* @param validDeviceIds - ids of current devices,
* client information for devices NOT in this list will be removed
*/
export const pruneClientInformation = (validDeviceIds: string[], matrixClient: MatrixClient): void => {
Array.from(matrixClient.store.accountData.values()).forEach((event) => {
if (!event.getType().startsWith(clientInformationEventPrefix)) {
return;
}
const [, deviceId] = event.getType().split(clientInformationEventPrefix);
if (deviceId && !validDeviceIds.includes(deviceId)) {
matrixClient.deleteAccountData(event.getType() as keyof AccountDataEvents);
}
});
};
/**
* Remove extra client information for current device
*/
export const removeClientInformation = async (matrixClient: MatrixClient): Promise<void> => {
const deviceId = matrixClient.getDeviceId()!;
const type = getClientInformationEventType(deviceId);
const clientInformation = getDeviceClientInformation(matrixClient, deviceId);
// if a non-empty client info event exists, remove it
if (clientInformation.name || clientInformation.version || clientInformation.url) {
await matrixClient.deleteAccountData(type);
}
};
const sanitizeContentString = (value: unknown): string | undefined =>
value && typeof value === "string" ? value : undefined;
export const getDeviceClientInformation = (matrixClient: MatrixClient, deviceId: string): DeviceClientInformation => {
const event = matrixClient.getAccountData(getClientInformationEventType(deviceId));
if (!event) {
return {};
}
const { name, version, url } = event.getContent();
return {
name: sanitizeContentString(name),
version: sanitizeContentString(version),
url: sanitizeContentString(url),
};
};
+30
View File
@@ -0,0 +1,30 @@
/*
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 { type StartDehydrationOpts } from "matrix-js-sdk/src/crypto-api";
import type { MatrixClient } from "matrix-js-sdk/src/matrix";
/**
* If dehydration is supported by the server, rehydrate a device (if available) and create
* a new dehydrated device.
*
* @param client - MatrixClient to use for the operation
* @param opts - options for the startDehydration operation, if one is performed.
*/
export async function initialiseDehydrationIfEnabled(
client: MatrixClient,
opts: StartDehydrationOpts = {},
): Promise<void> {
const crypto = client.getCrypto();
if (crypto && (await crypto.isDehydrationSupported())) {
logger.debug("Starting device dehydration");
await crypto.startDehydration(opts);
}
}
@@ -0,0 +1,27 @@
/*
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 MatrixClient } from "matrix-js-sdk/src/matrix";
/**
* Check if one of our own devices is verified via cross signing
*
* @param client - reference to the MatrixClient
* @param deviceId - ID of the device to be checked
*
* @returns `null` if the device is unknown or has not published encryption keys; otherwise a boolean
* indicating whether the device has been cross-signed by a cross-signing key we trust.
*/
export const isDeviceVerified = async (client: MatrixClient, deviceId: string): Promise<boolean | null> => {
const trustLevel = await client.getCrypto()?.getDeviceVerificationStatus(client.getSafeUserId(), deviceId);
if (!trustLevel) {
// either no crypto, or an unknown/no-e2e device
return null;
}
return trustLevel.crossSigningVerified;
};
+111
View File
@@ -0,0 +1,111 @@
/*
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 UAParser from "ua-parser-js";
export enum DeviceType {
Desktop = "Desktop",
Mobile = "Mobile",
Web = "Web",
Unknown = "Unknown",
}
export type ExtendedDeviceInformation = {
deviceType: DeviceType;
// eg Google Pixel 6
deviceModel?: string;
// eg Android 11
deviceOperatingSystem?: string;
// eg Firefox 1.1.0
client?: string;
};
// Element/1.8.21 (iPhone XS Max; iOS 15.2; Scale/3.00)
const IOS_KEYWORD = "; iOS ";
const BROWSER_KEYWORD = "Mozilla/";
const getDeviceType = (
userAgent: string,
device: UAParser.IDevice,
browser: UAParser.IBrowser,
operatingSystem: UAParser.IOS,
): DeviceType => {
if (device.type === "mobile" || operatingSystem.name?.includes("Android") || userAgent.indexOf(IOS_KEYWORD) > -1) {
return DeviceType.Mobile;
}
if (browser.name === "Electron") {
return DeviceType.Desktop;
}
if (!!browser.name) {
return DeviceType.Web;
}
return DeviceType.Unknown;
};
interface CustomValues {
customDeviceModel?: string;
customDeviceOS?: string;
}
/**
* Some mobile model and OS strings are not recognised
* by the UA parsing library
* check they exist by hand
*/
const checkForCustomValues = (userAgent: string): CustomValues => {
if (userAgent.includes(BROWSER_KEYWORD)) {
return {};
}
const mightHaveDevice = userAgent.includes("(");
if (!mightHaveDevice) {
return {};
}
const deviceInfoSegments = userAgent.substring(userAgent.indexOf("(") + 1).split("; ");
const customDeviceModel = deviceInfoSegments[0] || undefined;
const customDeviceOS = deviceInfoSegments[1] || undefined;
return { customDeviceModel, customDeviceOS };
};
const concatenateNameAndVersion = (name?: string, version?: string): string | undefined =>
name && [name, version].filter(Boolean).join(" ");
export const parseUserAgent = (userAgent?: string): ExtendedDeviceInformation => {
if (!userAgent) {
return {
deviceType: DeviceType.Unknown,
};
}
const parser = new UAParser(userAgent);
const browser = parser.getBrowser();
const device = parser.getDevice();
const operatingSystem = parser.getOS();
const deviceType = getDeviceType(userAgent, device, browser, operatingSystem);
// OSX versions are frozen at 10.15.17 in UA strings https://chromestatus.com/feature/5452592194781184
// ignore OS version in browser based sessions
const shouldIgnoreOSVersion = deviceType === DeviceType.Web || deviceType === DeviceType.Desktop;
const deviceOperatingSystem = concatenateNameAndVersion(
operatingSystem.name,
shouldIgnoreOSVersion ? undefined : operatingSystem.version,
);
const deviceModel = concatenateNameAndVersion(device.vendor, device.model);
const client = concatenateNameAndVersion(browser.name, browser.version);
// only try to parse custom model and OS when device type is known
const { customDeviceModel, customDeviceOS } =
deviceType !== DeviceType.Unknown ? checkForCustomValues(userAgent) : ({} as CustomValues);
return {
deviceType,
deviceModel: deviceModel || customDeviceModel,
deviceOperatingSystem: deviceOperatingSystem || customDeviceOS,
client,
};
};
@@ -0,0 +1,32 @@
/*
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 { logger } from "matrix-js-sdk/src/logger";
const SNOOZE_KEY = "mx_snooze_bulk_unverified_device_nag";
// one week
const snoozePeriod = 1000 * 60 * 60 * 24 * 7;
export const snoozeBulkUnverifiedDeviceReminder = (): void => {
try {
localStorage.setItem(SNOOZE_KEY, String(Date.now()));
} catch (error) {
logger.error("Failed to persist bulk unverified device nag snooze", error);
}
};
export const isBulkUnverifiedDeviceReminderSnoozed = (): boolean => {
try {
const snoozedTimestamp = localStorage.getItem(SNOOZE_KEY);
const parsedTimestamp = Number.parseInt(snoozedTimestamp || "", 10);
return Number.isInteger(parsedTimestamp) && parsedTimestamp + snoozePeriod > Date.now();
} catch {
return false;
}
};
+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.
*/
export type DeviceClientInformation = {
name?: string;
version?: string;
url?: string;
};
+204
View File
@@ -0,0 +1,204 @@
/*
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 { ClientEvent, type MatrixClient } from "matrix-js-sdk/src/matrix";
import { logger } from "matrix-js-sdk/src/logger";
import { canEncryptToAllUsers } from "../createRoom";
import { Action } from "../dispatcher/actions";
import { type ViewRoomPayload } from "../dispatcher/payloads/ViewRoomPayload";
import dis from "../dispatcher/dispatcher";
import { type LocalRoom, LocalRoomState } from "../models/LocalRoom";
import { waitForRoomReadyAndApplyAfterCreateCallbacks } from "./local-room";
import { findDMRoom } from "./dm/findDMRoom";
import { privateShouldBeEncrypted } from "./rooms";
import { createDmLocalRoom } from "./dm/createDmLocalRoom";
import { startDm } from "./dm/startDm";
import { resolveThreePids } from "./threepids";
export async function startDmOnFirstMessage(client: MatrixClient, targets: Member[]): Promise<string | null> {
let resolvedTargets = targets;
try {
resolvedTargets = await resolveThreePids(targets, client);
} catch (e) {
logger.warn("Error resolving 3rd-party members", e);
}
const existingRoom = findDMRoom(client, resolvedTargets);
if (existingRoom) {
dis.dispatch<ViewRoomPayload>({
action: Action.ViewRoom,
room_id: existingRoom.roomId,
should_peek: false,
joining: false,
metricsTrigger: "MessageUser",
});
return existingRoom.roomId;
}
if (targets.length === 1 && targets[0] instanceof ThreepidMember && privateShouldBeEncrypted(client)) {
// Single 3rd-party invite and well-known promotes encryption:
// Directly create a room and invite the other.
return await startDm(client, targets);
}
const room = await createDmLocalRoom(client, resolvedTargets);
dis.dispatch({
action: Action.ViewRoom,
room_id: room.roomId,
joining: false,
targets: resolvedTargets,
});
return room.roomId;
}
/**
* Starts a DM based on a local room.
*
* @async
* @param {MatrixClient} client
* @param {LocalRoom} localRoom
* @returns {Promise<string | void>} Resolves to the created room id
*/
export async function createRoomFromLocalRoom(client: MatrixClient, localRoom: LocalRoom): Promise<string | void> {
if (!localRoom.isNew) {
// This action only makes sense for new local rooms.
return;
}
localRoom.state = LocalRoomState.CREATING;
client.emit(ClientEvent.Room, localRoom);
return startDm(client, localRoom.targets, false).then(
(roomId) => {
if (!roomId) throw new Error(`startDm for local room ${localRoom.roomId} didn't return a room Id`);
localRoom.actualRoomId = roomId;
return waitForRoomReadyAndApplyAfterCreateCallbacks(client, localRoom, roomId);
},
() => {
logger.warn(`Error creating DM for local room ${localRoom.roomId}`);
localRoom.state = LocalRoomState.ERROR;
client.emit(ClientEvent.Room, localRoom);
},
);
}
// This is the interface that is expected by various components in the Invite Dialog and RoomInvite.
// It is a bit awkward because it also matches the RoomMember class from the js-sdk with some extra support
// for 3PIDs/email addresses.
export abstract class Member {
/**
* The display name of this Member. For users this should be their profile's display
* name or user ID if none set. For 3PIDs this should be the 3PID address (email).
*/
public abstract get name(): string;
/**
* The ID of this Member. For users this should be their user ID. For 3PIDs this should
* be the 3PID address (email).
*/
public abstract get userId(): string;
/**
* Gets the MXC URL of this Member's avatar. For users this should be their profile's
* avatar MXC URL or null if none set. For 3PIDs this should always be undefined.
*/
public abstract getMxcAvatarUrl(): string | undefined;
}
export class DirectoryMember extends Member {
private readonly _userId: string;
private readonly displayName?: string;
private readonly avatarUrl?: string;
// eslint-disable-next-line camelcase
public constructor(userDirResult: { user_id: string; display_name?: string; avatar_url?: string }) {
super();
this._userId = userDirResult.user_id;
this.displayName = userDirResult.display_name;
this.avatarUrl = userDirResult.avatar_url;
}
// These next class members are for the Member interface
public get name(): string {
return this.displayName || this._userId;
}
public get userId(): string {
return this._userId;
}
public getMxcAvatarUrl(): string | undefined {
return this.avatarUrl;
}
}
export class ThreepidMember extends Member {
private readonly id: string;
public constructor(id: string) {
super();
this.id = id;
}
// This is a getter that would be falsy on all other implementations. Until we have
// better type support in the react-sdk we can use this trick to determine the kind
// of 3PID we're dealing with, if any.
public get isEmail(): boolean {
return this.id.includes("@");
}
// These next class members are for the Member interface
public get name(): string {
return this.id;
}
public get userId(): string {
return this.id;
}
public getMxcAvatarUrl(): string | undefined {
return undefined;
}
}
export interface IDMUserTileProps {
member: Member;
onRemove?(member: Member): void;
}
/**
* Detects whether a room should be encrypted.
*
* @async
* @param {MatrixClient} client
* @param {Member[]} targets The members to which run the check against
* @returns {Promise<boolean>}
*/
export async function determineCreateRoomEncryptionOption(client: MatrixClient, targets: Member[]): Promise<boolean> {
if (privateShouldBeEncrypted(client)) {
// Enable encryption for a single 3rd party invite.
if (targets.length === 1 && targets[0] instanceof ThreepidMember) return true;
// Check whether all users have uploaded device keys before.
// If so, enable encryption in the new room.
const has3PidMembers = targets.some((t) => t instanceof ThreepidMember);
if (!has3PidMembers) {
const targetIds = targets.map((t) => t.userId);
const allHaveDeviceKeys = await canEncryptToAllUsers(client, targetIds);
if (allHaveDeviceKeys) {
return true;
}
}
}
return false;
}
+118
View File
@@ -0,0 +1,118 @@
/*
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 { EventType, KNOWN_SAFE_ROOM_VERSION, type MatrixClient, MatrixEvent } from "matrix-js-sdk/src/matrix";
import { KnownMembership } from "matrix-js-sdk/src/types";
import { LOCAL_ROOM_ID_PREFIX, LocalRoom } from "../../../src/models/LocalRoom";
import { determineCreateRoomEncryptionOption, type Member } from "../../../src/utils/direct-messages";
import { MEGOLM_ENCRYPTION_ALGORITHM } from "../crypto";
/**
* Create a DM local room. This room will not be send to the server and only exists inside the client.
* It sets up the local room with some artificial state events
* so that can be used in most components instead of a „real“ room.
*
* @async
* @param {MatrixClient} client
* @param {Member[]} targets DM partners
* @returns {Promise<LocalRoom>} Resolves to the new local room
*/
export async function createDmLocalRoom(client: MatrixClient, targets: Member[]): Promise<LocalRoom> {
const userId = client.getUserId()!;
const localRoom = new LocalRoom(LOCAL_ROOM_ID_PREFIX + client.makeTxnId(), client, userId);
const events: MatrixEvent[] = [];
events.push(
new MatrixEvent({
event_id: `~${localRoom.roomId}:${client.makeTxnId()}`,
type: EventType.RoomCreate,
content: {
creator: userId,
room_version: KNOWN_SAFE_ROOM_VERSION,
},
state_key: "",
sender: userId,
room_id: localRoom.roomId,
origin_server_ts: Date.now(),
}),
);
if (await determineCreateRoomEncryptionOption(client, targets)) {
localRoom.encrypted = true;
events.push(
new MatrixEvent({
event_id: `~${localRoom.roomId}:${client.makeTxnId()}`,
type: EventType.RoomEncryption,
content: {
algorithm: MEGOLM_ENCRYPTION_ALGORITHM,
},
sender: userId,
state_key: "",
room_id: localRoom.roomId,
origin_server_ts: Date.now(),
}),
);
}
events.push(
new MatrixEvent({
event_id: `~${localRoom.roomId}:${client.makeTxnId()}`,
type: EventType.RoomMember,
content: {
displayname: userId,
membership: KnownMembership.Join,
},
state_key: userId,
sender: userId,
room_id: localRoom.roomId,
}),
);
targets.forEach((target: Member) => {
events.push(
new MatrixEvent({
event_id: `~${localRoom.roomId}:${client.makeTxnId()}`,
type: EventType.RoomMember,
content: {
displayname: target.name,
avatar_url: target.getMxcAvatarUrl() ?? undefined,
membership: KnownMembership.Invite,
isDirect: true,
},
state_key: target.userId,
sender: userId,
room_id: localRoom.roomId,
}),
);
events.push(
new MatrixEvent({
event_id: `~${localRoom.roomId}:${client.makeTxnId()}`,
type: EventType.RoomMember,
content: {
displayname: target.name,
avatar_url: target.getMxcAvatarUrl() ?? undefined,
membership: KnownMembership.Join,
},
state_key: target.userId,
sender: target.userId,
room_id: localRoom.roomId,
}),
);
});
localRoom.targets = targets;
localRoom.updateMyMembership(KnownMembership.Join);
localRoom.addLiveEvents(events, { addToState: true });
localRoom.currentState.setStateEvents(events);
localRoom.name = localRoom.getDefaultRoomName(client.getUserId()!);
client.store.storeRoom(localRoom);
return localRoom;
}
@@ -0,0 +1,61 @@
/*
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.
*/
interface FilterValidMDirectResult {
/** Whether the entire content is valid */
valid: boolean;
/** Filtered content with only the valid parts */
filteredContent: Record<string, string[]>;
}
/**
* Filter m.direct content to be compliant to https://spec.matrix.org/v1.6/client-server-api/#mdirect.
*
* @param content - Raw event content to be filerted
* @returns value as a flag whether to content was valid.
* filteredContent with only values from the content that are spec compliant.
*/
export const filterValidMDirect = (content: unknown): FilterValidMDirectResult => {
if (content === null || typeof content !== "object") {
return {
valid: false,
filteredContent: {},
};
}
const filteredContent = new Map();
let valid = true;
for (const [userId, roomIds] of Object.entries(content)) {
if (typeof userId !== "string") {
valid = false;
continue;
}
if (!Array.isArray(roomIds)) {
valid = false;
continue;
}
const filteredRoomIds: string[] = [];
filteredContent.set(userId, filteredRoomIds);
for (const roomId of roomIds) {
if (typeof roomId === "string") {
filteredRoomIds.push(roomId);
} else {
valid = false;
}
}
}
return {
valid,
filteredContent: Object.fromEntries(filteredContent.entries()),
};
};
+95
View File
@@ -0,0 +1,95 @@
/*
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 MatrixClient, type Room } from "matrix-js-sdk/src/matrix";
import { KnownMembership } from "matrix-js-sdk/src/types";
import DMRoomMap from "../DMRoomMap";
import { isLocalRoom } from "../localRoom/isLocalRoom";
import { isJoinedOrNearlyJoined } from "../membership";
import { getFunctionalMembers } from "../room/getFunctionalMembers";
/**
* Iterates the rooms and tries to find a DM room with the user identified by UserId.
* A DM room is assumed if one of the following matches:
* - Has two members and contains a membership for the user identified by userId
* - findRoomWithThirdpartyInvites is true and has one member and a third pending third party invite
*
* If multiple rooms match it will return the one with the most recent event.
*
* @param rooms - Rooms to iterate
* @param userId - User Id of the other user
* @param [findRoomWithThirdpartyInvites] - Whether to find a DM for a pending thirdparty invite
* @returns DM room if found or undefined if not
*/
function extractSuitableRoom(rooms: Room[], userId: string, findRoomWithThirdpartyInvites: boolean): Room | undefined {
const suitableRooms = rooms
.filter((r) => {
// Validate that we are joined and the other person is also joined. We'll also make sure
// that the room also looks like a DM (until we have canonical DMs to tell us). For now,
// a DM is a room of two people that contains those two people exactly. This does mean
// that bots, assistants, etc will ruin a room's DM-ness, though this is a problem for
// canonical DMs to solve.
if (r && r.getMyMembership() === KnownMembership.Join) {
if (isLocalRoom(r)) return false;
const functionalUsers = getFunctionalMembers(r);
const members = r.currentState.getMembers();
const joinedMembers = members.filter(
(m) => !functionalUsers.includes(m.userId) && m.membership && isJoinedOrNearlyJoined(m.membership),
);
const otherMember = joinedMembers.find((m) => m.userId === userId);
if (otherMember && joinedMembers.length === 2) {
return true;
}
const thirdPartyInvites = r.currentState.getStateEvents("m.room.third_party_invite") || [];
// match room with pending third-party invite
return findRoomWithThirdpartyInvites && joinedMembers.length === 1 && thirdPartyInvites.length === 1;
}
return false;
})
.sort((r1, r2) => {
return r2.getLastActiveTimestamp() - r1.getLastActiveTimestamp();
});
if (suitableRooms.length) {
return suitableRooms[0];
}
return undefined;
}
/**
* Tries to find a DM room with a specific user.
*
* @param {MatrixClient} client
* @param {string} userId ID of the user to find the DM for
* @returns {Room | undefined} Room if found
*/
export function findDMForUser(client: MatrixClient, userId: string): Room | undefined {
const roomIdsForUserId = DMRoomMap.shared().getDMRoomsForUserId(userId);
const roomsForUserId = roomIdsForUserId.map((id) => client.getRoom(id)).filter((r): r is Room => r !== null);
// Call with findRoomWithThirdpartyInvites = true to also include rooms with pending thirdparty invites.
// roomsForUserId can only contain rooms with the other user here,
// because they have been queried by getDMRoomsForUserId().
const suitableRoomForUserId = extractSuitableRoom(roomsForUserId, userId, true);
if (suitableRoomForUserId) {
return suitableRoomForUserId;
}
// Try to find in all rooms as a fallback
const allRoomIds = DMRoomMap.shared().getRoomIds();
const allRooms = Array.from(allRoomIds)
.map((id) => client.getRoom(id))
.filter((r): r is Room => r !== null);
return extractSuitableRoom(allRooms, userId, false);
}
+31
View File
@@ -0,0 +1,31 @@
/*
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 MatrixClient, type Room } from "matrix-js-sdk/src/matrix";
import { type Member } from "../direct-messages";
import DMRoomMap from "../DMRoomMap";
import { findDMForUser } from "./findDMForUser";
/**
* Tries to find a DM room with some other users.
*
* @param {MatrixClient} client
* @param {Member[]} targets The Members to try to find the room for
* @returns {Room | null} Resolved so the room if found, else null
*/
export function findDMRoom(client: MatrixClient, targets: Member[]): Room | null {
const targetIds = targets.map((t) => t.userId);
let existingRoom: Room | null;
if (targetIds.length === 1) {
existingRoom = findDMForUser(client, targetIds[0]) ?? null;
} else {
existingRoom = DMRoomMap.shared().getDMRoomForIdentifiers(targetIds);
}
return existingRoom;
}
+85
View File
@@ -0,0 +1,85 @@
/*
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 IInvite3PID, type MatrixClient, type Room } from "matrix-js-sdk/src/matrix";
import { Action } from "../../dispatcher/actions";
import { type ViewRoomPayload } from "../../dispatcher/payloads/ViewRoomPayload";
import { determineCreateRoomEncryptionOption, type Member } from "../direct-messages";
import DMRoomMap from "../DMRoomMap";
import { isLocalRoom } from "../localRoom/isLocalRoom";
import { findDMForUser } from "./findDMForUser";
import dis from "../../dispatcher/dispatcher";
import { getAddressType } from "../../UserAddress";
import createRoom, { type IOpts } from "../../createRoom";
/**
* Start a DM.
*
* @returns {Promise<string | null} Resolves to the room id.
*/
export async function startDm(client: MatrixClient, targets: Member[], showSpinner = true): Promise<string | null> {
const targetIds = targets.map((t) => t.userId);
// Check if there is already a DM with these people and reuse it if possible.
let existingRoom: Room | undefined;
if (targetIds.length === 1) {
existingRoom = findDMForUser(client, targetIds[0]);
} else {
existingRoom = DMRoomMap.shared().getDMRoomForIdentifiers(targetIds) ?? undefined;
}
if (existingRoom && !isLocalRoom(existingRoom)) {
dis.dispatch<ViewRoomPayload>({
action: Action.ViewRoom,
room_id: existingRoom.roomId,
should_peek: false,
joining: false,
metricsTrigger: "MessageUser",
});
return Promise.resolve(existingRoom.roomId);
}
const createRoomOptions: IOpts = { inlineErrors: true };
if (await determineCreateRoomEncryptionOption(client, targets)) {
createRoomOptions.encryption = true;
}
// Check if it's a traditional DM and create the room if required.
// TODO: [Canonical DMs] Remove this check and instead just create the multi-person DM
const isSelf = targetIds.length === 1 && targetIds[0] === client.getUserId();
if (targetIds.length === 1 && !isSelf) {
createRoomOptions.dmUserId = targetIds[0];
}
if (targetIds.length > 1) {
createRoomOptions.createOpts = targetIds.reduce<{
invite_3pid: IInvite3PID[];
invite: string[];
}>(
(roomOptions, address) => {
const type = getAddressType(address);
if (type === "email") {
const invite: IInvite3PID = {
id_server: client.getIdentityServerUrl(true)!,
medium: "email",
address,
};
roomOptions.invite_3pid.push(invite);
} else if (type === "mx-user-id") {
roomOptions.invite.push(address);
}
return roomOptions;
},
{ invite: [], invite_3pid: [] },
);
}
createRoomOptions.spinner = showSpinner;
return createRoom(client, createRoomOptions);
}
+41
View File
@@ -0,0 +1,41 @@
/*
Copyright 2024 New Vector Ltd.
Copyright 2020, 2021 The Matrix.org Foundation C.I.C.
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE files in the repository root for full details.
*/
/**
* Get the values for an enum.
* @param e The enum.
* @returns The enum values.
*/
export function getEnumValues(e: any): (string | number)[] {
// String-based enums will simply be objects ({Key: "value"}), but number-based
// enums will instead map themselves twice: in one direction for {Key: 12} and
// the reverse for easy lookup, presumably ({12: Key}). In the reverse mapping,
// the key is a string, not a number.
//
// For this reason, we try to determine what kind of enum we're dealing with.
const keys = Object.keys(e);
const values: (string | number)[] = [];
for (const key of keys) {
const value = e[key];
if (Number.isFinite(value) || e[value.toString()] !== Number(key)) {
values.push(value);
}
}
return values;
}
/**
* Determines if a given value is a valid value for the provided enum.
* @param e The enum to check against.
* @param val The value to search for.
* @returns True if the enum contains the value.
*/
export function isEnumValue<T>(e: T, val: string | number): boolean {
return getEnumValues(e).includes(val);
}
+15
View File
@@ -0,0 +1,15 @@
/*
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 MatrixEvent } from "matrix-js-sdk/src/matrix";
import { _t } from "../../languageHandler";
export function getSenderName(event: MatrixEvent): string {
return event.sender?.name ?? event.getSender() ?? _t("common|someone");
}
+318
View File
@@ -0,0 +1,318 @@
/*
Copyright 2024 New Vector Ltd.
Copyright 2021, 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 { Direction, type MatrixEvent, type Relations, type Room } from "matrix-js-sdk/src/matrix";
import { type EventType, type MediaEventContent, type RelationType } from "matrix-js-sdk/src/types";
import { saveAs } from "file-saver";
import { logger } from "matrix-js-sdk/src/logger";
import sanitizeFilename from "sanitize-filename";
import { ExportType, type IExportOptions } from "./exportUtils";
import { decryptFile } from "../DecryptFile";
import { mediaFromContent } from "../../customisations/Media";
import { formatFullDateNoDay, formatFullDateNoDayISO } from "../../DateUtils";
import { isVoiceMessage } from "../EventUtils";
import { _t } from "../../languageHandler";
import SdkConfig from "../../SdkConfig";
type BlobFile = {
name: string;
blob: Blob;
};
type FileDetails = {
directory: string;
name: string;
date: string;
extension: string;
count?: number;
};
export default abstract class Exporter {
protected files: BlobFile[] = [];
protected fileNames: Map<string, number> = new Map();
protected cancelled = false;
protected constructor(
protected room: Room,
protected exportType: ExportType,
protected exportOptions: IExportOptions,
protected setProgressText: React.Dispatch<React.SetStateAction<string>>,
) {
if (
exportOptions.maxSize < 1 * 1024 * 1024 || // Less than 1 MB
exportOptions.maxSize > 8000 * 1024 * 1024 || // More than 8 GB
(!!exportOptions.numberOfMessages && exportOptions.numberOfMessages > 10 ** 8) ||
(exportType === ExportType.LastNMessages && !exportOptions.numberOfMessages)
) {
throw new Error("Invalid export options");
}
window.addEventListener("beforeunload", this.onBeforeUnload);
}
public get destinationFileName(): string {
return this.makeFileNameNoExtension(SdkConfig.get().brand) + ".zip";
}
protected onBeforeUnload(this: void, e: BeforeUnloadEvent): string {
e.preventDefault();
return (e.returnValue = _t("export_chat|unload_confirm"));
}
protected updateProgress(progress: string, log = true, show = true): void {
if (log) logger.log(progress);
if (show) this.setProgressText(progress);
}
protected addFile(filePath: string, blob: Blob): void {
const file = {
name: filePath,
blob,
};
this.files.push(file);
}
protected makeFileNameNoExtension(brand = "matrix"): string {
// First try to use the real name of the room, then a translated copy of a generic name,
// then finally hardcoded default to guarantee we'll have a name.
const safeRoomName = sanitizeFilename(this.room.name ?? _t("common|unnamed_room")).trim() || "Unnamed Room";
const safeDate = formatFullDateNoDayISO(new Date()).replace(/:/g, "-"); // ISO format automatically removes a lot of stuff for us
const safeBrand = sanitizeFilename(brand);
return `${safeBrand} - ${safeRoomName} - Chat Export - ${safeDate}`;
}
protected async downloadZIP(): Promise<string | void> {
const filename = this.destinationFileName;
const filenameWithoutExt = filename.substring(0, filename.lastIndexOf(".")); // take off the extension
const { default: JSZip } = await import("jszip");
const zip = new JSZip();
// Create a writable stream to the directory
if (!this.cancelled) this.updateProgress(_t("export_chat|generating_zip"));
else return this.cleanUp();
for (const file of this.files) zip.file(filenameWithoutExt + "/" + file.name, file.blob);
const content = await zip.generateAsync({ type: "blob" });
saveAs(content, filenameWithoutExt + ".zip");
}
protected cleanUp(): string {
logger.log("Cleaning up...");
window.removeEventListener("beforeunload", this.onBeforeUnload);
return "";
}
public async cancelExport(): Promise<void> {
logger.log("Cancelling export...");
this.cancelled = true;
}
protected downloadPlainText(fileName: string, text: string): void {
const content = new Blob([text], { type: "text/plain" });
saveAs(content, fileName);
}
protected setEventMetadata(event: MatrixEvent): MatrixEvent {
event.setMetadata(this.room.currentState, false);
return event;
}
public getLimit(): number {
let limit: number;
switch (this.exportType) {
case ExportType.LastNMessages:
// validated in constructor that numberOfMessages is defined
// when export type is LastNMessages
limit = this.exportOptions.numberOfMessages!;
break;
default:
limit = 10 ** 8;
}
return limit;
}
protected async getRequiredEvents(): Promise<MatrixEvent[]> {
const eventMapper = this.room.client.getEventMapper();
let prevToken: string | null = null;
let events: MatrixEvent[] = [];
if (this.exportType === ExportType.Timeline) {
events = this.room.getLiveTimeline().getEvents();
} else {
let limit = this.getLimit();
while (limit) {
const eventsPerCrawl = Math.min(limit, 1000);
const res = await this.room.client.createMessagesRequest(
this.room.roomId,
prevToken,
eventsPerCrawl,
Direction.Backward,
);
if (this.cancelled) {
this.cleanUp();
return [];
}
if (res.chunk.length === 0) break;
limit -= res.chunk.length;
const matrixEvents: MatrixEvent[] = res.chunk.map(eventMapper);
for (const mxEv of matrixEvents) {
// if (this.exportOptions.startDate && mxEv.getTs() < this.exportOptions.startDate) {
// // Once the last message received is older than the start date, we break out of both the loops
// limit = 0;
// break;
// }
events.push(mxEv);
}
if (this.exportType === ExportType.LastNMessages) {
this.updateProgress(
_t("export_chat|fetched_n_events_with_total", {
count: events.length,
total: this.exportOptions.numberOfMessages,
}),
);
} else {
this.updateProgress(
_t("export_chat|fetched_n_events", {
count: events.length,
}),
);
}
prevToken = res.end ?? null;
}
// Reverse the events so that we preserve the order
events.reverse();
}
const decryptionPromises = events
.filter((event) => event.isEncrypted())
.map((event) => {
return this.room.client.decryptEventIfNeeded(event, { emit: false });
});
// Wait for all the events to get decrypted.
await Promise.all(decryptionPromises);
for (let i = 0; i < events.length; i++) this.setEventMetadata(events[i]);
return events;
}
/**
* Decrypts if necessary, and fetches media from a matrix event
* @param event - matrix event with media event content
* @resolves when media has been fetched
* @throws if media was unable to be fetched
*/
protected async getMediaBlob(event: MatrixEvent): Promise<Blob> {
let blob: Blob | undefined = undefined;
try {
const isEncrypted = event.isEncrypted();
const content = event.getContent<MediaEventContent>();
const shouldDecrypt = isEncrypted && content.hasOwnProperty("file") && event.getType() !== "m.sticker";
if (shouldDecrypt) {
blob = await decryptFile(content.file);
} else {
const media = mediaFromContent(content);
if (!media.srcHttp) {
throw new Error("Cannot fetch without srcHttp");
}
const image = await fetch(media.srcHttp);
blob = await image.blob();
}
} catch {
logger.log("Error decrypting media");
}
if (!blob) {
throw new Error("Unable to fetch file");
}
return blob;
}
public splitFileName(file: string): string[] {
const lastDot = file.lastIndexOf(".");
if (lastDot === -1) return [file, ""];
const fileName = file.slice(0, lastDot);
const ext = file.slice(lastDot + 1);
return [fileName, "." + ext];
}
protected makeUniqueFilePath(details: FileDetails): string {
const makePath = ({ directory, name, date, extension, count = 0 }: FileDetails): string =>
`${directory}/${name}-${date}${count > 0 ? ` (${count})` : ""}${extension}`;
const defaultPath = makePath(details);
const count = this.fileNames.get(defaultPath) || 0;
this.fileNames.set(defaultPath, count + 1);
if (count > 0) {
return makePath({ ...details, count });
}
return defaultPath;
}
public getFilePath(event: MatrixEvent): string {
const mediaType = event.getContent().msgtype;
let fileDirectory: string;
switch (mediaType) {
case "m.image":
fileDirectory = "images";
break;
case "m.video":
fileDirectory = "videos";
break;
case "m.audio":
fileDirectory = "audio";
break;
default:
fileDirectory = event.getType() === "m.sticker" ? "stickers" : "files";
}
const fileDate = formatFullDateNoDay(new Date(event.getTs()));
let [fileName, fileExt] = this.splitFileName(event.getContent().body);
if (event.getType() === "m.sticker") fileExt = ".png";
if (isVoiceMessage(event)) fileExt = ".ogg";
return this.makeUniqueFilePath({
directory: fileDirectory,
name: fileName,
date: fileDate,
extension: fileExt,
});
}
protected isReply(event: MatrixEvent): boolean {
const isEncrypted = event.isEncrypted();
// If encrypted, in_reply_to lies in event.event.content
const content = isEncrypted ? event.event.content! : event.getContent();
const relatesTo = content["m.relates_to"];
return !!(relatesTo && relatesTo["m.in_reply_to"]);
}
protected isAttachment(mxEv: MatrixEvent): boolean {
const attachmentTypes = ["m.sticker", "m.image", "m.file", "m.video", "m.audio"];
return mxEv.getType() === attachmentTypes[0] || attachmentTypes.includes(mxEv.getContent().msgtype!);
}
protected getRelationsForEvent = (
eventId: string,
relationType: RelationType | string,
eventType: EventType | string,
): Relations | undefined => {
return this.room.getUnfilteredTimelineSet().relations.getChildEventsForEvent(eventId, relationType, eventType);
};
public abstract export(): Promise<void>;
}
@@ -0,0 +1,512 @@
/*
Copyright 2024 New Vector Ltd.
Copyright 2021-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 React, { type JSX } from "react";
import { createRoot } from "react-dom/client";
import { type Room, MatrixEvent, EventType, MsgType } from "matrix-js-sdk/src/matrix";
import { renderToStaticMarkup } from "react-dom/server";
import { logger } from "matrix-js-sdk/src/logger";
import escapeHtml from "escape-html";
import { TooltipProvider } from "@vector-im/compound-web";
import { DateSeparatorView, I18nContext } from "@element-hq/web-shared-components";
import Exporter from "./Exporter";
import { mediaFromMxc } from "../../customisations/Media";
import { Layout } from "../../settings/enums/Layout";
import { shouldFormContinuation } from "../../components/structures/MessagePanel";
import { formatFullDateNoDayNoTime, wantsDateSeparator } from "../../DateUtils";
import { RoomPermalinkCreator } from "../permalinks/Permalinks";
import { _t } from "../../languageHandler";
import * as Avatar from "../../Avatar";
import EventTile from "../../components/views/rooms/EventTile";
import BaseAvatar from "../../components/views/avatars/BaseAvatar";
import { type ExportType, type IExportOptions } from "./exportUtils";
import MatrixClientContext from "../../contexts/MatrixClientContext";
import getExportCSS from "./exportCSS";
import { textForEvent } from "../../TextForEvent";
import { haveRendererForEvent } from "../../events/EventTileFactory";
import { SDKContext, SdkContextClass } from "../../contexts/SDKContext.ts";
import { DateSeparatorViewModel } from "../../viewmodels/room/timeline/DateSeparatorViewModel";
import exportJS from "!!raw-loader!./exportJS";
export default class HTMLExporter extends Exporter {
protected avatars: Map<string, boolean>;
protected permalinkCreator: RoomPermalinkCreator;
protected totalSize: number;
protected mediaOmitText: string;
public constructor(
room: Room,
exportType: ExportType,
exportOptions: IExportOptions,
setProgressText: React.Dispatch<React.SetStateAction<string>>,
) {
super(room, exportType, exportOptions, setProgressText);
this.avatars = new Map<string, boolean>();
this.permalinkCreator = new RoomPermalinkCreator(this.room);
this.totalSize = 0;
this.mediaOmitText = !this.exportOptions.attachmentsIncluded
? _t("export_chat|media_omitted")
: _t("export_chat|media_omitted_file_size");
}
private renderToStaticMarkupWithProviders(element: JSX.Element): string {
return renderToStaticMarkup(
<I18nContext.Provider value={window.mxModuleApi.i18n}>{element}</I18nContext.Provider>,
);
}
protected async getRoomAvatar(): Promise<string> {
let blob: Blob | undefined = undefined;
const avatarUrl = Avatar.avatarUrlForRoom(this.room, 32, 32, "crop");
const avatarPath = "room.png";
if (avatarUrl) {
try {
const image = await fetch(avatarUrl);
blob = await image.blob();
this.totalSize += blob.size;
this.addFile(avatarPath, blob);
} catch (err) {
logger.log("Failed to fetch room's avatar" + err);
}
}
const avatar = (
<BaseAvatar size="32px" name={this.room.name} title={this.room.name} url={blob ? avatarPath : ""} />
);
return this.renderToStaticMarkupWithProviders(avatar);
}
protected async wrapHTML(content: string, currentPage: number, nbPages: number): Promise<string> {
const roomAvatar = await this.getRoomAvatar();
const exportDate = formatFullDateNoDayNoTime(new Date());
const creator = this.room.currentState.getStateEvents(EventType.RoomCreate, "")?.getSender();
const creatorName = (creator ? this.room.getMember(creator)?.rawDisplayName : creator) || creator;
const exporter = this.room.client.getSafeUserId();
const exporterName = this.room.getMember(exporter)?.rawDisplayName;
const topic = this.room.currentState.getStateEvents(EventType.RoomTopic, "")?.getContent()?.topic || "";
const safeCreatedText = escapeHtml(
_t("export_chat|creator_summary", {
creatorName,
}),
);
const safeExporter = escapeHtml(exporter);
const safeRoomName = escapeHtml(this.room.name);
const safeTopic = escapeHtml(topic);
const safeExportedText = this.renderToStaticMarkupWithProviders(
<p>
{_t(
"export_chat|export_info",
{
exportDate,
},
{
roomName: () => <strong>{safeRoomName}</strong>,
exporterDetails: () => (
<a
href={`https://matrix.to/#/${encodeURIComponent(exporter)}`}
target="_blank"
rel="noopener noreferrer"
>
{exporterName ? (
<>
<strong>{escapeHtml(exporterName)}</strong>I {" (" + safeExporter + ")"}
</>
) : (
<strong>{safeExporter}</strong>
)}
</a>
),
},
)}
</p>,
);
const safeTopicText = topic ? _t("export_chat|topic", { topic: safeTopic }) : "";
const previousMessagesLink = this.renderToStaticMarkupWithProviders(
currentPage !== 0 ? (
<div style={{ textAlign: "center" }}>
<a href={`./messages${currentPage === 1 ? "" : currentPage}.html`} style={{ fontWeight: "bold" }}>
{_t("export_chat|previous_page")}
</a>
</div>
) : (
<></>
),
);
const nextMessagesLink = this.renderToStaticMarkupWithProviders(
currentPage < nbPages - 1 ? (
<div style={{ textAlign: "center", margin: "10px" }}>
<a href={"./messages" + (currentPage + 2) + ".html"} style={{ fontWeight: "bold" }}>
{_t("export_chat|next_page")}
</a>
</div>
) : (
<></>
),
);
return `
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta http-equiv="X-UA-Compatible" content="IE=edge" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<link href="css/style.css" rel="stylesheet" />
<script src="js/script.js"></script>
<title>${_t("export_chat|html_title")}</title>
</head>
<body style="height: 100vh;" class="cpd-theme-light">
<div id="matrixchat" style="height: 100%; overflow: auto">
<div class="mx_MatrixChat_wrapper" aria-hidden="false">
<div class="mx_MatrixChat">
<main class="mx_RoomView">
<div class="mx_RoomHeader light-panel">
${roomAvatar}
<div class="mx_RoomHeader_infoWrapper">
<div
dir="auto"
class="mx_RoomHeader_info"
title="${safeRoomName}"
>
<span class="mx_RoomHeader_truncated mx_lineClamp">
${safeRoomName}
</span>
</div>
</div>
</div>
${previousMessagesLink}
<div class="mx_MainSplit">
<div class="mx_RoomView_body">
<div
class="mx_RoomView_timeline mx_RoomView_timeline_rr_enabled"
>
<div
class="
mx_AutoHideScrollbar
mx_ScrollPanel
mx_RoomView_messagePanel
"
>
<div class="mx_RoomView_messageListWrapper">
<ol
class="mx_RoomView_MessageList"
aria-live="polite"
role="list"
>
${
currentPage == 0
? `<div class="mx_NewRoomIntro">
${roomAvatar}
<h2> ${safeRoomName} </h2>
<p> ${safeCreatedText} <br/><br/> ${safeExportedText} </p>
<br/>
<p> ${safeTopicText} </p>
</div>`
: ""
}
${content}
</ol>
</div>
</div>
</div>
<div class="mx_RoomView_statusArea">
<div class="mx_RoomView_statusAreaBox">
<div class="mx_RoomView_statusAreaBox_line"></div>
</div>
</div>
</div>
</div>
${nextMessagesLink}
</main>
</div>
</div>
</div>
<div id="snackbar"/>
</body>
</html>`;
}
protected getAvatarURL(event: MatrixEvent): string | null {
const member = event.sender;
const avatarUrl = member?.getMxcAvatarUrl();
return avatarUrl ? mediaFromMxc(avatarUrl).getThumbnailOfSourceHttp(30, 30, "crop") : null;
}
protected async saveAvatarIfNeeded(event: MatrixEvent): Promise<void> {
const member = event.sender!;
if (!this.avatars.has(member.userId)) {
try {
const avatarUrl = this.getAvatarURL(event);
this.avatars.set(member.userId, true);
const image = await fetch(avatarUrl!);
const blob = await image.blob();
this.addFile(`users/${member.userId.replace(/:/g, "-")}.png`, blob);
} catch (err) {
logger.log("Failed to fetch user's avatar" + err);
}
}
}
protected getDateSeparator(event: MatrixEvent): string {
const ts = event.getTs();
const dateSeparatorViewModel = new DateSeparatorViewModel({
roomId: event.getRoomId()!,
ts,
forExport: true,
});
try {
const dateSeparator = (
<li key={ts}>
<DateSeparatorView vm={dateSeparatorViewModel} className="mx_TimelineSeparator" />
</li>
);
return this.renderToStaticMarkupWithProviders(dateSeparator);
} finally {
dateSeparatorViewModel.dispose();
}
}
protected needsDateSeparator(event: MatrixEvent, prevEvent: MatrixEvent | null): boolean {
if (!prevEvent) return true;
return wantsDateSeparator(prevEvent.getDate() || undefined, event.getDate() || undefined);
}
public getEventTile(mxEv: MatrixEvent, continuation: boolean, ref?: () => void): JSX.Element {
return (
<div className="mx_Export_EventWrapper" id={mxEv.getId()}>
{/* Export rendering uses an isolated root, so provide I18nContext explicitly. */}
<I18nContext.Provider value={window.mxModuleApi.i18n}>
<MatrixClientContext.Provider value={this.room.client}>
<SDKContext.Provider value={SdkContextClass.instance}>
<TooltipProvider>
<EventTile
mxEvent={mxEv}
continuation={continuation}
isRedacted={mxEv.isRedacted()}
replacingEventId={mxEv.replacingEventId()}
forExport={true}
alwaysShowTimestamps={true}
showUrlPreview={false}
checkUnmounting={() => false}
isTwelveHour={false}
last={false}
lastInSection={false}
permalinkCreator={this.permalinkCreator}
lastSuccessful={false}
isSelectedEvent={false}
showReactions={true}
layout={Layout.Group}
showReadReceipts={false}
getRelationsForEvent={this.getRelationsForEvent}
ref={ref}
/>
</TooltipProvider>
</SDKContext.Provider>
</MatrixClientContext.Provider>
</I18nContext.Provider>
</div>
);
}
protected async getEventTileMarkup(mxEv: MatrixEvent, continuation: boolean, filePath?: string): Promise<string> {
const avatarUrl = this.getAvatarURL(mxEv);
const hasAvatar = !!avatarUrl;
if (hasAvatar) await this.saveAvatarIfNeeded(mxEv);
// We have to wait for the component to be rendered before we can get the markup
// so pass a deferred as a ref to the component.
const deferred = Promise.withResolvers<void>();
const EventTile = this.getEventTile(mxEv, continuation, deferred.resolve);
let eventTileMarkup: string;
if (
mxEv.getContent().msgtype == MsgType.Emote ||
mxEv.getContent().msgtype == MsgType.Notice ||
mxEv.getContent().msgtype === MsgType.Text
) {
// to linkify textual events, we'll need lifecycle methods which won't be invoked in renderToString
// So, we'll have to render the component into a temporary root element
const tempElement = document.createElement("div");
const tempRoot = createRoot(tempElement);
tempRoot.render(EventTile);
await deferred.promise;
eventTileMarkup = tempElement.innerHTML;
tempRoot.unmount();
} else {
eventTileMarkup = this.renderToStaticMarkupWithProviders(EventTile);
}
if (filePath) {
const mxc = mxEv.getContent().url ?? mxEv.getContent().file?.url;
eventTileMarkup = eventTileMarkup.split(mxc).join(filePath);
}
eventTileMarkup = eventTileMarkup.replace(/<span class="mx_MFileBody".*?>.*?<\/span>/, "");
if (hasAvatar) {
eventTileMarkup = eventTileMarkup.replace(
encodeURI(avatarUrl).replace(/&/g, "&amp;"),
`users/${mxEv.sender!.userId.replace(/:/g, "-")}.png`,
);
}
return eventTileMarkup;
}
protected createModifiedEvent(text: string, mxEv: MatrixEvent, italic = true): MatrixEvent {
const modifiedContent = {
msgtype: MsgType.Text,
body: `${text}`,
format: "org.matrix.custom.html",
formatted_body: `${text}`,
};
if (italic) {
modifiedContent.formatted_body = "<em>" + modifiedContent.formatted_body + "</em>";
modifiedContent.body = "*" + modifiedContent.body + "*";
}
const modifiedEvent = new MatrixEvent();
modifiedEvent.event = mxEv.event;
modifiedEvent.sender = mxEv.sender;
modifiedEvent.event.type = "m.room.message";
modifiedEvent.event.content = modifiedContent;
return modifiedEvent;
}
protected async createMessageBody(mxEv: MatrixEvent, joined = false): Promise<string> {
let eventTile: string;
try {
if (this.isAttachment(mxEv)) {
if (this.exportOptions.attachmentsIncluded) {
try {
const blob = await this.getMediaBlob(mxEv);
if (this.totalSize + blob.size > this.exportOptions.maxSize) {
eventTile = await this.getEventTileMarkup(
this.createModifiedEvent(this.mediaOmitText, mxEv),
joined,
);
} else {
this.totalSize += blob.size;
const filePath = this.getFilePath(mxEv);
eventTile = await this.getEventTileMarkup(mxEv, joined, filePath);
if (this.totalSize == this.exportOptions.maxSize) {
this.exportOptions.attachmentsIncluded = false;
}
this.addFile(filePath, blob);
}
} catch (e) {
logger.log("Error while fetching file" + e);
eventTile = await this.getEventTileMarkup(
this.createModifiedEvent(_t("export_chat|error_fetching_file"), mxEv),
joined,
);
}
} else {
eventTile = await this.getEventTileMarkup(
this.createModifiedEvent(this.mediaOmitText, mxEv),
joined,
);
}
} else {
eventTile = await this.getEventTileMarkup(mxEv, joined);
}
} catch (e) {
// TODO: Handle callEvent errors
logger.error(e);
eventTile = await this.getEventTileMarkup(
this.createModifiedEvent(textForEvent(mxEv, this.room.client), mxEv, false),
joined,
);
}
return eventTile;
}
protected async createHTML(
events: MatrixEvent[],
start: number,
currentPage: number,
nbPages: number,
): Promise<string> {
let content = "";
let prevEvent: MatrixEvent | null = null;
for (let i = start; i < Math.min(start + 1000, events.length); i++) {
const event = events[i];
this.updateProgress(
_t("export_chat|processing_event_n", {
number: i + 1,
total: events.length,
}),
false,
true,
);
if (this.cancelled) return this.cleanUp();
if (!haveRendererForEvent(event, this.room.client, false)) continue;
content += this.needsDateSeparator(event, prevEvent) ? this.getDateSeparator(event) : "";
const shouldBeJoined =
!this.needsDateSeparator(event, prevEvent) &&
shouldFormContinuation(prevEvent, event, this.room.client, false);
const body = await this.createMessageBody(event, shouldBeJoined);
this.totalSize += new TextEncoder().encode(body).byteLength;
content += body;
prevEvent = event;
}
return this.wrapHTML(content, currentPage, nbPages);
}
public async export(): Promise<void> {
this.updateProgress(_t("export_chat|starting_export"));
const fetchStart = performance.now();
const res = await this.getRequiredEvents();
const fetchEnd = performance.now();
this.updateProgress(
_t("export_chat|fetched_n_events_in_time", {
count: res.length,
seconds: (fetchEnd - fetchStart) / 1000,
}),
true,
false,
);
this.updateProgress(_t("export_chat|creating_html"));
const usedClasses = new Set<string>();
for (let page = 0; page < res.length / 1000; page++) {
const html = await this.createHTML(res, page * 1000, page, res.length / 1000);
const document = new DOMParser().parseFromString(html, "text/html");
document.querySelectorAll("*").forEach((element) => {
element.classList.forEach((c) => usedClasses.add(c));
});
this.addFile(`messages${page ? page + 1 : ""}.html`, new Blob([html]));
}
const exportCSS = await getExportCSS(usedClasses);
this.addFile("css/style.css", new Blob([exportCSS]));
this.addFile("js/script.js", new Blob([exportJS]));
await this.downloadZIP();
const exportEnd = performance.now();
if (this.cancelled) {
logger.info("Export cancelled successfully");
} else {
this.updateProgress(_t("export_chat|export_successful"));
this.updateProgress(
_t("export_chat|exported_n_events_in_time", {
count: res.length,
seconds: (exportEnd - fetchStart) / 1000,
}),
);
}
this.cleanUp();
}
}
@@ -0,0 +1,122 @@
/*
Copyright 2024 New Vector Ltd.
Copyright 2021, 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 Room, type IEvent, type MatrixEvent, EventType } from "matrix-js-sdk/src/matrix";
import { logger } from "matrix-js-sdk/src/logger";
import Exporter from "./Exporter";
import { formatFullDateNoDayNoTime } from "../../DateUtils";
import { type ExportType, type IExportOptions } from "./exportUtils";
import { _t } from "../../languageHandler";
import { haveRendererForEvent } from "../../events/EventTileFactory";
export default class JSONExporter extends Exporter {
protected totalSize = 0;
protected messages: Record<string, any>[] = [];
public constructor(
room: Room,
exportType: ExportType,
exportOptions: IExportOptions,
setProgressText: React.Dispatch<React.SetStateAction<string>>,
) {
super(room, exportType, exportOptions, setProgressText);
}
public get destinationFileName(): string {
return this.makeFileNameNoExtension() + ".json";
}
protected createJSONString(): string {
const exportDate = formatFullDateNoDayNoTime(new Date());
const creator = this.room.currentState.getStateEvents(EventType.RoomCreate, "")?.getSender();
const creatorName = (creator && this.room?.getMember(creator)?.rawDisplayName) || creator;
const topic = this.room.currentState.getStateEvents(EventType.RoomTopic, "")?.getContent()?.topic || "";
const exporter = this.room.client.getUserId()!;
const exporterName = this.room?.getMember(exporter)?.rawDisplayName || exporter;
const jsonObject = {
room_name: this.room.name,
room_creator: creatorName,
topic,
export_date: exportDate,
exported_by: exporterName,
messages: this.messages,
};
return JSON.stringify(jsonObject, null, 2);
}
protected async getJSONString(mxEv: MatrixEvent): Promise<IEvent> {
if (this.exportOptions.attachmentsIncluded && this.isAttachment(mxEv)) {
try {
const blob = await this.getMediaBlob(mxEv);
if (this.totalSize + blob.size < this.exportOptions.maxSize) {
this.totalSize += blob.size;
const filePath = this.getFilePath(mxEv);
if (this.totalSize == this.exportOptions.maxSize) {
this.exportOptions.attachmentsIncluded = false;
}
this.addFile(filePath, blob);
}
} catch (err) {
logger.log("Error fetching file: " + err);
}
}
return mxEv.getEffectiveEvent();
}
protected async createOutput(events: MatrixEvent[]): Promise<string> {
for (let i = 0; i < events.length; i++) {
const event = events[i];
this.updateProgress(
_t("export_chat|processing_event_n", {
number: i + 1,
total: events.length,
}),
false,
true,
);
if (this.cancelled) return this.cleanUp();
if (!haveRendererForEvent(event, this.room.client, false)) continue;
this.messages.push(await this.getJSONString(event));
}
return this.createJSONString();
}
public async export(): Promise<void> {
logger.info("Starting export process...");
logger.info("Fetching events...");
const fetchStart = performance.now();
const res = await this.getRequiredEvents();
const fetchEnd = performance.now();
logger.log(`Fetched ${res.length} events in ${(fetchEnd - fetchStart) / 1000}s`);
logger.info("Creating output...");
const text = await this.createOutput(res);
if (this.files.length) {
this.addFile("export.json", new Blob([text]));
await this.downloadZIP();
} else {
const fileName = this.destinationFileName;
this.downloadPlainText(fileName, text);
}
const exportEnd = performance.now();
if (this.cancelled) {
logger.info("Export cancelled successfully");
} else {
logger.info("Export successful!");
logger.log(`Exported ${res.length} events in ${(exportEnd - fetchStart) / 1000} seconds`);
}
this.cleanUp();
}
}
@@ -0,0 +1,160 @@
/*
Copyright 2024 New Vector Ltd.
Copyright 2021, 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 Room, type IContent, type MatrixEvent } from "matrix-js-sdk/src/matrix";
import { logger } from "matrix-js-sdk/src/logger";
import type React from "react";
import Exporter from "./Exporter";
import { _t } from "../../languageHandler";
import { type ExportType, type IExportOptions } from "./exportUtils";
import { textForEvent } from "../../TextForEvent";
import { haveRendererForEvent } from "../../events/EventTileFactory";
import SettingsStore from "../../settings/SettingsStore";
import { formatFullDate } from "../../DateUtils";
export default class PlainTextExporter extends Exporter {
protected totalSize: number;
protected mediaOmitText: string;
public constructor(
room: Room,
exportType: ExportType,
exportOptions: IExportOptions,
setProgressText: React.Dispatch<React.SetStateAction<string>>,
) {
super(room, exportType, exportOptions, setProgressText);
this.totalSize = 0;
this.mediaOmitText = !this.exportOptions.attachmentsIncluded
? _t("export_chat|media_omitted")
: _t("export_chat|media_omitted_file_size");
}
public get destinationFileName(): string {
return this.makeFileNameNoExtension() + ".txt";
}
public textForReplyEvent = (content: IContent): string => {
const REPLY_REGEX = /> <(.*?)>(.*?)\n\n(.*)/s;
const REPLY_SOURCE_MAX_LENGTH = 32;
const match = REPLY_REGEX.exec(content.body);
// if the reply format is invalid, then return the body
if (!match) return content.body;
let rplSource: string;
const rplName = match[1];
const rplText = match[3];
rplSource = match[2].substring(1);
// Get the first non-blank line from the source.
const lines = rplSource.split("\n").filter((line) => !/^\s*$/.test(line));
if (lines.length > 0) {
// Cut to a maximum length.
rplSource = lines[0].substring(0, REPLY_SOURCE_MAX_LENGTH);
// Ellipsis if needed.
if (lines[0].length > REPLY_SOURCE_MAX_LENGTH) {
rplSource = rplSource + "...";
}
// Wrap in formatting
rplSource = ` "${rplSource}"`;
} else {
// Don't show a source because we couldn't format one.
rplSource = "";
}
return `<${rplName}${rplSource}> ${rplText}`;
};
protected plainTextForEvent = async (mxEv: MatrixEvent): Promise<string> => {
const senderDisplayName = mxEv.sender && mxEv.sender.name ? mxEv.sender.name : mxEv.getSender();
let mediaText = "";
if (this.isAttachment(mxEv)) {
if (this.exportOptions.attachmentsIncluded) {
try {
const blob = await this.getMediaBlob(mxEv);
if (this.totalSize + blob.size > this.exportOptions.maxSize) {
mediaText = ` (${this.mediaOmitText})`;
} else {
this.totalSize += blob.size;
const filePath = this.getFilePath(mxEv);
mediaText = " (" + _t("export_chat|file_attached") + ")";
this.addFile(filePath, blob);
if (this.totalSize == this.exportOptions.maxSize) {
this.exportOptions.attachmentsIncluded = false;
}
}
} catch (error) {
mediaText = " (" + _t("export_chat|error_fetching_file") + ")";
logger.log("Error fetching file " + error);
}
} else mediaText = ` (${this.mediaOmitText})`;
}
if (this.isReply(mxEv)) return senderDisplayName + ": " + this.textForReplyEvent(mxEv.getContent()) + mediaText;
else return textForEvent(mxEv, this.room.client) + mediaText;
};
protected async createOutput(events: MatrixEvent[]): Promise<string> {
let content = "";
for (let i = 0; i < events.length; i++) {
const event = events[i];
this.updateProgress(
_t("export_chat|processing_event_n", {
number: i + 1,
total: events.length,
}),
false,
true,
);
if (this.cancelled) return this.cleanUp();
if (!haveRendererForEvent(event, this.room.client, false)) continue;
const textForEvent = await this.plainTextForEvent(event);
content +=
textForEvent &&
`${formatFullDate(
new Date(event.getTs()),
SettingsStore.getValue("showTwelveHourTimestamps"),
)} - ${textForEvent}\n`;
}
return content;
}
public async export(): Promise<void> {
this.updateProgress(_t("export_chat|starting_export"));
this.updateProgress(_t("export_chat|fetching_events"));
const fetchStart = performance.now();
const res = await this.getRequiredEvents();
const fetchEnd = performance.now();
logger.log(`Fetched ${res.length} events in ${(fetchEnd - fetchStart) / 1000}s`);
this.updateProgress(_t("export_chat|creating_output"));
const text = await this.createOutput(res);
if (this.files.length) {
this.addFile("export.txt", new Blob([text]));
await this.downloadZIP();
} else {
const fileName = this.destinationFileName;
this.downloadPlainText(fileName, text);
}
const exportEnd = performance.now();
if (this.cancelled) {
logger.info("Export cancelled successfully");
} else {
logger.info("Export successful!");
logger.log(`Exported ${res.length} events in ${(exportEnd - fetchStart) / 1000} seconds`);
}
this.cleanUp();
}
}
@@ -0,0 +1,99 @@
/*
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 { CssNode, Rule, StyleSheet } from "css-tree";
import customCSS from "!!raw-loader!./exportCustomCSS.css";
const cssSelectorTextClassesRegex = /\.[\w-]+/g;
const appLayerName = "app-web";
function mutateCssText(css: string): string {
// replace used fonts so that we don't have to bundle Inter & Fira Code
const sansFont = `-apple-system, BlinkMacSystemFont, avenir next,
avenir, segoe ui, helvetica neue, helvetica, Ubuntu, roboto, noto, arial, sans-serif`;
return css
.replace(/font-family: ?(Inter|'Inter'|"Inter")/g, `font-family: ${sansFont}`)
.replace(/--cpd-font-family-sans: ?(Inter|'Inter'|"Inter")/g, `--cpd-font-family-sans: ${sansFont}`)
.replace(
/font-family: ?Fira Code/g,
"font-family: Menlo, Consolas, Monaco, Liberation Mono, Lucida Console, monospace",
);
}
function includeRule(rule: Rule, usedClasses: Set<string>): boolean {
if (rule.prelude.type === "Raw") {
// cull empty rules
if (rule.block.children.isEmpty) return false;
return rule.prelude.value.split(",").some((subselector) => {
const classes = subselector.trim().match(cssSelectorTextClassesRegex);
if (classes && !classes.every((c) => usedClasses.has(c.substring(1)))) {
return false;
}
return true;
});
}
return true;
}
function includeNode(node: CssNode, usedClasses: Set<string>): boolean {
if (node.type === "Atrule") {
if (node.name === "font-face") {
return false;
}
if (node.block) {
node.block.children = node.block.children.filter((child) => includeNode(child, usedClasses));
return !node.block.children.isEmpty;
}
}
return node.type !== "Rule" || includeRule(node, usedClasses);
}
// naively culls unused css rules based on which classes are present in the html,
// doesn't cull rules which won't apply due to the full selector not matching but gets rid of a LOT of cruft anyway.
// We cannot use document.styleSheets as it does not handle variables in shorthand properties sanely,
// see https://github.com/element-hq/element-web/issues/26761
const getExportCSS = async (usedClasses: Set<string>): Promise<string> => {
const csstree = await import("css-tree");
// only include bundle.css and light theme styling
const hrefs = ["bundle.css", "theme-light.css"].map((name) => {
return document.querySelector<HTMLLinkElement>(`link[rel="stylesheet"][href$="${name}"]`)?.href;
});
let css = "";
for (const href of hrefs) {
if (!href) continue;
const res = await fetch(href);
const text = await res.text();
const ast = csstree.parse(text, {
context: "stylesheet",
parseAtrulePrelude: false,
parseRulePrelude: false,
parseValue: false,
parseCustomProperty: false,
}) as StyleSheet;
for (const rule of ast.children) {
if (!includeNode(rule, usedClasses)) {
continue;
}
css += mutateCssText(csstree.generate(rule));
}
}
return `${css}@layer ${appLayerName} {${customCSS}}`;
};
export default getExportCSS;
@@ -0,0 +1,153 @@
/*
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.
*/
/*
This file is raw-imported (imported as plain text) for the export bundle, which is the reason for the .css format and the colours being hard-coded hard-coded.
*/
html,
body {
font-size: var(--cpd-font-size-root) !important;
}
#snackbar {
display: flex;
visibility: hidden;
min-width: 250px;
margin-left: -125px;
background-color: #333;
color: #fff;
text-align: center;
position: fixed;
z-index: 1;
left: 50%;
bottom: 30px;
font-size: 17px;
padding: 6px 16px;
font-family:
-apple-system,
BlinkMacSystemFont,
avenir next,
avenir,
segoe ui,
helvetica neue,
helvetica,
Ubuntu,
roboto,
noto,
arial,
sans-serif;
font-weight: 400;
line-height: 1.43;
border-radius: 4px;
letter-spacing: 0.01071em;
}
#snackbar.mx_show {
visibility: visible;
-webkit-animation:
mx_snackbar_fadein 0.5s,
mx_snackbar_fadeout 0.5s 2.5s;
animation:
mx_snackbar_fadein 0.5s,
mx_snackbar_fadeout 0.5s 2.5s;
}
a.mx_reply_anchor {
cursor: pointer;
}
@-webkit-keyframes mx_snackbar_fadein {
from {
bottom: 0;
opacity: 0;
}
to {
bottom: 30px;
opacity: 1;
}
}
@keyframes mx_snackbar_fadein {
from {
bottom: 0;
opacity: 0;
}
to {
bottom: 30px;
opacity: 1;
}
}
@-webkit-keyframes mx_snackbar_fadeout {
from {
bottom: 30px;
opacity: 1;
}
to {
bottom: 0;
opacity: 0;
}
}
@keyframes mx_snackbar_fadeout {
from {
bottom: 30px;
opacity: 1;
}
to {
bottom: 0;
opacity: 0;
}
}
* {
scroll-behavior: smooth !important;
}
.mx_Export_EventWrapper:target {
background: white;
animation: mx_event_highlight_animation 2s linear;
}
@keyframes mx_event_highlight_animation {
0%,
100% {
background: white;
}
50% {
background: #e3e2df;
}
}
.mx_RoomHeader {
display: flex;
flex-direction: row;
align-items: center;
justify-content: start;
gap: var(--cpd-space-3x);
}
.mx_ReplyChain_Export {
margin-top: 0;
margin-bottom: 5px;
}
.mx_RedactedBody,
.mx_HiddenBody {
padding-left: unset;
}
img {
white-space: nowrap;
overflow: hidden;
}
.mx_MatrixChat {
max-width: 100%;
}
@@ -0,0 +1,33 @@
/*
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.
*/
// This file is raw-imported (imported as plain text) for the export bundle, which is why this is in JS
function showToastIfNeeded(replyId) {
const el = document.getElementById(replyId);
if (!el) {
showToast("The message you're looking for wasn't exported");
return;
}
}
function showToast(text) {
const el = document.getElementById("snackbar");
el.innerHTML = text;
el.className = "mx_show";
window.setTimeout(() => {
el.className = el.className.replace("mx_show", "");
}, 2000);
}
window.onload = () => {
document.querySelectorAll(".mx_reply_anchor").forEach((element) => {
element.addEventListener("click", (event) => {
showToastIfNeeded(event.target.dataset.scrollTo);
});
});
};
@@ -0,0 +1,61 @@
/*
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 { _t } from "../../languageHandler";
export enum ExportFormat {
Html = "Html",
PlainText = "PlainText",
Json = "Json",
}
export type ExportFormatKey = "Html" | "PlainText" | "Json";
export enum ExportType {
Timeline = "Timeline",
Beginning = "Beginning",
LastNMessages = "LastNMessages",
// START_DATE = "START_DATE",
}
export type ExportTypeKey = "Timeline" | "Beginning" | "LastNMessages";
export const textForFormat = (format: ExportFormat): string => {
switch (format) {
case ExportFormat.Html:
return _t("export_chat|html");
case ExportFormat.Json:
return _t("export_chat|json");
case ExportFormat.PlainText:
return _t("export_chat|text");
default:
throw new Error("Unknown format");
}
};
export const textForType = (type: ExportType): string => {
switch (type) {
case ExportType.Beginning:
return _t("export_chat|from_the_beginning");
case ExportType.LastNMessages:
return _t("export_chat|number_of_messages");
case ExportType.Timeline:
return _t("export_chat|current_timeline");
default:
throw new Error("Unknown type: " + type);
// case exportTypes.START_DATE:
// return _t("From a specific date");
}
};
export interface IExportOptions {
// startDate?: number;
numberOfMessages?: number;
attachmentsIncluded: boolean;
maxSize: number;
}
+17
View File
@@ -0,0 +1,17 @@
/*
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.
*/
import type React from "react";
/**
* onSubmit handler which calls preventDefault and stopPropagation on the event
* @param e submit event
*/
export function onSubmitPreventDefault(e: SubmitEvent | React.SubmitEvent): void {
e.preventDefault();
e.stopPropagation();
}
+54
View File
@@ -0,0 +1,54 @@
/*
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 Room } from "matrix-js-sdk/src/matrix";
import SpaceStore from "../stores/spaces/SpaceStore";
import { _t } from "../languageHandler";
import DMRoomMap from "./DMRoomMap";
import { formatList } from "./FormattingUtils";
export interface RoomContextDetails {
details: string | null;
ariaLabel?: string;
}
export function roomContextDetails(room: Room): RoomContextDetails | null {
const dmPartner = DMRoomMap.shared().getUserIdForRoomId(room.roomId);
// if weve got more than 2 users, dont treat it like a regular DM
const isGroupDm = room.getMembers().length > 2;
if (!room.isSpaceRoom() && dmPartner && !isGroupDm) {
return { details: dmPartner };
}
const [parent, secondParent, ...otherParents] = SpaceStore.instance.getKnownParents(room.roomId);
if (secondParent && !otherParents?.length) {
// exactly 2 edge case for improved i18n
const space1Name = room.client.getRoom(parent)?.name;
const space2Name = room.client.getRoom(secondParent)?.name;
return {
details: formatList([space1Name ?? "", space2Name ?? ""]),
ariaLabel: _t("in_space1_and_space2", { space1Name, space2Name }),
};
} else if (parent) {
const spaceName = room.client.getRoom(parent)?.name ?? "";
const count = otherParents.length;
if (count > 0) {
return {
details: formatList([spaceName, ...otherParents], 1),
ariaLabel: _t("in_space_and_n_other_spaces", { spaceName, count }),
};
}
return {
details: spaceName,
ariaLabel: _t("in_space", { spaceName }),
};
}
return { details: room.getCanonicalAlias() };
}
+105
View File
@@ -0,0 +1,105 @@
/*
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 ImageInfo } from "matrix-js-sdk/src/types";
import { BlurhashEncoder } from "../BlurhashEncoder";
type ThumbnailableElement = HTMLImageElement | HTMLVideoElement;
export const BLURHASH_FIELD = "xyz.amorgan.blurhash"; // MSC2448
interface IThumbnail {
info: ImageInfo;
thumbnail: Blob;
}
const MAX_WIDTH = 800;
const MAX_HEIGHT = 600;
/**
* Create a thumbnail for a image DOM element.
* The image will be smaller than MAX_WIDTH and MAX_HEIGHT.
* The thumbnail will have the same aspect ratio as the original.
* Draws the element into a canvas using CanvasRenderingContext2D.drawImage
* Then calls Canvas.toBlob to get a blob object for the image data.
*
* Since it needs to calculate the dimensions of the source image and the
* thumbnailed image it returns an info object filled out with information
* about the original image and the thumbnail.
*
* @param {HTMLElement} element The element to thumbnail.
* @param {number} inputWidth The width of the image in the input element.
* @param {number} inputHeight the width of the image in the input element.
* @param {string} mimeType The mimeType to save the blob as.
* @param {boolean} calculateBlurhash Whether to calculate a blurhash of the given image too.
* @return {Promise} A promise that resolves with an object with an info key
* and a thumbnail key.
*/
export async function createThumbnail(
element: ThumbnailableElement,
inputWidth: number,
inputHeight: number,
mimeType: string,
calculateBlurhash = true,
): Promise<IThumbnail> {
let targetWidth = inputWidth;
let targetHeight = inputHeight;
if (targetHeight > MAX_HEIGHT) {
targetWidth = Math.floor(targetWidth * (MAX_HEIGHT / targetHeight));
targetHeight = MAX_HEIGHT;
}
if (targetWidth > MAX_WIDTH) {
targetHeight = Math.floor(targetHeight * (MAX_WIDTH / targetWidth));
targetWidth = MAX_WIDTH;
}
let canvas: HTMLCanvasElement | OffscreenCanvas;
let context: CanvasRenderingContext2D | OffscreenCanvasRenderingContext2D;
try {
canvas = new window.OffscreenCanvas(targetWidth, targetHeight);
context = canvas.getContext("2d") as OffscreenCanvasRenderingContext2D;
} catch {
// Fallback support for other browsers (Safari and Firefox for now)
canvas = document.createElement("canvas");
canvas.width = targetWidth;
canvas.height = targetHeight;
context = canvas.getContext("2d")!;
}
context.drawImage(element, 0, 0, targetWidth, targetHeight);
let thumbnailPromise: Promise<Blob>;
if (window.OffscreenCanvas && canvas instanceof OffscreenCanvas) {
thumbnailPromise = canvas.convertToBlob({ type: mimeType });
} else {
thumbnailPromise = new Promise<Blob>((resolve) =>
(canvas as HTMLCanvasElement).toBlob(resolve as BlobCallback, mimeType),
);
}
const imageData = context.getImageData(0, 0, targetWidth, targetHeight);
// thumbnailPromise and blurhash promise are being awaited concurrently
const blurhash = calculateBlurhash ? await BlurhashEncoder.instance.getBlurhash(imageData) : undefined;
const thumbnail = await thumbnailPromise;
return {
info: {
thumbnail_info: {
w: targetWidth,
h: targetHeight,
mimetype: thumbnail.type,
size: thumbnail.size,
},
w: inputWidth,
h: inputHeight,
[BLURHASH_FIELD]: blurhash,
},
thumbnail,
};
}

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