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
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:
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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 = "";
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -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>;
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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>;
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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
|
||||
});
|
||||
};
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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,
|
||||
},
|
||||
"*",
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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)];
|
||||
}),
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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"];
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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" />;
|
||||
}
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
@@ -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();
|
||||
},
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
};
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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),
|
||||
};
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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];
|
||||
}
|
||||
@@ -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 = [];
|
||||
}
|
||||
}
|
||||
@@ -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>;
|
||||
}
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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[];
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
};
|
||||
@@ -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();
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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";
|
||||
@@ -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());
|
||||
@@ -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,
|
||||
};
|
||||
};
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
};
|
||||
};
|
||||
@@ -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
|
||||
};
|
||||
};
|
||||
@@ -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() ?? []);
|
||||
}
|
||||
@@ -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),
|
||||
};
|
||||
};
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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;
|
||||
}
|
||||
};
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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()),
|
||||
};
|
||||
};
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
@@ -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, "&"),
|
||||
`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;
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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 we’ve got more than 2 users, don’t 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() };
|
||||
}
|
||||
@@ -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
Reference in New Issue
Block a user