Merge branch 'develop' of github.com:matrix-org/matrix-react-sdk into t3chguy/querystring

 Conflicts:
	package.json
	src/@types/global.d.ts
	src/components/views/elements/AppTile.js
	src/utils/HostingLink.js
	yarn.lock
This commit is contained in:
Michael Telatynski 2021-07-16 12:47:33 +01:00
commit 5dbd79c729
1950 changed files with 174795 additions and 76807 deletions

View file

@ -0,0 +1,32 @@
/*
Copyright 2021 Šimon Brandner <simon.bra.ag@gmail.com>
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
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) {
amt = clamp(amt, 0, 1);
return (1 - amt) * start + amt * end;
}

View file

@ -1,6 +1,5 @@
/*
Copyright 2019 New Vector Ltd
Copyright 2019 The Matrix.org Foundation C.I.C.
Copyright 2019-2021 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
@ -15,13 +14,13 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import React from 'react';
import {AutoDiscovery} from "matrix-js-sdk";
import {_t, _td, newTranslatableError} from "../languageHandler";
import {makeType} from "./TypeUtils";
import React, { ReactNode } from 'react';
import { AutoDiscovery } from "matrix-js-sdk/src/autodiscovery";
import { _t, _td, newTranslatableError } from "../languageHandler";
import { makeType } from "./TypeUtils";
import SdkConfig from '../SdkConfig';
const LIVELINESS_DISCOVERY_ERRORS = [
const LIVELINESS_DISCOVERY_ERRORS: string[] = [
AutoDiscovery.ERROR_INVALID_HOMESERVER,
AutoDiscovery.ERROR_INVALID_IDENTITY_SERVER,
];
@ -34,21 +33,29 @@ export class ValidatedServerConfig {
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;
}
export interface IAuthComponentState {
serverIsAlive: boolean;
serverErrorIsFatal: boolean;
serverDeadError?: ReactNode;
}
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
* @param {string | Error} error The error to check
* @returns {boolean} True if the error is a liveliness error.
*/
static isLivelinessError(error: string|Error): boolean {
static isLivelinessError(error: string | Error): boolean {
if (!error) return false;
return !!LIVELINESS_DISCOVERY_ERRORS.find(e => e === error || e === error.message);
return !!LIVELINESS_DISCOVERY_ERRORS.find(e => typeof error === "string" ? e === error : e === error.message);
}
/**
@ -59,7 +66,7 @@ export default class AutoDiscoveryUtils {
* implementation for known values.
* @returns {*} The state for the component, given the error.
*/
static authComponentStateForError(err: string | Error | null, pageName = "login"): Object {
static authComponentStateForError(err: string | Error | null, pageName = "login"): IAuthComponentState {
if (!err) {
return {
serverIsAlive: true,
@ -68,15 +75,19 @@ export default class AutoDiscoveryUtils {
};
}
let title = _t("Cannot reach homeserver");
let body = _t("Ensure you have a stable internet connection, or get in touch with the server admin");
let body: ReactNode = _t("Ensure you have a stable internet connection, or get in touch with the server admin");
if (!AutoDiscoveryUtils.isLivelinessError(err)) {
title = _t("Your Riot is misconfigured");
const brand = SdkConfig.get().brand;
title = _t("Your %(brand)s is misconfigured", { brand });
body = _t(
"Ask your Riot admin to check <a>your config</a> for incorrect or duplicate entries.",
{}, {
"Ask your %(brand)s admin to check <a>your config</a> for incorrect or duplicate entries.",
{
brand,
},
{
a: (sub) => {
return <a
href="https://github.com/vector-im/riot-web/blob/master/docs/config.md"
href="https://github.com/vector-im/element-web/blob/master/docs/config.md"
target="_blank"
rel="noreferrer noopener"
>{sub}</a>;
@ -86,7 +97,7 @@ export default class AutoDiscoveryUtils {
}
let isFatalError = true;
const errorMessage = err.message ? err.message : err;
const errorMessage = typeof err === "string" ? err : err.message;
if (errorMessage === AutoDiscovery.ERROR_INVALID_IDENTITY_SERVER) {
isFatalError = false;
title = _t("Cannot reach identity server");
@ -135,7 +146,10 @@ export default class AutoDiscoveryUtils {
* @returns {Promise<ValidatedServerConfig>} Resolves to the validated configuration.
*/
static async validateServerConfigWithStaticUrls(
homeserverUrl: string, identityUrl: string, syntaxOnly = false): ValidatedServerConfig {
homeserverUrl: string,
identityUrl?: string,
syntaxOnly = false,
): Promise<ValidatedServerConfig> {
if (!homeserverUrl) {
throw newTranslatableError(_td("No homeserver URL provided"));
}
@ -157,7 +171,7 @@ export default class AutoDiscoveryUtils {
const url = new URL(homeserverUrl);
const serverName = url.hostname;
return AutoDiscoveryUtils.buildValidatedConfigFromDiscovery(serverName, result, syntaxOnly);
return AutoDiscoveryUtils.buildValidatedConfigFromDiscovery(serverName, result, syntaxOnly, true);
}
/**
@ -165,7 +179,7 @@ export default class AutoDiscoveryUtils {
* @param {string} serverName The homeserver domain name (eg: "matrix.org") to validate.
* @returns {Promise<ValidatedServerConfig>} Resolves to the validated configuration.
*/
static async validateServerName(serverName: string): ValidatedServerConfig {
static async validateServerName(serverName: string): Promise<ValidatedServerConfig> {
const result = await AutoDiscovery.findClientConfig(serverName);
return AutoDiscoveryUtils.buildValidatedConfigFromDiscovery(serverName, result);
}
@ -175,12 +189,12 @@ export default class AutoDiscoveryUtils {
* 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} 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.
*/
static buildValidatedConfigFromDiscovery(
serverName: string, discoveryResult, syntaxOnly=false): ValidatedServerConfig {
serverName: string, discoveryResult, syntaxOnly=false, isSynthetic=false): ValidatedServerConfig {
if (!discoveryResult || !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 codee but otherwise tell teh user "it broke".
@ -199,7 +213,7 @@ export default class AutoDiscoveryUtils {
// 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 Riot.
// of Element.
let preferredIdentityUrl = defaultConfig && defaultConfig['isUrl'];
if (isResult && isResult.state === AutoDiscovery.SUCCESS) {
preferredIdentityUrl = isResult["base_url"];
@ -248,6 +262,7 @@ export default class AutoDiscoveryUtils {
isUrl: preferredIdentityUrl,
isDefault: false,
warning: hsResult.error,
isNameResolvable: !isSynthetic,
});
}
}

View file

@ -1,6 +1,5 @@
/*
Copyright 2016 OpenMarket Ltd
Copyright 2019, 2020 The Matrix.org Foundation C.I.C.
Copyright 2016, 2019, 2021 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
@ -15,9 +14,11 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import {MatrixClientPeg} from '../MatrixClientPeg';
import _uniq from 'lodash/uniq';
import {Room} from "matrix-js-sdk/src/matrix";
import { uniq } from "lodash";
import { Room } from "matrix-js-sdk/src/models/room";
import { MatrixClient } from "matrix-js-sdk/src/client";
import { MatrixClientPeg } from '../MatrixClientPeg';
/**
* Class that takes a Matrix Client and flips the m.direct map
@ -27,29 +28,38 @@ import {Room} from "matrix-js-sdk/src/matrix";
* With 'start', this can also keep itself up to date over time.
*/
export default class DMRoomMap {
constructor(matrixClient) {
this.matrixClient = matrixClient;
this.roomToUser = null;
// see _onAccountData
this._hasSentOutPatchDirectAccountDataPatch = false;
private static sharedInstance: DMRoomMap;
// XXX: Force-bind the event handler method because it
// doesn't call it with our object as the 'this'
// (use a static property arrow function for this when we can)
this._onAccountData = this._onAccountData.bind(this);
// TODO: convert these to maps
private roomToUser: {[key: string]: string} = null;
private userToRooms: {[key: string]: string[]} = null;
private hasSentOutPatchDirectAccountDataPatch: boolean;
private mDirectEvent: object;
constructor(private readonly matrixClient: MatrixClient) {
// see onAccountData
this.hasSentOutPatchDirectAccountDataPatch = false;
const mDirectEvent = matrixClient.getAccountData('m.direct');
this.mDirectEvent = mDirectEvent ? mDirectEvent.getContent() : {};
this.userToRooms = null;
}
/**
* Makes and returns a new shared instance that can then be accessed
* with shared(). This returned instance is not automatically started.
*/
static makeShared() {
DMRoomMap._sharedInstance = new DMRoomMap(MatrixClientPeg.get());
return DMRoomMap._sharedInstance;
public static makeShared(): DMRoomMap {
DMRoomMap.sharedInstance = new DMRoomMap(MatrixClientPeg.get());
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) {
DMRoomMap.sharedInstance = inst;
}
/**
@ -57,32 +67,33 @@ export default class DMRoomMap {
* that uses the singleton matrix client
* The shared instance must be started before use.
*/
static shared() {
return DMRoomMap._sharedInstance;
public static shared(): DMRoomMap {
return DMRoomMap.sharedInstance;
}
start() {
this._populateRoomToUser();
this.matrixClient.on("accountData", this._onAccountData);
public start() {
this.populateRoomToUser();
this.matrixClient.on("accountData", this.onAccountData);
}
stop() {
this.matrixClient.removeListener("accountData", this._onAccountData);
public stop() {
this.matrixClient.removeListener("accountData", this.onAccountData);
}
_onAccountData(ev) {
private onAccountData = (ev) => {
if (ev.getType() == 'm.direct') {
this.mDirectEvent = this.matrixClient.getAccountData('m.direct').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
*/
_patchUpSelfDMs(userToRooms) {
private patchUpSelfDMs(userToRooms) {
const myUserId = this.matrixClient.getUserId();
const selfRoomIds = userToRooms[myUserId];
if (selfRoomIds) {
@ -92,7 +103,7 @@ export default class DMRoomMap {
if (room) {
const userId = room.guessDMUserId();
if (userId && userId !== myUserId) {
return {userId, roomId};
return { userId, roomId };
}
}
}).filter((ids) => !!ids); //filter out
@ -105,23 +116,23 @@ export default class DMRoomMap {
return !guessedUserIdsThatChanged
.some((ids) => ids.roomId === roomId);
});
guessedUserIdsThatChanged.forEach(({userId, roomId}) => {
guessedUserIdsThatChanged.forEach(({ userId, roomId }) => {
const roomIds = userToRooms[userId];
if (!roomIds) {
userToRooms[userId] = [roomId];
} else {
roomIds.push(roomId);
userToRooms[userId] = _uniq(roomIds);
userToRooms[userId] = uniq(roomIds);
}
});
return true;
}
}
getDMRoomsForUserId(userId) {
public getDMRoomsForUserId(userId): 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] || [];
return this.getUserToRooms()[userId] || [];
}
/**
@ -129,7 +140,7 @@ export default class DMRoomMap {
* @param {string[]} ids The identifiers (user IDs and email addresses) to look for.
* @returns {Room} The DM room which all IDs given share, or falsey if no common room.
*/
getDMRoomForIdentifiers(ids) {
public getDMRoomForIdentifiers(ids: string[]): Room {
// 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
@ -145,7 +156,7 @@ export default class DMRoomMap {
return joinedRooms[0];
}
getUserIdForRoomId(roomId) {
public getUserIdForRoomId(roomId: string) {
if (this.roomToUser == null) {
// we lazily populate roomToUser so you can use
// this class just to call getDMRoomsForUserId
@ -153,7 +164,7 @@ export default class DMRoomMap {
// convenient wrapper and there's no point
// iterating through the map if getUserIdForRoomId()
// is never called.
this._populateRoomToUser();
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.
@ -167,28 +178,28 @@ export default class DMRoomMap {
return this.roomToUser[roomId];
}
getUniqueRoomsWithIndividuals(): {[userId: string]: Room} {
public getUniqueRoomsWithIndividuals(): {[userId: string]: Room} {
if (!this.roomToUser) return {}; // No rooms means no map.
return Object.keys(this.roomToUser)
.map(r => ({userId: this.getUserIdForRoomId(r), room: this.matrixClient.getRoom(r)}))
.map(r => ({ userId: this.getUserIdForRoomId(r), room: this.matrixClient.getRoom(r) }))
.filter(r => r.userId && r.room && r.room.getInvitedAndJoinedMemberCount() === 2)
.reduce((obj, r) => (obj[r.userId] = r.room) && obj, {});
}
_getUserToRooms() {
private getUserToRooms(): {[key: string]: string[]} {
if (!this.userToRooms) {
const userToRooms = this.mDirectEvent;
const userToRooms = this.mDirectEvent as {[key: string]: string[]};
const myUserId = this.matrixClient.getUserId();
const selfDMs = userToRooms[myUserId];
if (selfDMs && selfDMs.length) {
const neededPatching = this._patchUpSelfDMs(userToRooms);
const neededPatching = this.patchUpSelfDMs(userToRooms);
// to avoid multiple devices fighting to correct
// the account data, only try to send the corrected
// version once.
console.warn(`Invalid m.direct account data detected ` +
`(self-chats that shouldn't be), patching it up.`);
if (neededPatching && !this._hasSentOutPatchDirectAccountDataPatch) {
this._hasSentOutPatchDirectAccountDataPatch = true;
if (neededPatching && !this.hasSentOutPatchDirectAccountDataPatch) {
this.hasSentOutPatchDirectAccountDataPatch = true;
this.matrixClient.setAccountData('m.direct', userToRooms);
}
}
@ -197,9 +208,9 @@ export default class DMRoomMap {
return this.userToRooms;
}
_populateRoomToUser() {
private populateRoomToUser() {
this.roomToUser = {};
for (const user of Object.keys(this._getUserToRooms())) {
for (const user of Object.keys(this.getUserToRooms())) {
for (const roomId of this.userToRooms[user]) {
this.roomToUser[roomId] = user;
}

View file

@ -1,112 +0,0 @@
/*
Copyright 2016 OpenMarket Ltd
Copyright 2018 New Vector Ltd
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
// Pull in the encryption lib so that we can decrypt attachments.
import encrypt from 'browser-encrypt-attachment';
// Grab the client so that we can turn mxc:// URLs into https:// URLS.
import {MatrixClientPeg} from '../MatrixClientPeg';
// 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 Riot'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': true,
'image/gif': true,
'image/png': true,
'video/mp4': true,
'video/webm': true,
'video/ogg': true,
'audio/mp4': true,
'audio/webm': true,
'audio/aac': true,
'audio/mpeg': true,
'audio/ogg': true,
'audio/wave': true,
'audio/wav': true,
'audio/x-wav': true,
'audio/x-pn-wav': true,
'audio/flac': true,
'audio/x-flac': true,
};
/**
* Decrypt a file attached to a matrix event.
* @param file {Object} The json taken from the matrix event.
* This passed to [link]{@link https://github.com/matrix-org/browser-encrypt-attachments}
* as the encryption info object, so will also have the those keys in addition to
* the keys below.
* @param file.url {string} An mxc:// URL for the encrypted file.
* @param file.mimetype {string} The MIME-type of the plaintext file.
*/
export function decryptFile(file) {
const url = MatrixClientPeg.get().mxcUrlToHttp(file.url);
// Download the encrypted file as an array buffer.
return Promise.resolve(fetch(url)).then(function(response) {
return response.arrayBuffer();
}).then(function(responseData) {
// Decrypt the array buffer using the information taken from
// the event content.
return encrypt.decryptAttachment(responseData, file);
}).then(function(dataArray) {
// 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.
let mimetype = file.mimetype ? file.mimetype.split(";")[0].trim() : '';
if (!ALLOWED_BLOB_MIMETYPES[mimetype]) {
mimetype = 'application/octet-stream';
}
const blob = new Blob([dataArray], {type: mimetype});
return blob;
});
}

52
src/utils/DecryptFile.ts Normal file
View file

@ -0,0 +1,52 @@
/*
Copyright 2016, 2018, 2021 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
// Pull in the encryption lib so that we can decrypt attachments.
import encrypt from 'browser-encrypt-attachment';
import { mediaFromContent } from "../customisations/Media";
import { IEncryptedFile } from "../customisations/models/IMediaEventContent";
import { getBlobSafeMimeType } from "./blobs";
/**
* Decrypt a file attached to a matrix event.
* @param {IEncryptedFile} file The json taken from the matrix event.
* This passed to [link]{@link https://github.com/matrix-org/browser-encrypt-attachments}
* as the encryption info object, so will also have the those keys in addition to
* the keys below.
* @returns {Promise<Blob>} Resolves to a Blob of the file.
*/
export function decryptFile(file: IEncryptedFile): Promise<Blob> {
const media = mediaFromContent({ file });
// Download the encrypted file as an array buffer.
return media.downloadSource().then((response) => {
return response.arrayBuffer();
}).then((responseData) => {
// Decrypt the array buffer using the information taken from
// the event content.
return encrypt.decryptAttachment(responseData, file);
}).then((dataArray) => {
// 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.
let mimetype = file.mimetype ? file.mimetype.split(";")[0].trim() : '';
mimetype = getBlobSafeMimeType(mimetype);
return new Blob([dataArray], { type: mimetype });
});
}

View file

@ -1,23 +1,39 @@
/*
Copyright 2018 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
// Find a protocol 'instance' with a given instance_id
// in the supplied protocols dict
export function instanceForInstanceId(protocols, instance_id) {
if (!instance_id) return null;
export function instanceForInstanceId(protocols, instanceId) {
if (!instanceId) return null;
for (const proto of Object.keys(protocols)) {
if (!protocols[proto].instances && protocols[proto].instances instanceof Array) continue;
for (const instance of protocols[proto].instances) {
if (instance.instance_id == instance_id) return instance;
if (instance.instance_id == instanceId) return instance;
}
}
}
// given an instance_id, return the name of the protocol for
// that instance ID in the supplied protocols dict
export function protocolNameForInstanceId(protocols, instance_id) {
if (!instance_id) return null;
export function protocolNameForInstanceId(protocols, instanceId) {
if (!instanceId) return null;
for (const proto of Object.keys(protocols)) {
if (!protocols[proto].instances && protocols[proto].instances instanceof Array) continue;
for (const instance of protocols[proto].instances) {
if (instance.instance_id == instance_id) return proto;
if (instance.instance_id == instanceId) return proto;
}
}
}

View file

@ -1,49 +0,0 @@
/*
Copyright 2019 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
/**
* 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 {
constructor(event) {
this._event = event;
this._serializedParts = null;
this.caret = null;
}
setEditorState(caret, serializedParts) {
this._caret = caret;
this._serializedParts = serializedParts;
}
hasEditorState() {
return !!this._serializedParts;
}
getSerializedParts() {
return this._serializedParts;
}
getCaret() {
return this._caret;
}
getEvent() {
return this._event;
}
}

View file

@ -0,0 +1,53 @@
/*
Copyright 2019 - 2021 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import { MatrixEvent } from "matrix-js-sdk/src/models/event";
import { SerializedPart } from "../editor/parts";
import 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;
private caret: DocumentOffset = null;
constructor(private readonly event: MatrixEvent) {}
public setEditorState(caret: DocumentOffset, serializedParts: SerializedPart[]) {
this.caret = caret;
this.serializedParts = serializedParts;
}
public hasEditorState(): boolean {
return !!this.serializedParts;
}
public getSerializedParts(): SerializedPart[] {
return this.serializedParts;
}
public getCaret(): DocumentOffset {
return this.caret;
}
public getEvent(): MatrixEvent {
return this.event;
}
}

View file

@ -1,5 +1,5 @@
/*
Copyright 2018 New Vector Ltd
Copyright 2018 - 2021 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
@ -14,7 +14,10 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import { _t, _td } from '../languageHandler';
import React, { ReactNode } from "react";
import { MatrixError } from "matrix-js-sdk/src/http-api";
import { _t, _td, Tags, TranslatedString } from '../languageHandler';
/**
* Produce a translated error message for a
@ -30,7 +33,12 @@ import { _t, _td } from '../languageHandler';
* for any tags in the strings apart from 'a'
* @returns {*} Translated string or react component
*/
export function messageForResourceLimitError(limitType, adminContact, strings, extraTranslations) {
export function messageForResourceLimitError(
limitType: string,
adminContact: string,
strings: Record<string, string>,
extraTranslations?: Tags,
): TranslatedString {
let errString = strings[limitType];
if (errString === undefined) errString = strings[''];
@ -49,19 +57,14 @@ export function messageForResourceLimitError(limitType, adminContact, strings, e
}
}
export function messageForSendError(errorData) {
if (errorData.errcode === "M_TOO_LARGE") {
return _t("The message you are trying to send is too large.");
}
}
export function messageForSyncError(err) {
export function messageForSyncError(err: MatrixError | Error): ReactNode {
if (err.errcode === 'M_RESOURCE_LIMIT_EXCEEDED') {
const limitError = messageForResourceLimitError(
err.data.limit_type,
err.data.admin_contact,
{
'monthly_active_user': _td("This homeserver has hit its Monthly Active User limit."),
'hs_blocked': _td("This homeserver has been blocked by its administrator."),
'': _td("This homeserver has exceeded one of its resource limits."),
},
);

View file

@ -1,5 +1,5 @@
/*
Copyright 2019 New Vector Ltd
Copyright 2019 - 2021 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
@ -14,9 +14,15 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import { EventStatus } from 'matrix-js-sdk';
import {MatrixClientPeg} from '../MatrixClientPeg';
import { Room } from 'matrix-js-sdk/src/models/room';
import { MatrixEvent, EventStatus } from 'matrix-js-sdk/src/models/event';
import { MatrixClientPeg } from '../MatrixClientPeg';
import shouldHideEvent from "../shouldHideEvent";
import { getHandlerTile, haveTileForEvent } from "../components/views/rooms/EventTile";
import SettingsStore from "../settings/SettingsStore";
import { EventType } from "matrix-js-sdk/src/@types/event";
/**
* 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
@ -25,13 +31,13 @@ import shouldHideEvent from "../shouldHideEvent";
* @param {MatrixEvent} mxEvent The event to check
* @returns {boolean} true if actionable
*/
export function isContentActionable(mxEvent) {
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) {
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')) {
@ -45,18 +51,18 @@ export function isContentActionable(mxEvent) {
return false;
}
export function canEditContent(mxEvent) {
export function canEditContent(mxEvent: MatrixEvent): boolean {
if (mxEvent.status === EventStatus.CANCELLED || mxEvent.getType() !== "m.room.message" || mxEvent.isRedacted()) {
return false;
}
const content = mxEvent.getOriginalContent();
const {msgtype} = content;
const { msgtype } = content;
return (msgtype === "m.text" || msgtype === "m.emote") &&
content.body && typeof content.body === 'string' &&
mxEvent.getSender() === MatrixClientPeg.get().getUserId();
}
export function canEditOwnEvent(mxEvent) {
export function canEditOwnEvent(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
@ -67,7 +73,7 @@ export function canEditOwnEvent(mxEvent) {
}
const MAX_JUMP_DISTANCE = 100;
export function findEditableEvent(room, isForward, fromEventId = undefined) {
export function findEditableEvent(room: Room, isForward: boolean, fromEventId: string = undefined): MatrixEvent {
const liveTimeline = room.getLiveTimeline();
const events = liveTimeline.getEvents().concat(room.getPendingEvents());
const maxIdx = events.length - 1;
@ -93,3 +99,38 @@ export function findEditableEvent(room, isForward, fromEventId = undefined) {
}
}
export function getEventDisplayInfo(mxEvent: MatrixEvent): {
isInfoMessage: boolean;
tileHandler: string;
isBubbleMessage: boolean;
} {
const content = mxEvent.getContent();
const msgtype = content.msgtype;
const eventType = mxEvent.getType();
let tileHandler = getHandlerTile(mxEvent);
// Info messages are basically information about commands processed on a room
let isBubbleMessage = eventType.startsWith("m.key.verification") ||
(eventType === EventType.RoomMessage && msgtype && msgtype.startsWith("m.key.verification")) ||
(eventType === EventType.RoomCreate) ||
(eventType === EventType.RoomEncryption) ||
(tileHandler === "messages.MJitsiWidgetEvent");
let isInfoMessage = (
!isBubbleMessage && eventType !== EventType.RoomMessage &&
eventType !== EventType.Sticker && eventType !== EventType.RoomCreate
);
// 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 (SettingsStore.getValue("showHiddenEventsInTimeline") && !haveTileForEvent(mxEvent)) {
tileHandler = "messages.ViewSourceEvent";
isBubbleMessage = false;
// Reuse info message avatar and sender profile styling
isInfoMessage = true;
}
return { tileHandler, isInfoMessage, isBubbleMessage };
}

View file

@ -0,0 +1,54 @@
/*
Copyright 2021 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
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.
*/
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) {
let swap = arrayFastClone(this.samples);
swap.splice(0, 0, value);
if (swap.length > this.width) {
swap = swap.slice(0, this.width);
}
this.samples = swap;
}
}

View file

@ -1,5 +1,5 @@
/*
Copyright 2019 The Matrix.org Foundation C.I.C.
Copyright 2019 - 2021 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
@ -21,7 +21,7 @@ limitations under the License.
* MIT license
*/
function safariVersionCheck(ua) {
function safariVersionCheck(ua: string): boolean {
console.log("Browser is Safari - checking version for COLR support");
try {
const safariVersionMatch = ua.match(/Mac OS X ([\d|_]+).*Version\/([\d|.]+).*Safari/);
@ -44,10 +44,10 @@ function safariVersionCheck(ua) {
return false;
}
async function isColrFontSupported() {
async function isColrFontSupported(): Promise<boolean> {
console.log("Checking for COLR support");
const {userAgent} = navigator;
const { userAgent } = navigator;
// Firefox has supported COLR fonts since version 26
// but doesn't support the check below without
// "Extract canvas data" permissions
@ -101,7 +101,7 @@ async function isColrFontSupported() {
}
let colrFontCheckStarted = false;
export async function fixupColorFonts() {
export async function fixupColorFonts(): Promise<void> {
if (colrFontCheckStarted) {
return;
}
@ -112,14 +112,14 @@ export async function fixupColorFonts() {
document.fonts.add(new FontFace("Twemoji", path, {}));
// For at least Chrome on Windows 10, we have to explictly add extra
// weights for the emoji to appear in bold messages, etc.
document.fonts.add(new FontFace("Twemoji", path, { weight: 600 }));
document.fonts.add(new FontFace("Twemoji", path, { weight: 700 }));
document.fonts.add(new FontFace("Twemoji", path, { weight: "600" }));
document.fonts.add(new FontFace("Twemoji", path, { weight: "700" }));
} else {
// fall back to SBIX, generated via https://github.com/matrix-org/twemoji-colr/tree/matthew/sbix
const path = `url('${require("../../res/fonts/Twemoji_Mozilla/TwemojiMozilla-sbix.woff2")}')`;
document.fonts.add(new FontFace("Twemoji", path, {}));
document.fonts.add(new FontFace("Twemoji", path, { weight: 600 }));
document.fonts.add(new FontFace("Twemoji", path, { weight: 700 }));
document.fonts.add(new FontFace("Twemoji", path, { weight: "600" }));
document.fonts.add(new FontFace("Twemoji", path, { weight: "700" }));
}
// ...and if SBIX is not supported, the browser will fall back to one of the native fonts specified.
}

View file

@ -1,6 +1,6 @@
/*
Copyright 2016 OpenMarket Ltd
Copyright 2019 The Matrix.org Foundation C.I.C.
Copyright 2019, 2020 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
@ -21,29 +21,29 @@ import { _t } from '../languageHandler';
* formats numbers to fit into ~3 characters, suitable for badge counts
* e.g: 999, 9.9K, 99K, 0.9M, 9.9M, 99M, 0.9B, 9.9B
*/
export function formatCount(count) {
if (count < 1000) return count;
if (count < 10000) return (count / 1000).toFixed(1) + "K";
if (count < 100000) return (count / 1000).toFixed(0) + "K";
if (count < 10000000) return (count / 1000000).toFixed(1) + "M";
if (count < 100000000) return (count / 1000000).toFixed(0) + "M";
return (count / 1000000000).toFixed(1) + "B"; // 10B is enough for anyone, right? :S
export function formatCount(count: number): string {
if (count < 1000) return count.toString();
if (count < 10000) return (count / 1000).toFixed(1) + "K";
if (count < 100000) return (count / 1000).toFixed(0) + "K";
if (count < 10000000) return (count / 1000000).toFixed(1) + "M";
if (count < 100000000) return (count / 1000000).toFixed(0) + "M";
return (count / 1000000000).toFixed(1) + "B"; // 10B is enough for anyone, right? :S
}
/**
* Format a count showing the whole number but making it a bit more readable.
* e.g: 1000 => 1,000
*/
export function formatCountLong(count) {
export function formatCountLong(count: number): string {
const formatter = new Intl.NumberFormat();
return formatter.format(count)
return formatter.format(count);
}
/**
* format a size in bytes into a human readable form
* e.g: 1024 -> 1.00 KB
*/
export function formatBytes(bytes, decimals = 2) {
export function formatBytes(bytes: number, decimals = 2): string {
if (bytes === 0) return '0 Bytes';
const k = 1024;
@ -62,7 +62,7 @@ export function formatBytes(bytes, decimals = 2) {
*
* @return {string}
*/
export function formatCryptoKey(key) {
export function formatCryptoKey(key: string): string {
return key.match(/.{1,4}/g).join(" ");
}
/**
@ -72,7 +72,7 @@ export function formatCryptoKey(key) {
*
* @return {number}
*/
export function hashCode(str) {
export function hashCode(str: string): number {
let hash = 0;
let i;
let chr;
@ -87,7 +87,7 @@ export function hashCode(str) {
return Math.abs(hash);
}
export function getUserNameColorClass(userId) {
export function getUserNameColorClass(userId: string): string {
const colorNumber = (hashCode(userId) % 8) + 1;
return `mx_Username_color${colorNumber}`;
}
@ -103,7 +103,7 @@ export function getUserNameColorClass(userId) {
* @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 formatCommaSeparatedList(items, itemLimit) {
export function formatCommaSeparatedList(items: string[], itemLimit?: number): string {
const remaining = itemLimit === undefined ? 0 : Math.max(
items.length - itemLimit, 0,
);

View file

@ -17,8 +17,8 @@ limitations under the License.
import url from 'url';
import SdkConfig from '../SdkConfig';
import {MatrixClientPeg} from '../MatrixClientPeg';
import {urlSearchParamsToObject} from "./UrlUtils";
import { MatrixClientPeg } from '../MatrixClientPeg';
import { urlSearchParamsToObject } from "./UrlUtils";
export function getHostingLink(campaign) {
const hostingLink = SdkConfig.get().hosting_signup_link;

View file

@ -1,5 +1,5 @@
/*
Copyright 2019 The Matrix.org Foundation C.I.C.
Copyright 2020 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
@ -14,15 +14,6 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
/**
* Automatically focuses the captured reference when receiving a non-null
* object. Useful in scenarios where componentDidMount does not have a
* useful reference to an element, but one needs to focus the element on
* first render. Example usage: ref={focusCapturedRef}
* @param {function} ref The React reference to focus on, if not null
*/
export function focusCapturedRef(ref) {
if (ref) {
ref.focus();
}
export interface IDestroyable {
destroy(): void;
}

View file

@ -1,5 +1,5 @@
/*
Copyright 2019 The Matrix.org Foundation C.I.C.
Copyright 2019 - 2021 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
@ -14,15 +14,16 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import { SERVICE_TYPES } from 'matrix-js-sdk';
import SdkConfig from '../SdkConfig';
import {MatrixClientPeg} from '../MatrixClientPeg';
import { SERVICE_TYPES } from 'matrix-js-sdk/src/service-types';
export function getDefaultIdentityServerUrl() {
import SdkConfig from '../SdkConfig';
import { MatrixClientPeg } from '../MatrixClientPeg';
export function getDefaultIdentityServerUrl(): string {
return SdkConfig.get()['validated_server_config']['isUrl'];
}
export function useDefaultIdentityServer() {
export function useDefaultIdentityServer(): void {
const url = getDefaultIdentityServerUrl();
// Account data change will update localstorage, client, etc through dispatcher
MatrixClientPeg.get().setAccountData("m.identity_server", {
@ -30,7 +31,7 @@ export function useDefaultIdentityServer() {
});
}
export async function doesIdentityServerHaveTerms(fullUrl) {
export async function doesIdentityServerHaveTerms(fullUrl: string): Promise<boolean> {
let terms;
try {
terms = await MatrixClientPeg.get().getTerms(SERVICE_TYPES.IS, fullUrl);
@ -46,7 +47,7 @@ export async function doesIdentityServerHaveTerms(fullUrl) {
return terms && terms["policies"] && (Object.keys(terms["policies"]).length > 0);
}
export function doesAccountDataHaveIdentityServer() {
export function doesAccountDataHaveIdentityServer(): boolean {
const event = MatrixClientPeg.get().getAccountData("m.identity_server");
return event && event.getContent() && event.getContent()['base_url'];
}

View file

@ -14,7 +14,7 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import {MatrixClientPeg} from '../MatrixClientPeg';
import { MatrixClientPeg } from '../MatrixClientPeg';
import { _t } from '../languageHandler';
export function getNameForEventRoom(userId, roomId) {
@ -27,7 +27,7 @@ export function getNameForEventRoom(userId, roomId) {
export function userLabelForEventRoom(userId, roomId) {
const name = getNameForEventRoom(userId, roomId);
if (name !== userId) {
return _t("%(name)s (%(userId)s)", {name, userId});
return _t("%(name)s (%(userId)s)", { name, userId });
} else {
return userId;
}

View file

@ -0,0 +1,59 @@
/*
Copyright 2020 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
/**
* 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.
*/
constructor(private fn: () => void, private onMarkCallback?: () => void) {
}
/**
* Resets the mark without calling the function.
*/
public reset() {
this.marked = false;
}
/**
* Marks the function to be called upon trigger().
*/
public mark() {
if (!this.marked) this.onMarkCallback?.();
this.marked = true;
}
/**
* If marked, the function will be called, otherwise this does nothing.
*/
public trigger() {
if (!this.marked) return;
this.reset(); // reset first just in case the fn() causes a trigger()
this.fn();
}
}

View file

@ -1,5 +1,6 @@
/*
Copyright 2017 Vector Creations Ltd
Copyright 2020 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
@ -14,21 +15,8 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
"use strict";
// polyfill textencoder if necessary
import * as TextEncodingUtf8 from 'text-encoding-utf-8';
let TextEncoder = window.TextEncoder;
if (!TextEncoder) {
TextEncoder = TextEncodingUtf8.TextEncoder;
}
let TextDecoder = window.TextDecoder;
if (!TextDecoder) {
TextDecoder = TextEncodingUtf8.TextDecoder;
}
import { _t } from '../languageHandler';
import SdkConfig from '../SdkConfig';
const subtleCrypto = window.crypto.subtle || window.crypto.webkitSubtle;
@ -61,23 +49,24 @@ function cryptoFailMsg() {
*/
export async function decryptMegolmKeyFile(data, password) {
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('Not a valid Riot keyfile'));
_t('Not a valid %(brand)s keyfile', { brand }));
}
const version = body[0];
if (version !== 1) {
throw friendlyError('Unsupported version',
_t('Not a valid Riot keyfile'));
_t('Not a valid %(brand)s keyfile', { brand }));
}
const ciphertextLength = body.length-(1+16+16+4+32);
if (ciphertextLength < 0) {
throw friendlyError('Invalid file: too short',
_t('Not a valid Riot keyfile'));
_t('Not a valid %(brand)s keyfile', { brand }));
}
const salt = body.subarray(1, 1+16);
@ -92,7 +81,7 @@ export async function decryptMegolmKeyFile(data, password) {
let isValid;
try {
isValid = await subtleCrypto.verify(
{name: 'HMAC'},
{ name: 'HMAC' },
hmacKey,
hmac,
toVerify,
@ -123,7 +112,6 @@ export async function decryptMegolmKeyFile(data, password) {
return new TextDecoder().decode(new Uint8Array(plaintext));
}
/**
* Encrypt a megolm key file
*
@ -185,7 +173,7 @@ export async function encryptMegolmKeyFile(data, password, options) {
let hmac;
try {
hmac = await subtleCrypto.sign(
{name: 'HMAC'},
{ name: 'HMAC' },
hmacKey,
toSign,
);
@ -193,7 +181,6 @@ export async function encryptMegolmKeyFile(data, password, options) {
throw friendlyError('subtleCrypto.sign failed: ' + e, cryptoFailMsg());
}
const hmacArray = new Uint8Array(hmac);
resultBuffer.set(hmacArray, idx);
return packMegolmKeyFile(resultBuffer);
@ -215,7 +202,7 @@ async function deriveKeys(salt, iterations, password) {
key = await subtleCrypto.importKey(
'raw',
new TextEncoder().encode(password),
{name: 'PBKDF2'},
{ name: 'PBKDF2' },
false,
['deriveBits'],
);
@ -248,7 +235,7 @@ async function deriveKeys(salt, iterations, password) {
const aesProm = subtleCrypto.importKey(
'raw',
aesKey,
{name: 'AES-CTR'},
{ name: 'AES-CTR' },
false,
['encrypt', 'decrypt'],
).catch((e) => {
@ -260,7 +247,7 @@ async function deriveKeys(salt, iterations, password) {
hmacKey,
{
name: 'HMAC',
hash: {name: 'SHA-256'},
hash: { name: 'SHA-256' },
},
false,
['sign', 'verify'],
@ -310,8 +297,7 @@ function unpackMegolmKeyFile(data) {
// look for the end line
while (1) {
const lineEnd = fileStr.indexOf('\n', lineStart);
const line = fileStr.slice(lineStart, lineEnd < 0 ? undefined : lineEnd)
.trim();
const line = fileStr.slice(lineStart, lineEnd < 0 ? undefined : lineEnd).trim();
if (line === TRAILER_LINE) {
break;
}

View file

@ -1,5 +1,5 @@
/*
Copyright 2019 The Matrix.org Foundation C.I.C.
Copyright 2019 - 2021 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
@ -14,31 +14,33 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import React from 'react';
import React, { ReactNode } from 'react';
import classNames from 'classnames';
import DiffMatchPatch from 'diff-match-patch';
import {DiffDOM} from "diff-dom";
import { checkBlockNode, bodyToHtml } from "../HtmlUtils";
import { diff_match_patch as DiffMatchPatch } from 'diff-match-patch';
import { DiffDOM, IDiff } from "diff-dom";
import { IContent } from "matrix-js-sdk/src/models/event";
import { bodyToHtml, checkBlockNode, IOptsReturnString } from "../HtmlUtils";
const decodeEntities = (function() {
let textarea = null;
return function(string) {
return function(str: string): string {
if (!textarea) {
textarea = document.createElement("textarea");
}
textarea.innerHTML = string;
textarea.innerHTML = str;
return textarea.value;
};
})();
function textToHtml(text) {
function textToHtml(text: string): string {
const container = document.createElement("div");
container.textContent = text;
return container.innerHTML;
}
function getSanitizedHtmlBody(content) {
const opts = {
function getSanitizedHtmlBody(content: IContent): string {
const opts: IOptsReturnString = {
stripReplyFallback: true,
returnString: true,
};
@ -57,31 +59,29 @@ function getSanitizedHtmlBody(content) {
}
}
function wrapInsertion(child) {
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) {
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, route, isAddition) {
function findRefNodes(root: Node, route: number[], isAddition = false) {
let refNode = root;
let refParentNode;
const end = isAddition ? route.length - 1 : route.length;
for (let i = 0; i < end; ++i) {
refParentNode = refNode;
// Lists don't have appropriate child nodes we can use.
if (!refNode.childNodes[route[i]]) continue;
refNode = refNode.childNodes[route[i]];
}
return {refNode, refParentNode};
return { refNode, refParentNode };
}
function diffTreeToDOM(desc) {
@ -103,7 +103,7 @@ function diffTreeToDOM(desc) {
}
}
function insertBefore(parent, nextSibling, child) {
function insertBefore(parent: Node, nextSibling: Node | null, child: Node): void {
if (nextSibling) {
parent.insertBefore(child, nextSibling);
} else {
@ -111,7 +111,7 @@ function insertBefore(parent, nextSibling, child) {
}
}
function isRouteOfNextSibling(route1, route2) {
function isRouteOfNextSibling(route1: number[], route2: number[]): boolean {
// routes are arrays with indices,
// to be interpreted as a path in the dom tree
@ -129,7 +129,7 @@ function isRouteOfNextSibling(route1, route2) {
return route2[lastD1Idx] >= route1[lastD1Idx];
}
function adjustRoutes(diff, remainingDiffs) {
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.
@ -142,12 +142,12 @@ function adjustRoutes(diff, remainingDiffs) {
}
}
function stringAsTextNode(string) {
function stringAsTextNode(string: string): Text {
return document.createTextNode(decodeEntities(string));
}
function renderDifferenceInDOM(originalRootNode, diff, diffMathPatch) {
const {refNode, refParentNode} = findRefNodes(originalRootNode, diff.route);
function renderDifferenceInDOM(originalRootNode: Node, diff: IDiff, diffMathPatch: DiffMatchPatch): void {
const { refNode, refParentNode } = findRefNodes(originalRootNode, diff.route);
switch (diff.action) {
case "replaceElement": {
const container = document.createElement("span");
@ -173,7 +173,7 @@ function renderDifferenceInDOM(originalRootNode, diff, diffMathPatch) {
diffMathPatch.diff_cleanupSemantic(textDiffs);
const container = document.createElement("span");
for (const [modifier, text] of textDiffs) {
let textDiffNode = stringAsTextNode(text);
let textDiffNode: Node = stringAsTextNode(text);
if (modifier < 0) {
textDiffNode = wrapDeletion(textDiffNode);
} else if (modifier > 0) {
@ -190,10 +190,11 @@ function renderDifferenceInDOM(originalRootNode, diff, diffMathPatch) {
break;
}
case "addTextElement": {
if (diff.value !== "\n") {
const insNode = wrapInsertion(stringAsTextNode(diff.value));
insertBefore(refParentNode, refNode, insNode);
}
// 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 : ""));
insertBefore(refParentNode, refNode, insNode);
break;
}
// e.g. when changing a the href of a link,
@ -202,7 +203,7 @@ function renderDifferenceInDOM(originalRootNode, diff, diffMathPatch) {
case "addAttribute":
case "modifyAttribute": {
const delNode = wrapDeletion(refNode.cloneNode(true));
const updatedNode = refNode.cloneNode(true);
const updatedNode = refNode.cloneNode(true) as HTMLElement;
if (diff.action === "addAttribute" || diff.action === "modifyAttribute") {
updatedNode.setAttribute(diff.name, diff.newValue);
} else {
@ -221,12 +222,12 @@ function renderDifferenceInDOM(originalRootNode, diff, diffMathPatch) {
}
}
function routeIsEqual(r1, r2) {
function routeIsEqual(r1: number[], r2: number[]): boolean {
return r1.length === r2.length && !r1.some((e, i) => e !== r2[i]);
}
// workaround for https://github.com/fiduswriter/diffDOM/issues/90
function filterCancelingOutDiffs(originalDiffActions) {
function filterCancelingOutDiffs(originalDiffActions: IDiff[]): IDiff[] {
const diffActions = originalDiffActions.slice();
for (let i = 0; i < diffActions.length; ++i) {
@ -253,7 +254,7 @@ function filterCancelingOutDiffs(originalDiffActions) {
* @param {object} editContent the content for the edit message
* @return {object} a react element similar to what `bodyToHtml` returns
*/
export function editBodyDiffToHtml(originalContent, editContent) {
export function editBodyDiffToHtml(originalContent: IContent, editContent: IContent): ReactNode {
// wrap the body in a div, DiffDOM needs a root element
const originalBody = `<div>${getSanitizedHtmlBody(originalContent)}</div>`;
const editBody = `<div>${getSanitizedHtmlBody(editContent)}</div>`;

50
src/utils/Mouse.ts Normal file
View file

@ -0,0 +1,50 @@
/*
Copyright 2021 Šimon Brandner <simon.bra.ag@gmail.com>
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
/**
* 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(event: WheelEvent): WheelEvent {
const LINE_HEIGHT = 18;
let deltaX;
let deltaY;
let deltaZ;
if (event.deltaMode === 1) { // Units are lines
deltaX = (event.deltaX * LINE_HEIGHT);
deltaY = (event.deltaY * LINE_HEIGHT);
deltaZ = (event.deltaZ * LINE_HEIGHT);
} else {
deltaX = event.deltaX;
deltaY = event.deltaY;
deltaZ = event.deltaZ;
}
return new WheelEvent(
"syntheticWheel",
{
deltaMode: 0,
deltaY: deltaY,
deltaX: deltaX,
deltaZ: deltaZ,
...event,
},
);
}

View file

@ -1,268 +0,0 @@
/*
Copyright 2016 OpenMarket Ltd
Copyright 2017, 2018 New Vector Ltd
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import {MatrixClientPeg} from '../MatrixClientPeg';
import {getAddressType} from '../UserAddress';
import GroupStore from '../stores/GroupStore';
import {_t} from "../languageHandler";
import * as sdk from "../index";
import Modal from "../Modal";
import SettingsStore from "../settings/SettingsStore";
import {defer} from "./promise";
/**
* Invites multiple addresses to a room or group, handling rate limiting from the server
*/
export default class MultiInviter {
/**
* @param {string} targetId The ID of the room or group to invite to
*/
constructor(targetId) {
if (targetId[0] === '+') {
this.roomId = null;
this.groupId = targetId;
} else {
this.roomId = targetId;
this.groupId = null;
}
this.canceled = false;
this.addrs = [];
this.busy = false;
this.completionStates = {}; // State of each address (invited or error)
this.errors = {}; // { address: {errorText, errcode} }
this.deferred = null;
}
/**
* Invite users to this room. This may only be called once per
* instance of the class.
*
* @param {array} addrs Array of addresses to invite
* @returns {Promise} Resolved when all invitations in the queue are complete
*/
invite(addrs) {
if (this.addrs.length > 0) {
throw new Error("Already inviting/invited");
}
this.addrs.push(...addrs);
for (const addr of this.addrs) {
if (getAddressType(addr) === null) {
this.completionStates[addr] = 'error';
this.errors[addr] = {
errcode: 'M_INVALID',
errorText: _t('Unrecognised address'),
};
}
}
this.deferred = defer();
this._inviteMore(0);
return this.deferred.promise;
}
/**
* Stops inviting. Causes promises returned by invite() to be rejected.
*/
cancel() {
if (!this.busy) return;
this._canceled = true;
this.deferred.reject(new Error('canceled'));
}
getCompletionState(addr) {
return this.completionStates[addr];
}
getErrorText(addr) {
return this.errors[addr] ? this.errors[addr].errorText : null;
}
async _inviteToRoom(roomId, addr, ignoreProfile) {
const addrType = getAddressType(addr);
if (addrType === 'email') {
return MatrixClientPeg.get().inviteByEmail(roomId, addr);
} else if (addrType === 'mx-user-id') {
const room = MatrixClientPeg.get().getRoom(roomId);
if (!room) throw new Error("Room not found");
const member = room.getMember(addr);
if (member && ['join', 'invite'].includes(member.membership)) {
throw {errcode: "RIOT.ALREADY_IN_ROOM", error: "Member already invited"};
}
if (!ignoreProfile && SettingsStore.getValue("promptBeforeInviteUnknownUsers", this.roomId)) {
try {
const profile = await MatrixClientPeg.get().getProfileInfo(addr);
if (!profile) {
// noinspection ExceptionCaughtLocallyJS
throw new Error("User has no profile");
}
} catch (e) {
throw {
errcode: "RIOT.USER_NOT_FOUND",
error: "User does not have a profile or does not exist."
};
}
}
return MatrixClientPeg.get().invite(roomId, addr);
} else {
throw new Error('Unsupported address');
}
}
_doInvite(address, ignoreProfile) {
return new Promise((resolve, reject) => {
console.log(`Inviting ${address}`);
let doInvite;
if (this.groupId !== null) {
doInvite = GroupStore.inviteUserToGroup(this.groupId, address);
} else {
doInvite = this._inviteToRoom(this.roomId, address, ignoreProfile);
}
doInvite.then(() => {
if (this._canceled) {
return;
}
this.completionStates[address] = 'invited';
delete this.errors[address];
resolve();
}).catch((err) => {
if (this._canceled) {
return;
}
console.error(err);
let errorText;
let fatal = false;
if (err.errcode === 'M_FORBIDDEN') {
fatal = true;
errorText = _t('You do not have permission to invite people to this room.');
} else if (err.errcode === "RIOT.ALREADY_IN_ROOM") {
errorText = _t("User %(userId)s is already in the room", {userId: address});
} else if (err.errcode === 'M_LIMIT_EXCEEDED') {
// we're being throttled so wait a bit & try again
setTimeout(() => {
this._doInvite(address, ignoreProfile).then(resolve, reject);
}, 5000);
return;
} else if (['M_NOT_FOUND', 'M_USER_NOT_FOUND', 'RIOT.USER_NOT_FOUND'].includes(err.errcode)) {
errorText = _t("User %(user_id)s does not exist", {user_id: address});
} else if (err.errcode === 'M_PROFILE_UNDISCLOSED') {
errorText = _t("User %(user_id)s may or may not exist", {user_id: address});
} else if (err.errcode === 'M_PROFILE_NOT_FOUND' && !ignoreProfile) {
// Invite without the profile check
console.warn(`User ${address} does not have a profile - inviting anyways automatically`);
this._doInvite(address, true).then(resolve, reject);
} else if (err.errcode === "M_BAD_STATE") {
errorText = _t("The user must be unbanned before they can be invited.");
} else if (err.errcode === "M_UNSUPPORTED_ROOM_VERSION") {
errorText = _t("The user's homeserver does not support the version of the room.");
} else {
errorText = _t('Unknown server error');
}
this.completionStates[address] = 'error';
this.errors[address] = {errorText, errcode: err.errcode};
this.busy = !fatal;
this.fatal = fatal;
if (fatal) {
reject();
} else {
resolve();
}
});
});
}
_inviteMore(nextIndex, ignoreProfile) {
if (this._canceled) {
return;
}
if (nextIndex === this.addrs.length) {
this.busy = false;
if (Object.keys(this.errors).length > 0 && !this.groupId) {
// There were problems inviting some people - see if we can invite them
// without caring if they exist or not.
const unknownProfileErrors = ['M_NOT_FOUND', 'M_USER_NOT_FOUND', 'M_PROFILE_UNDISCLOSED', 'M_PROFILE_NOT_FOUND', 'RIOT.USER_NOT_FOUND'];
const unknownProfileUsers = Object.keys(this.errors).filter(a => unknownProfileErrors.includes(this.errors[a].errcode));
if (unknownProfileUsers.length > 0) {
const inviteUnknowns = () => {
const promises = unknownProfileUsers.map(u => this._doInvite(u, true));
Promise.all(promises).then(() => this.deferred.resolve(this.completionStates));
};
if (!SettingsStore.getValue("promptBeforeInviteUnknownUsers", this.roomId)) {
inviteUnknowns();
return;
}
const AskInviteAnywayDialog = sdk.getComponent("dialogs.AskInviteAnywayDialog");
console.log("Showing failed to invite dialog...");
Modal.createTrackedDialog('Failed to invite the following users to the room', '', AskInviteAnywayDialog, {
unknownProfileUsers: unknownProfileUsers.map(u => {return {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] = 'invited';
}
this.deferred.resolve(this.completionStates);
},
});
return;
}
}
this.deferred.resolve(this.completionStates);
return;
}
const addr = this.addrs[nextIndex];
// 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) {
this._inviteMore(nextIndex + 1);
return;
}
// don't re-invite (there's no way in the UI to do this, but
// for sanity's sake)
if (this.completionStates[addr] === 'invited') {
this._inviteMore(nextIndex + 1);
return;
}
this._doInvite(addr, ignoreProfile).then(() => {
this._inviteMore(nextIndex + 1, ignoreProfile);
}).catch(() => this.deferred.resolve(this.completionStates));
}
}

317
src/utils/MultiInviter.ts Normal file
View file

@ -0,0 +1,317 @@
/*
Copyright 2016 - 2021 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import { MatrixError } from "matrix-js-sdk/src/http-api";
import { defer, IDeferred } from "matrix-js-sdk/src/utils";
import { MatrixClientPeg } from '../MatrixClientPeg';
import { AddressType, getAddressType } from '../UserAddress';
import GroupStore from '../stores/GroupStore';
import { _t } from "../languageHandler";
import Modal from "../Modal";
import SettingsStore from "../settings/SettingsStore";
import AskInviteAnywayDialog from "../components/views/dialogs/AskInviteAnywayDialog";
export enum InviteState {
Invited = "invited",
Error = "error",
}
interface IError {
errorText: string;
errcode: string;
}
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";
/**
* Invites multiple addresses to a room or group, handling rate limiting from the server
*/
export default class MultiInviter {
private readonly roomId?: string;
private readonly groupId?: string;
private canceled = false;
private addresses: string[] = [];
private busy = false;
private _fatal = false;
private completionStates: CompletionStates = {}; // State of each address (invited or error)
private errors: Record<string, IError> = {}; // { address: {errorText, errcode} }
private deferred: IDeferred<CompletionStates> = null;
private reason: string = null;
/**
* @param {string} targetId The ID of the room or group to invite to
*/
constructor(targetId: string) {
if (targetId[0] === '+') {
this.roomId = null;
this.groupId = targetId;
} else {
this.roomId = targetId;
this.groupId = null;
}
}
public get fatal() {
return this._fatal;
}
/**
* Invite users to this room. This may only be called once per
* instance of the class.
*
* @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 invite(addresses, reason?: string): Promise<CompletionStates> {
if (this.addresses.length > 0) {
throw new Error("Already inviting/invited");
}
this.addresses.push(...addresses);
this.reason = reason;
for (const addr of this.addresses) {
if (getAddressType(addr) === null) {
this.completionStates[addr] = InviteState.Error;
this.errors[addr] = {
errcode: 'M_INVALID',
errorText: _t('Unrecognised address'),
};
}
}
this.deferred = defer<CompletionStates>();
this.inviteMore(0);
return this.deferred.promise;
}
/**
* Stops inviting. Causes promises returned by invite() to be rejected.
*/
public cancel(): void {
if (!this.busy) return;
this.canceled = true;
this.deferred.reject(new Error('canceled'));
}
public getCompletionState(addr: string): InviteState {
return this.completionStates[addr];
}
public getErrorText(addr: string): string {
return this.errors[addr] ? this.errors[addr].errorText : null;
}
private async inviteToRoom(roomId: string, addr: string, ignoreProfile = false): Promise<{}> {
const addrType = getAddressType(addr);
if (addrType === AddressType.Email) {
return MatrixClientPeg.get().inviteByEmail(roomId, addr);
} else if (addrType === AddressType.MatrixUserId) {
const room = MatrixClientPeg.get().getRoom(roomId);
if (!room) throw new Error("Room not found");
const member = room.getMember(addr);
if (member?.membership === "join") {
throw new MatrixError({
errcode: USER_ALREADY_JOINED,
error: "Member already joined",
});
} else if (member?.membership === "invite") {
throw new MatrixError({
errcode: USER_ALREADY_INVITED,
error: "Member already invited",
});
}
if (!ignoreProfile && SettingsStore.getValue("promptBeforeInviteUnknownUsers", this.roomId)) {
const profile = await MatrixClientPeg.get().getProfileInfo(addr);
if (!profile) {
// noinspection ExceptionCaughtLocallyJS
throw new Error("User has no profile");
}
}
return MatrixClientPeg.get().invite(roomId, addr, undefined, this.reason);
} else {
throw new Error('Unsupported address');
}
}
private doInvite(address: string, ignoreProfile = false): Promise<void> {
return new Promise<void>((resolve, reject) => {
console.log(`Inviting ${address}`);
let doInvite;
if (this.groupId !== null) {
doInvite = GroupStore.inviteUserToGroup(this.groupId, address);
} else {
doInvite = this.inviteToRoom(this.roomId, address, ignoreProfile);
}
doInvite.then(() => {
if (this.canceled) {
return;
}
this.completionStates[address] = InviteState.Invited;
delete this.errors[address];
resolve();
}).catch((err) => {
if (this.canceled) {
return;
}
console.error(err);
let errorText;
let fatal = false;
switch (err.errcode) {
case "M_FORBIDDEN":
errorText = _t('You do not have permission to invite people to this room.');
fatal = true;
break;
case USER_ALREADY_INVITED:
errorText = _t("User %(userId)s is already invited to the room", { userId: address });
break;
case USER_ALREADY_JOINED:
errorText = _t("User %(userId)s is already in the room", { userId: address });
break;
case "M_LIMIT_EXCEEDED":
// we're being throttled so wait a bit & try again
setTimeout(() => {
this.doInvite(address, ignoreProfile).then(resolve, reject);
}, 5000);
return;
case "M_NOT_FOUND":
case "M_USER_NOT_FOUND":
errorText = _t("User %(user_id)s does not exist", { user_id: address });
break;
case "M_PROFILE_UNDISCLOSED":
errorText = _t("User %(user_id)s may or may not exist", { user_id: address });
break;
case "M_PROFILE_NOT_FOUND":
if (!ignoreProfile) {
// Invite without the profile check
console.warn(`User ${address} does not have a profile - inviting anyways automatically`);
this.doInvite(address, true).then(resolve, reject);
return;
}
break;
case "M_BAD_STATE":
errorText = _t("The user must be unbanned before they can be invited.");
break;
case "M_UNSUPPORTED_ROOM_VERSION":
errorText = _t("The user's homeserver does not support the version of the room.");
break;
}
if (!errorText) {
errorText = _t('Unknown server error');
}
this.completionStates[address] = InviteState.Error;
this.errors[address] = { errorText, errcode: err.errcode };
this.busy = !fatal;
this._fatal = fatal;
if (fatal) {
reject(err);
} else {
resolve();
}
});
});
}
private inviteMore(nextIndex: number, ignoreProfile = false): void {
if (this.canceled) {
return;
}
if (nextIndex === this.addresses.length) {
this.busy = false;
if (Object.keys(this.errors).length > 0 && !this.groupId) {
// 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) {
const inviteUnknowns = () => {
const promises = unknownProfileUsers.map(u => this.doInvite(u, true));
Promise.all(promises).then(() => this.deferred.resolve(this.completionStates));
};
if (!SettingsStore.getValue("promptBeforeInviteUnknownUsers", this.roomId)) {
inviteUnknowns();
return;
}
console.log("Showing failed to invite dialog...");
Modal.createTrackedDialog('Failed to invite', '', 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;
}
this.deferred.resolve(this.completionStates);
},
});
return;
}
}
this.deferred.resolve(this.completionStates);
return;
}
const addr = this.addresses[nextIndex];
// 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) {
this.inviteMore(nextIndex + 1);
return;
}
// 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) {
this.inviteMore(nextIndex + 1);
return;
}
this.doInvite(addr, ignoreProfile).then(() => {
this.inviteMore(nextIndex + 1, ignoreProfile);
}).catch(() => this.deferred.resolve(this.completionStates));
}
}

View file

@ -16,7 +16,7 @@ limitations under the License.
import zxcvbn from 'zxcvbn';
import {MatrixClientPeg} from '../MatrixClientPeg';
import { MatrixClientPeg } from '../MatrixClientPeg';
import { _t, _td } from '../languageHandler';
const ZXCVBN_USER_INPUTS = [
@ -63,7 +63,7 @@ _td("Short keyboard patterns are easy to guess");
* @param {string} password Password to score
* @returns {object} Score result with `score` and `feedback` properties
*/
export function scorePassword(password) {
export function scorePassword(password: string) {
if (password.length === 0) return null;
const userInputs = ZXCVBN_USER_INPUTS.slice();

View file

@ -14,13 +14,15 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import { MatrixEvent } from "matrix-js-sdk/src/models/event";
export default class PinningUtils {
/**
* Determines if the given event may be pinned.
* @param {MatrixEvent} event The event to check.
* @return {boolean} True if the event may be pinned, false otherwise.
*/
static isPinnable(event) {
static isPinnable(event: MatrixEvent): boolean {
if (!event) return false;
if (event.getType() !== "m.room.message") return false;
if (event.isRedacted()) return false;

View file

@ -1,5 +1,5 @@
/*
Copyright 2016 OpenMarket Ltd
Copyright 2016 - 2021 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
@ -14,12 +14,18 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import { MatrixEvent } from "matrix-js-sdk/src/models/event";
/**
* Given MatrixEvent containing receipts, return the first
* read receipt from the given user ID, or null if no such
* receipt exists.
*
* @param {Object} receiptEvent A Matrix Event
* @param {string} userId A user ID
* @returns {Object} Read receipt
*/
export function findReadReceiptFromUserId(receiptEvent, userId) {
export function findReadReceiptFromUserId(receiptEvent: MatrixEvent, userId: string): object | null {
const receiptKeys = Object.keys(receiptEvent.getContent());
for (let i = 0; i < receiptKeys.length; ++i) {
const rcpt = receiptEvent.getContent()[receiptKeys[i]];

View file

@ -1,59 +0,0 @@
/*
Copyright 2019 New Vector Ltd
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
/**
* Fires when the middle panel has been resized.
* @event module:utils~ResizeNotifier#"middlePanelResized"
*/
import { EventEmitter } from "events";
import { throttle } from "lodash";
export default class ResizeNotifier extends EventEmitter {
constructor() {
super();
// with default options, will call fn once at first call, and then every x ms
// if there was another call in that timespan
this._throttledMiddlePanel = throttle(() => this.emit("middlePanelResized"), 200);
}
notifyBannersChanged() {
this.emit("leftPanelResized");
this.emit("middlePanelResized");
}
// can be called in quick succession
notifyLeftHandleResized() {
// don't emit event for own region
this._throttledMiddlePanel();
}
// can be called in quick succession
notifyRightHandleResized() {
this._throttledMiddlePanel();
}
// can be called in quick succession
notifyWindowResized() {
// no need to throttle this one,
// also it could make scrollbars appear for
// a split second when the room list manual layout is now
// taller than the available space
this.emit("leftPanelResized");
this._throttledMiddlePanel();
}
}

View file

@ -0,0 +1,79 @@
/*
Copyright 2019 - 2021 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
/**
* 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() {
return this._isResizing;
}
public startResizing() {
this._isResizing = true;
this.emit("isResizing", true);
}
public stopResizing() {
this._isResizing = false;
this.emit("isResizing", false);
}
private noisyMiddlePanel() {
this.emit("middlePanelResizedNoisy");
}
private updateMiddlePanel() {
this.throttledMiddlePanel();
this.noisyMiddlePanel();
}
// can be called in quick succession
public notifyLeftHandleResized() {
// don't emit event for own region
this.updateMiddlePanel();
}
// can be called in quick succession
public notifyRightHandleResized() {
this.updateMiddlePanel();
}
public notifyTimelineHeightChanged() {
this.updateMiddlePanel();
}
// can be called in quick succession
public notifyWindowResized() {
this.updateMiddlePanel();
}
}

View file

@ -1,25 +1,32 @@
/*
Copyright 2021 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import { MatrixClient } from "matrix-js-sdk/src/client";
import { Room } from "matrix-js-sdk/src/models/room";
import DMRoomMap from './DMRoomMap';
/* For now, a cut-down type spec for the client */
interface Client {
getUserId: () => string;
checkUserTrust: (userId: string) => {
isCrossSigningVerified: () => boolean
wasCrossSigningVerified: () => boolean
};
getStoredDevicesForUser: (userId: string) => Promise<[{ deviceId: string }]>;
checkDeviceTrust: (userId: string, deviceId: string) => {
isVerified: () => boolean
}
export enum E2EStatus {
Warning = "warning",
Verified = "verified",
Normal = "normal"
}
interface Room {
getEncryptionTargetMembers: () => Promise<[{userId: string}]>;
roomId: string;
}
export async function shieldStatusForRoom(client: Client, room: Room): Promise<string> {
const members = (await room.getEncryptionTargetMembers()).map(({userId}) => userId);
export async function shieldStatusForRoom(client: MatrixClient, room: Room): Promise<E2EStatus> {
const members = (await room.getEncryptionTargetMembers()).map(({ userId }) => userId);
const inDMMap = !!DMRoomMap.shared().getUserIdForRoomId(room.roomId);
const verified: string[] = [];
@ -27,13 +34,13 @@ export async function shieldStatusForRoom(client: Client, room: Room): Promise<s
members.filter((userId) => userId !== client.getUserId())
.forEach((userId) => {
(client.checkUserTrust(userId).isCrossSigningVerified() ?
verified : unverified).push(userId);
verified : unverified).push(userId);
});
/* Alarm if any unverified users were verified before. */
for (const userId of unverified) {
if (client.checkUserTrust(userId).wasCrossSigningVerified()) {
return "warning";
return E2EStatus.Warning;
}
}
@ -45,14 +52,14 @@ export async function shieldStatusForRoom(client: Client, room: Room): Promise<s
(members.length === 1); // Do alarm for self if we're alone in a room
const targets = includeUser ? [...verified, client.getUserId()] : verified;
for (const userId of targets) {
const devices = await client.getStoredDevicesForUser(userId);
const anyDeviceNotVerified = devices.some(({deviceId}) => {
const devices = client.getStoredDevicesForUser(userId);
const anyDeviceNotVerified = devices.some(({ deviceId }) => {
return !client.checkDeviceTrust(userId, deviceId).isVerified();
});
if (anyDeviceNotVerified) {
return "warning";
return E2EStatus.Warning;
}
}
return unverified.length === 0 ? "verified" : "normal";
return unverified.length === 0 ? E2EStatus.Verified : E2EStatus.Normal;
}

126
src/utils/Singleflight.ts Normal file
View file

@ -0,0 +1,126 @@
/*
Copyright 2021 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
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 usecase 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, key: string): 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) {
keyMap.delete(instance);
}
/**
* Forgets all cached results for all instances. Intended for use by tests.
*/
public static forgetAll() {
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() {
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;
}
}

View file

@ -1,5 +1,5 @@
/*
Copyright 2019 New Vector Ltd
Copyright 2019-2021 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
@ -14,9 +14,10 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import Matrix from 'matrix-js-sdk';
import {LocalStorageCryptoStore} from 'matrix-js-sdk/src/crypto/store/localStorage-crypto-store';
import { LocalStorageCryptoStore } from 'matrix-js-sdk/src/crypto/store/localStorage-crypto-store';
import Analytics from '../Analytics';
import { IndexedDBStore } from "matrix-js-sdk/src/store/indexeddb";
import { IndexedDBCryptoStore } from "matrix-js-sdk/src/crypto/store/indexeddb-crypto-store";
const localStorage = window.localStorage;
@ -31,15 +32,15 @@ try {
const SYNC_STORE_NAME = "riot-web-sync";
const CRYPTO_STORE_NAME = "matrix-js-sdk:crypto";
function log(msg) {
function log(msg: string) {
console.log(`StorageManager: ${msg}`);
}
function error(msg) {
console.error(`StorageManager: ${msg}`);
function error(msg: string, ...args: string[]) {
console.error(`StorageManager: ${msg}`, ...args);
}
function track(action) {
function track(action: string) {
Analytics.trackEvent("StorageManager", action);
}
@ -72,7 +73,7 @@ export async function checkConsistency() {
dataInLocalStorage = localStorage.length > 0;
log(`Local storage contains data? ${dataInLocalStorage}`);
cryptoInited = localStorage.getItem("mx_crypto_initialised");
cryptoInited = !!localStorage.getItem("mx_crypto_initialised");
log(`Crypto initialised? ${cryptoInited}`);
} else {
healthy = false;
@ -132,7 +133,7 @@ export async function checkConsistency() {
async function checkSyncStore() {
let exists = false;
try {
exists = await Matrix.IndexedDBStore.exists(
exists = await IndexedDBStore.exists(
indexedDB, SYNC_STORE_NAME,
);
log(`Sync store using IndexedDB contains data? ${exists}`);
@ -148,7 +149,7 @@ async function checkSyncStore() {
async function checkCryptoStore() {
let exists = false;
try {
exists = await Matrix.IndexedDBCryptoStore.exists(
exists = await IndexedDBCryptoStore.exists(
indexedDB, CRYPTO_STORE_NAME,
);
log(`Crypto store using IndexedDB contains data? ${exists}`);
@ -190,3 +191,79 @@ export function trackStores(client) {
export function setCryptoInitialised(cryptoInited) {
localStorage.setItem("mx_crypto_initialised", cryptoInited);
}
/* Simple wrapper functions around IndexedDB.
*/
let idb = null;
async function idbInit(): Promise<void> {
if (!indexedDB) {
throw new Error("IndexedDB not available");
}
idb = await new Promise((resolve, reject) => {
const request = indexedDB.open("matrix-react-sdk", 1);
request.onerror = reject;
request.onsuccess = (event) => { resolve(request.result); };
request.onupgradeneeded = (event) => {
const db = request.result;
db.createObjectStore("pickleKey");
db.createObjectStore("account");
};
});
}
export async function idbLoad(
table: string,
key: string | string[],
): Promise<any> {
if (!idb) {
await idbInit();
}
return new Promise((resolve, reject) => {
const txn = idb.transaction([table], "readonly");
txn.onerror = reject;
const objectStore = txn.objectStore(table);
const request = objectStore.get(key);
request.onerror = reject;
request.onsuccess = (event) => { resolve(request.result); };
});
}
export async function idbSave(
table: string,
key: string | string[],
data: any,
): Promise<void> {
if (!idb) {
await idbInit();
}
return new Promise((resolve, reject) => {
const txn = idb.transaction([table], "readwrite");
txn.onerror = reject;
const objectStore = txn.objectStore(table);
const request = objectStore.put(data, key);
request.onerror = reject;
request.onsuccess = (event) => { resolve(); };
});
}
export async function idbDelete(
table: string,
key: string | string[],
): Promise<void> {
if (!idb) {
await idbInit();
}
return new Promise((resolve, reject) => {
const txn = idb.transaction([table], "readwrite");
txn.onerror = reject;
const objectStore = txn.objectStore(table);
const request = objectStore.delete(key);
request.onerror = reject;
request.onsuccess = (event) => { resolve(); };
});
}

View file

@ -1,5 +1,5 @@
/*
Copyright 2018 New Vector Ltd
Copyright 2018, 2021 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
@ -26,44 +26,48 @@ Once a timer is finished or aborted, it can't be started again
a new one through `clone()` or `cloneIfRun()`.
*/
export default class Timer {
constructor(timeout) {
this._timeout = timeout;
this._onTimeout = this._onTimeout.bind(this);
this._setNotStarted();
private timerHandle: number;
private startTs: number;
private promise: Promise<void>;
private resolve: () => void;
private reject: (Error) => void;
constructor(private timeout: number) {
this.setNotStarted();
}
_setNotStarted() {
this._timerHandle = null;
this._startTs = null;
this._promise = new Promise((resolve, reject) => {
this._resolve = resolve;
this._reject = reject;
private setNotStarted() {
this.timerHandle = null;
this.startTs = null;
this.promise = new Promise<void>((resolve, reject) => {
this.resolve = resolve;
this.reject = reject;
}).finally(() => {
this._timerHandle = null;
this.timerHandle = null;
});
}
_onTimeout() {
private onTimeout = () => {
const now = Date.now();
const elapsed = now - this._startTs;
if (elapsed >= this._timeout) {
this._resolve();
this._setNotStarted();
const elapsed = now - this.startTs;
if (elapsed >= this.timeout) {
this.resolve();
this.setNotStarted();
} else {
const delta = this._timeout - elapsed;
this._timerHandle = setTimeout(this._onTimeout, delta);
const delta = this.timeout - elapsed;
this.timerHandle = setTimeout(this.onTimeout, delta);
}
}
};
changeTimeout(timeout) {
if (timeout === this._timeout) {
changeTimeout(timeout: number) {
if (timeout === this.timeout) {
return;
}
const isSmallerTimeout = timeout < this._timeout;
this._timeout = timeout;
const isSmallerTimeout = timeout < this.timeout;
this.timeout = timeout;
if (this.isRunning() && isSmallerTimeout) {
clearTimeout(this._timerHandle);
this._onTimeout();
clearTimeout(this.timerHandle);
this.onTimeout();
}
}
@ -73,8 +77,8 @@ export default class Timer {
*/
start() {
if (!this.isRunning()) {
this._startTs = Date.now();
this._timerHandle = setTimeout(this._onTimeout, this._timeout);
this.startTs = Date.now();
this.timerHandle = setTimeout(this.onTimeout, this.timeout);
}
return this;
}
@ -89,7 +93,7 @@ export default class Timer {
// can be called in fast succession,
// instead just take note and compare
// when the already running timeout expires
this._startTs = Date.now();
this.startTs = Date.now();
return this;
} else {
return this.start();
@ -103,9 +107,9 @@ export default class Timer {
*/
abort() {
if (this.isRunning()) {
clearTimeout(this._timerHandle);
this._reject(new Error("Timer was aborted."));
this._setNotStarted();
clearTimeout(this.timerHandle);
this.reject(new Error("Timer was aborted."));
this.setNotStarted();
}
return this;
}
@ -116,10 +120,10 @@ export default class Timer {
*@return {Promise}
*/
finished() {
return this._promise;
return this.promise;
}
isRunning() {
return this._timerHandle !== null;
return this.timerHandle !== null;
}
}

View file

@ -1,5 +1,5 @@
/*
Copyright 2019 The Matrix.org Foundation C.I.C.
Copyright 2019, 2021 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
@ -26,7 +26,7 @@ export function urlSearchParamsToObject<T extends {}>(params: URLSearchParams) {
* @param {string} u The url to be abbreviated
* @returns {string} The abbreviated url
*/
export function abbreviateUrl(u) {
export function abbreviateUrl(u: string): string {
if (!u) return '';
const parsedUrl = url.parse(u);
@ -41,7 +41,7 @@ export function abbreviateUrl(u) {
return u;
}
export function unabbreviateUrl(u) {
export function unabbreviateUrl(u: string): string {
if (!u) return '';
let longUrl = u;

View file

@ -0,0 +1,78 @@
/*
Copyright 2020 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import { MatrixClientPeg } from '../MatrixClientPeg';
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";
/* eslint-disable camelcase */
export interface ICallBehaviourWellKnown {
widget_build_url?: string;
}
export interface IE2EEWellKnown {
default?: boolean;
secure_backup_required?: boolean;
secure_backup_setup_methods?: SecureBackupSetupMethod[];
}
/* eslint-enable camelcase */
export function getCallBehaviourWellKnown(): ICallBehaviourWellKnown {
const clientWellKnown = MatrixClientPeg.get().getClientWellKnown();
return clientWellKnown?.[CALL_BEHAVIOUR_WK_KEY];
}
export function getE2EEWellKnown(): IE2EEWellKnown {
const clientWellKnown = MatrixClientPeg.get().getClientWellKnown();
if (clientWellKnown && clientWellKnown[E2EE_WK_KEY]) {
return clientWellKnown[E2EE_WK_KEY];
}
if (clientWellKnown && clientWellKnown[E2EE_WK_KEY_DEPRECATED]) {
return clientWellKnown[E2EE_WK_KEY_DEPRECATED];
}
return null;
}
export function isSecureBackupRequired(): boolean {
const wellKnown = getE2EEWellKnown();
return wellKnown && wellKnown["secure_backup_required"] === true;
}
export enum SecureBackupSetupMethod {
Key = "key",
Passphrase = "passphrase",
}
export function getSecureBackupSetupMethods(): SecureBackupSetupMethod[] {
const wellKnown = getE2EEWellKnown();
if (
!wellKnown ||
!wellKnown["secure_backup_setup_methods"] ||
!wellKnown["secure_backup_setup_methods"].length ||
!(
wellKnown["secure_backup_setup_methods"].includes(SecureBackupSetupMethod.Key) ||
wellKnown["secure_backup_setup_methods"].includes(SecureBackupSetupMethod.Passphrase)
)
) {
return [
SecureBackupSetupMethod.Key,
SecureBackupSetupMethod.Passphrase,
];
}
return wellKnown["secure_backup_setup_methods"];
}

85
src/utils/Whenable.ts Normal file
View file

@ -0,0 +1,85 @@
/*
Copyright 2020 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import { IDestroyable } from "./IDestroyable";
import { arrayFastClone } from "./arrays";
export type WhenFn<T> = (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> 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) {
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) {
console.error(`Error calling whenable listener for ${condition}:`, e);
}
}
}
}
public destroy() {
this.listeners = [];
}
}

View file

@ -1,7 +1,6 @@
/*
Copyright 2017 Vector Creations Ltd
Copyright 2018 New Vector Ltd
Copyright 2019 Travis Ralston
Copyright 2017 - 2020 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
@ -16,19 +15,34 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import {MatrixClientPeg} from '../MatrixClientPeg';
import SdkConfig from "../SdkConfig";
import dis from '../dispatcher';
import * as url from "url";
import { Capability, IWidget, IWidgetData, MatrixCapabilities } from "matrix-widget-api";
import { Room } from "matrix-js-sdk/src/models/room";
import { MatrixEvent } from "matrix-js-sdk/src/models/event";
import { MatrixClientPeg } from '../MatrixClientPeg';
import SdkConfig from "../SdkConfig";
import dis from '../dispatcher/dispatcher';
import WidgetEchoStore from '../stores/WidgetEchoStore';
import SettingsStore from "../settings/SettingsStore";
import { IntegrationManagers } from "../integrations/IntegrationManagers";
import { WidgetType } from "../widgets/WidgetType";
import { objectClone } from "./objects";
import { _t } from "../languageHandler";
import { IApp } from "../stores/WidgetStore";
// 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;
import SettingsStore from "../settings/SettingsStore";
import ActiveWidgetStore from "../stores/ActiveWidgetStore";
import {IntegrationManagers} from "../integrations/IntegrationManagers";
import {Capability} from "../widgets/WidgetApi";
export interface IWidgetEvent {
id: string;
type: string;
sender: string;
// eslint-disable-next-line camelcase
state_key: string;
content: Partial<IApp>;
}
export default class WidgetUtils {
/* Returns true if user is able to send state events to modify widgets in this room
@ -37,7 +51,7 @@ export default class WidgetUtils {
* @return Boolean -- true if the user can modify widgets in this room
* @throws Error -- specifies the error reason
*/
static canUserModifyWidgets(roomId) {
static canUserModifyWidgets(roomId: string): boolean {
if (!roomId) {
console.warn('No room ID specified');
return false;
@ -66,15 +80,17 @@ export default class WidgetUtils {
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 {[type]} testUrlString URL to check
* @return {Boolean} True if specified URL is a scalar URL
*/
static isScalarUrl(testUrlString) {
static isScalarUrl(testUrlString: string): boolean {
if (!testUrlString) {
console.error('Scalar URL check failed. No URL specified');
return false;
@ -117,7 +133,7 @@ export default class WidgetUtils {
* @returns {Promise} that resolves when the widget is in the
* requested state according to the `add` param
*/
static waitForUserWidget(widgetId, add) {
static waitForUserWidget(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
@ -164,7 +180,7 @@ export default class WidgetUtils {
* @returns {Promise} that resolves when the widget is in the
* requested state according to the `add` param
*/
static waitForRoomWidget(widgetId, roomId, add) {
static waitForRoomWidget(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
@ -180,6 +196,7 @@ export default class WidgetUtils {
}
const room = MatrixClientPeg.get().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();
@ -189,6 +206,7 @@ export default class WidgetUtils {
function onRoomStateEvents(ev) {
if (ev.getRoomId() !== roomId) 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)) {
@ -205,9 +223,15 @@ export default class WidgetUtils {
});
}
static setUserWidget(widgetId, widgetType, widgetUrl, widgetName, widgetData) {
static setUserWidget(
widgetId: string,
widgetType: WidgetType,
widgetUrl: string,
widgetName: string,
widgetData: IWidgetData,
) {
const content = {
type: widgetType,
type: widgetType.preferred,
url: widgetUrl,
name: widgetName,
data: widgetData,
@ -216,7 +240,7 @@ export default class WidgetUtils {
const client = MatrixClientPeg.get();
// Get the current widgets and clone them before we modify them, otherwise
// we'll modify the content of the old event.
const userWidgets = JSON.parse(JSON.stringify(WidgetUtils.getUserWidgets()));
const userWidgets = objectClone(WidgetUtils.getUserWidgets());
// Delete existing widget with ID
try {
@ -249,14 +273,23 @@ export default class WidgetUtils {
});
}
static setRoomWidget(roomId, widgetId, widgetType, widgetUrl, widgetName, widgetData) {
static setRoomWidget(
roomId: string,
widgetId: string,
widgetType?: WidgetType,
widgetUrl?: string,
widgetName?: string,
widgetData?: object,
) {
let content;
const addingWidget = Boolean(widgetUrl);
if (addingWidget) {
content = {
type: widgetType,
// 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,
@ -265,11 +298,20 @@ export default class WidgetUtils {
content = {};
}
return WidgetUtils.setRoomWidgetContent(roomId, widgetId, content);
}
static setRoomWidgetContent(
roomId: string,
widgetId: string,
content: IWidget,
) {
const addingWidget = !!content.url;
WidgetEchoStore.setRoomWidgetEcho(roomId, widgetId, content);
const client = MatrixClientPeg.get();
// TODO - Room widgets need to be moved to 'm.widget' state events
// https://docs.google.com/document/d/1uPF7XWY_dXTKVKV7jZQ2KmsI19wn9-kFRgQ1tFQP7wQ/edit?usp=sharing
// 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(widgetId, roomId, addingWidget);
}).finally(() => {
@ -279,10 +321,11 @@ export default class WidgetUtils {
/**
* Get room specific widgets
* @param {object} room The room to get widgets force
* @param {Room} room The room to get widgets force
* @return {[object]} Array containing current / active room widgets
*/
static getRoomWidgets(room) {
static getRoomWidgets(room: Room) {
// 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 [];
@ -297,7 +340,7 @@ export default class WidgetUtils {
* Get user specific widgets (not linked to a specific room)
* @return {object} Event content object containing current / active user widgets
*/
static getUserWidgets() {
static getUserWidgets(): Record<string, IWidgetEvent> {
const client = MatrixClientPeg.get();
if (!client) {
throw new Error('User not logged in');
@ -313,7 +356,7 @@ export default class WidgetUtils {
* Get user specific widgets (not linked to a specific room) as an array
* @return {[object]} Array containing current / active user widgets
*/
static getUserWidgetsArray() {
static getUserWidgetsArray(): IWidgetEvent[] {
return Object.values(WidgetUtils.getUserWidgets());
}
@ -321,7 +364,7 @@ export default class WidgetUtils {
* Get active stickerpicker widgets (stickerpickers are user widgets by nature)
* @return {[object]} Array containing current / active stickerpicker widgets
*/
static getStickerpickerWidgets() {
static getStickerpickerWidgets(): IWidgetEvent[] {
const widgets = WidgetUtils.getUserWidgetsArray();
return widgets.filter((widget) => widget.content && widget.content.type === "m.stickerpicker");
}
@ -330,34 +373,42 @@ export default class WidgetUtils {
* Get all integration manager widgets for this user.
* @returns {Object[]} An array of integration manager user widgets.
*/
static getIntegrationManagerWidgets() {
static getIntegrationManagerWidgets(): IWidgetEvent[] {
const widgets = WidgetUtils.getUserWidgetsArray();
return widgets.filter(w => w.content && w.content.type === "m.integration_manager");
}
static removeIntegrationManagerWidgets() {
static getRoomWidgetsOfType(room: Room, type: WidgetType): MatrixEvent[] {
const widgets = WidgetUtils.getRoomWidgets(room) || [];
return widgets.filter(w => {
const content = w.getContent();
return content.url && type.matches(content.type);
});
}
static async removeIntegrationManagerWidgets(): Promise<void> {
const client = MatrixClientPeg.get();
if (!client) {
throw new Error('User not logged in');
}
const widgets = client.getAccountData('m.widgets');
if (!widgets) return;
const userWidgets = widgets.getContent() || {};
const userWidgets: Record<string, IWidgetEvent> = widgets.getContent() || {};
Object.entries(userWidgets).forEach(([key, widget]) => {
if (widget.content && widget.content.type === "m.integration_manager") {
delete userWidgets[key];
}
});
return client.setAccountData('m.widgets', userWidgets);
await client.setAccountData('m.widgets', userWidgets);
}
static addIntegrationManagerWidget(name: string, uiUrl: string, apiUrl: string) {
static addIntegrationManagerWidget(name: string, uiUrl: string, apiUrl: string): Promise<void> {
return WidgetUtils.setUserWidget(
"integration_manager_" + (new Date().getTime()),
"m.integration_manager",
WidgetType.INTEGRATION_MANAGER,
uiUrl,
"Integration Manager: " + name,
{"api_url": apiUrl},
"Integration manager: " + name,
{ "api_url": apiUrl },
);
}
@ -365,92 +416,114 @@ export default class WidgetUtils {
* Remove all stickerpicker widgets (stickerpickers are user widgets by nature)
* @return {Promise} Resolves on account data updated
*/
static removeStickerpickerWidgets() {
static async removeStickerpickerWidgets(): Promise<void> {
const client = MatrixClientPeg.get();
if (!client) {
throw new Error('User not logged in');
}
const widgets = client.getAccountData('m.widgets');
if (!widgets) return;
const userWidgets = widgets.getContent() || {};
const userWidgets: Record<string, IWidgetEvent> = widgets.getContent() || {};
Object.entries(userWidgets).forEach(([key, widget]) => {
if (widget.content && widget.content.type === 'm.stickerpicker') {
delete userWidgets[key];
}
});
return client.setAccountData('m.widgets', userWidgets);
await client.setAccountData('m.widgets', userWidgets);
}
static makeAppConfig(appId, app, senderUserId, roomId, eventId) {
static makeAppConfig(
appId: string,
app: Partial<IApp>,
senderUserId: string,
roomId: string | null,
eventId: string,
): 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;
return app as IApp;
}
static getCapWhitelistForAppTypeInRoomId(appType, roomId) {
static getCapWhitelistForAppTypeInRoomId(appType: string, roomId: string): Capability[] {
const enableScreenshots = SettingsStore.getValue("enableWidgetScreenshots", roomId);
const capWhitelist = enableScreenshots ? [Capability.Screenshot] : [];
const capWhitelist = enableScreenshots ? [MatrixCapabilities.Screenshots] : [];
// Obviously anyone that can add a widget can claim it's a jitsi widget,
// so this doesn't really offer much over the set of domains we load
// widgets from at all, but it probably makes sense for sanity.
if (appType === 'jitsi') {
capWhitelist.push(Capability.AlwaysOnScreen);
if (WidgetType.JITSI.matches(appType)) {
capWhitelist.push(MatrixCapabilities.AlwaysOnScreen);
}
return capWhitelist;
}
static getWidgetSecurityKey(widgetId, widgetUrl, isUserWidget) {
let widgetLocation = ActiveWidgetStore.getRoomId(widgetId);
if (isUserWidget) {
const userWidget = WidgetUtils.getUserWidgetsArray()
.find((w) => w.id === widgetId && w.content && w.content.url === widgetUrl);
if (!userWidget) {
throw new Error("No matching user widget to form security key");
}
widgetLocation = userWidget.sender;
}
if (!widgetLocation) {
throw new Error("Failed to locate where the widget resides");
}
return encodeURIComponent(`${widgetLocation}::${widgetUrl}`);
}
static getLocalJitsiWrapperUrl(opts: {forLocalRender?: boolean}={}) {
static getLocalJitsiWrapperUrl(opts: {forLocalRender?: boolean, auth?: string} = {}) {
// NB. we can't just encodeURIComponent all of these because the $ signs need to be there
const queryString = [
const queryStringParts = [
'conferenceDomain=$domain',
'conferenceId=$conferenceId',
'isAudioOnly=$isAudioOnly',
'displayName=$matrix_display_name',
'avatarUrl=$matrix_avatar_url',
'userId=$matrix_user_id',
].join('&');
'roomId=$matrix_room_id',
'theme=$theme',
'roomName=$roomName',
];
if (opts.auth) {
queryStringParts.push(`auth=${opts.auth}`);
}
const queryString = queryStringParts.join('&');
let baseUrl = window.location;
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 = "https://riot.im/app/";
baseUrl = "https://app.element.io/";
}
const url = new URL("jitsi.html#" + queryString, baseUrl); // this strips hash fragment from baseUrl
return url.href;
}
static getWidgetName(app?: IApp): string {
return app?.name?.trim() || _t("Unknown App");
}
static getWidgetDataTitle(app?: IApp): string {
return app?.data?.title?.trim() || "";
}
static editWidget(room: Room, app: IApp): void {
// TODO: Open the right manager for the widget
if (SettingsStore.getValue("feature_many_integration_managers")) {
IntegrationManagers.sharedInstance().openAll(room, 'type_' + app.type, app.id);
} else {
IntegrationManagers.sharedInstance().getPrimaryManager().open(room, 'type_' + app.type, app.id);
}
}
static isManagedByManager(app) {
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;
}
}

304
src/utils/arrays.ts Normal file
View file

@ -0,0 +1,304 @@
/*
Copyright 2020, 2021 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import { percentageOf, percentageWithin } from "./numbers";
/**
* 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
}
}
/**
* 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[]): { added: T[], removed: T[] } {
return {
added: b.filter(i => !a.includes(i)),
removed: a.filter(i => !b.includes(i)),
};
}
/**
* Returns the union of two arrays.
* @param a The first array. Must be defined.
* @param b The second array. Must be defined.
* @returns The union of the arrays.
*/
export function arrayUnion<T>(a: T[], b: T[]): T[] {
return a.filter(i => b.includes(i));
}
/**
* Merges arrays, deduping contents using a Set.
* @param a The arrays to merge.
* @returns The merged array.
*/
export function arrayMerge<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.
*/
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.
*/
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);
}
}

78
src/utils/blobs.ts Normal file
View file

@ -0,0 +1,78 @@
/*
Copyright 2021 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
// 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',
'video/mp4',
'video/webm',
'video/ogg',
'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',
];
export function getBlobSafeMimeType(mimetype: string): string {
if (!ALLOWED_BLOB_MIMETYPES.includes(mimetype)) {
return 'application/octet-stream';
}
return mimetype;
}

View file

@ -1,67 +0,0 @@
/*
Copyright 2019 Michael Telatynski <7t3chguy@gmail.com>
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
export function hueToRGB(h, s, l) {
const c = s * (1 - Math.abs(2 * l - 1));
const x = c * (1 - Math.abs((h / 60) % 2 - 1));
const m = l - c / 2;
let r = 0;
let g = 0;
let b = 0;
if (0 <= h && h < 60) {
r = c;
g = x;
b = 0;
} else if (60 <= h && h < 120) {
r = x;
g = c;
b = 0;
} else if (120 <= h && h < 180) {
r = 0;
g = c;
b = x;
} else if (180 <= h && h < 240) {
r = 0;
g = x;
b = c;
} else if (240 <= h && h < 300) {
r = x;
g = 0;
b = c;
} else if (300 <= h && h < 360) {
r = c;
g = 0;
b = x;
}
return [Math.round((r + m) * 255), Math.round((g + m) * 255), Math.round((b + m) * 255)];
}
export function textToHtmlRainbow(str) {
const frequency = 360 / str.length;
return Array.from(str).map((c, i) => {
const [r, g, b] = hueToRGB(i * frequency, 1.0, 0.5);
return '<font color="#' +
r.toString(16).padStart(2, "0") +
g.toString(16).padStart(2, "0") +
b.toString(16).padStart(2, "0") +
'">' + c + '</font>';
}).join("");
}

88
src/utils/colour.ts Normal file
View file

@ -0,0 +1,88 @@
/*
Copyright 2019 Michael Telatynski <7t3chguy@gmail.com>
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
export function textToHtmlRainbow(str: string): string {
const frequency = (2 * Math.PI) / str.length;
return Array.from(str)
.map((c, i) => {
if (c === " ") {
return c;
}
const [a, b] = generateAB(i * frequency, 1);
const [red, green, blue] = labToRGB(75, a, b);
return (
'<font color="#' +
red.toString(16).padStart(2, "0") +
green.toString(16).padStart(2, "0") +
blue.toString(16).padStart(2, "0") +
'">' +
c +
"</font>"
);
})
.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);
}

View file

@ -1,5 +1,5 @@
/*
Copyright 2017 Vector Creations Ltd
Copyright 2017 - 2021 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
@ -14,7 +14,12 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import * as Matrix from 'matrix-js-sdk';
// @ts-ignore - `.ts` is needed here to make TS happy
import IndexedDBWorker from "../workers/indexeddb.worker.ts";
import { createClient, ICreateClientOpts } from "matrix-js-sdk/src/matrix";
import { IndexedDBCryptoStore } from "matrix-js-sdk/src/crypto/store/indexeddb-crypto-store";
import { WebStorageSessionStore } from "matrix-js-sdk/src/store/session/webstorage";
import { IndexedDBStore } from "matrix-js-sdk/src/store/indexeddb";
const localStorage = window.localStorage;
@ -32,39 +37,34 @@ try {
* @param {Object} opts options to pass to Matrix.createClient. This will be
* extended with `sessionStore` and `store` members.
*
* @property {string} indexedDbWorkerScript Optional URL for a web worker script
* for IndexedDB store operations. By default, indexeddb ops are done on
* the main thread.
*
* @returns {MatrixClient} the newly-created MatrixClient
*/
export default function createMatrixClient(opts) {
const storeOpts = {
export default function createMatrixClient(opts: ICreateClientOpts) {
const storeOpts: Partial<ICreateClientOpts> = {
useAuthorizationHeader: true,
};
if (indexedDB && localStorage) {
storeOpts.store = new Matrix.IndexedDBStore({
storeOpts.store = new IndexedDBStore({
indexedDB: indexedDB,
dbName: "riot-web-sync",
localStorage: localStorage,
workerScript: createMatrixClient.indexedDbWorkerScript,
workerFactory: () => new IndexedDBWorker(),
});
}
if (localStorage) {
storeOpts.sessionStore = new Matrix.WebStorageSessionStore(localStorage);
storeOpts.sessionStore = new WebStorageSessionStore(localStorage);
}
if (indexedDB) {
storeOpts.cryptoStore = new Matrix.IndexedDBCryptoStore(
storeOpts.cryptoStore = new IndexedDBCryptoStore(
indexedDB, "matrix-js-sdk:crypto",
);
}
opts = Object.assign(storeOpts, opts);
return Matrix.createClient(opts);
return createClient({
...storeOpts,
...opts,
});
}
createMatrixClient.indexedDbWorkerScript = null;

49
src/utils/enums.ts Normal file
View file

@ -0,0 +1,49 @@
/*
Copyright 2020, 2021 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
/**
* 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);
}

View file

@ -1,5 +1,5 @@
/*
Copyright 2020 The Matrix.org Foundation C.I.C.
Copyright 2020, 2021 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
@ -14,22 +14,22 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import {_t} from "../languageHandler";
import { _t } from "../languageHandler";
// These are the constants we use for when to break the text
const MILLISECONDS_RECENT = 15000;
const MILLISECONDS_1_MIN = 75000;
const MINUTES_UNDER_1_HOUR = 45;
const MINUTES_1_HOUR = 75;
const HOURS_UNDER_1_DAY = 23;
const HOURS_1_DAY = 26;
/**
* Converts a timestamp into human-readable, translated, text.
* @param {number} timeMillis The time in millis to compare against.
* @returns {string} The humanized time.
*/
export function humanizeTime(timeMillis) {
// These are the constants we use for when to break the text
const MILLISECONDS_RECENT = 15000;
const MILLISECONDS_1_MIN = 75000;
const MINUTES_UNDER_1_HOUR = 45;
const MINUTES_1_HOUR = 75;
const HOURS_UNDER_1_DAY = 23;
const HOURS_1_DAY = 26;
export function humanizeTime(timeMillis: number): string {
const now = (new Date()).getTime();
let msAgo = now - timeMillis;
const minutes = Math.abs(Math.ceil(msAgo / 60000));
@ -39,19 +39,19 @@ export function humanizeTime(timeMillis) {
if (msAgo >= 0) { // Past
if (msAgo <= MILLISECONDS_RECENT) return _t("a few seconds ago");
if (msAgo <= MILLISECONDS_1_MIN) return _t("about a minute ago");
if (minutes <= MINUTES_UNDER_1_HOUR) return _t("%(num)s minutes ago", {num: minutes});
if (minutes <= MINUTES_UNDER_1_HOUR) return _t("%(num)s minutes ago", { num: minutes });
if (minutes <= MINUTES_1_HOUR) return _t("about an hour ago");
if (hours <= HOURS_UNDER_1_DAY) return _t("%(num)s hours ago", {num: hours});
if (hours <= HOURS_UNDER_1_DAY) return _t("%(num)s hours ago", { num: hours });
if (hours <= HOURS_1_DAY) return _t("about a day ago");
return _t("%(num)s days ago", {num: days});
return _t("%(num)s days ago", { num: days });
} else { // Future
msAgo = Math.abs(msAgo);
if (msAgo <= MILLISECONDS_RECENT) return _t("a few seconds from now");
if (msAgo <= MILLISECONDS_1_MIN) return _t("about a minute from now");
if (minutes <= MINUTES_UNDER_1_HOUR) return _t("%(num)s minutes from now", {num: minutes});
if (minutes <= MINUTES_UNDER_1_HOUR) return _t("%(num)s minutes from now", { num: minutes });
if (minutes <= MINUTES_1_HOUR) return _t("about an hour from now");
if (hours <= HOURS_UNDER_1_DAY) return _t("%(num)s hours from now", {num: hours});
if (hours <= HOURS_UNDER_1_DAY) return _t("%(num)s hours from now", { num: hours });
if (hours <= HOURS_1_DAY) return _t("about a day from now");
return _t("%(num)s days from now", {num: days});
return _t("%(num)s days from now", { num: days });
}
}

25
src/utils/iterables.ts Normal file
View file

@ -0,0 +1,25 @@
/*
* Copyright 2020 The Matrix.org Foundation C.I.C.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { arrayDiff, arrayUnion } from "./arrays";
export function iterableUnion<T>(a: Iterable<T>, b: Iterable<T>): Iterable<T> {
return arrayUnion(Array.from(a), Array.from(b));
}
export function iterableDiff<T>(a: Iterable<T>, b: Iterable<T>): { added: Iterable<T>, removed: Iterable<T> } {
return arrayDiff(Array.from(a), Array.from(b));
}

69
src/utils/maps.ts Normal file
View file

@ -0,0 +1,69 @@
/*
Copyright 2020 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import { arrayDiff, arrayMerge, arrayUnion } from "./arrays";
/**
* Determines the keys added, changed, and removed between two Maps.
* For changes, simple triple equal comparisons are done, not in-depth tree checking.
* @param a The first Map. Must be defined.
* @param b The second Map. Must be defined.
* @returns The difference between the keys of each Map.
*/
export function mapDiff<K, V>(a: Map<K, V>, b: Map<K, V>): { changed: K[], added: K[], removed: K[] } {
const aKeys = [...a.keys()];
const bKeys = [...b.keys()];
const keyDiff = arrayDiff(aKeys, bKeys);
const possibleChanges = arrayUnion(aKeys, bKeys);
const changes = possibleChanges.filter(k => a.get(k) !== b.get(k));
return { changed: changes, added: keyDiff.added, removed: keyDiff.removed };
}
/**
* Gets all the key changes (added, removed, or value difference) between two Maps.
* Triple equals is used to compare values, not in-depth tree checking.
* @param a The first Map. Must be defined.
* @param b The second Map. Must be defined.
* @returns The keys which have been added, removed, or changed between the two Maps.
*/
export function mapKeyChanges<K, V>(a: Map<K, V>, b: Map<K, V>): K[] {
const diff = mapDiff(a, b);
return arrayMerge(diff.removed, diff.added, diff.changed);
}
/**
* A Map<K, V> with added utility.
*/
export class EnhancedMap<K, V> extends Map<K, V> {
public constructor(entries?: Iterable<[K, V]>) {
super(entries);
}
public getOrCreate(key: K, def: V): V {
if (this.has(key)) {
return this.get(key);
}
this.set(key, def);
return def;
}
public remove(key: K): V {
const v = this.get(key);
this.delete(key);
return v;
}
}

145
src/utils/membership.ts Normal file
View file

@ -0,0 +1,145 @@
/*
Copyright 2020 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import { Room } from "matrix-js-sdk/src/models/room";
import { MatrixClientPeg } from "../MatrixClientPeg";
import { _t } from "../languageHandler";
import Modal from "../Modal";
import ErrorDialog from "../components/views/dialogs/ErrorDialog";
import React from "react";
import dis from "../dispatcher/dispatcher";
import RoomViewStore from "../stores/RoomViewStore";
/**
* Approximation of a membership status for a given room.
*/
export enum EffectiveMembership {
/**
* The user is effectively joined to the room. For example, actually joined
* or knocking on the room (when that becomes possible).
*/
Join = "JOIN",
/**
* The user is effectively invited to the room. Currently this is a direct map
* to the invite membership as no other membership states are effectively
* invites.
*/
Invite = "INVITE",
/**
* The user is effectively no longer in the room. For example, kicked,
* banned, or voluntarily left.
*/
Leave = "LEAVE",
}
export interface MembershipSplit {
// @ts-ignore - TS wants this to be a string key, but we know better.
[state: EffectiveMembership]: Room[];
}
export function splitRoomsByMembership(rooms: Room[]): MembershipSplit {
const split: MembershipSplit = {
[EffectiveMembership.Invite]: [],
[EffectiveMembership.Join]: [],
[EffectiveMembership.Leave]: [],
};
for (const room of rooms) {
split[getEffectiveMembership(room.getMyMembership())].push(room);
}
return split;
}
export function getEffectiveMembership(membership: string): EffectiveMembership {
if (membership === 'invite') {
return EffectiveMembership.Invite;
} else if (membership === 'join') {
// TODO: Include knocks? Update docs as needed in the enum. https://github.com/vector-im/element-web/issues/14237
return EffectiveMembership.Join;
} else {
// Probably a leave, kick, or ban
return EffectiveMembership.Leave;
}
}
export function isJoinedOrNearlyJoined(membership: string): boolean {
const effective = getEffectiveMembership(membership);
return effective === EffectiveMembership.Join || effective === EffectiveMembership.Invite;
}
export async function leaveRoomBehaviour(roomId: string) {
let leavingAllVersions = true;
const history = await MatrixClientPeg.get().getRoomUpgradeHistory(roomId);
if (history && history.length > 0) {
const currentRoom = history[history.length - 1];
if (currentRoom.roomId !== roomId) {
// The user is trying to leave an older version of the room. Let them through
// without making them leave the current version of the room.
leavingAllVersions = false;
}
}
let results: { [roomId: string]: Error & { errcode: string, message: string } } = {};
if (!leavingAllVersions) {
try {
await MatrixClientPeg.get().leave(roomId);
} catch (e) {
if (e && e.data && e.data.errcode) {
const message = e.data.error || _t("Unexpected server error trying to leave the room");
results[roomId] = Object.assign(new Error(message), { errcode: e.data.errcode });
} else {
results[roomId] = e || new Error("Failed to leave room for unknown causes");
}
}
} else {
results = await MatrixClientPeg.get().leaveRoomChain(roomId);
}
const errors = Object.entries(results).filter(r => !!r[1]);
if (errors.length > 0) {
const messages = [];
for (const roomErr of errors) {
const err = roomErr[1]; // [0] is the roomId
let message = _t("Unexpected server error trying to leave the room");
if (err.errcode && err.message) {
if (err.errcode === 'M_CANNOT_LEAVE_SERVER_NOTICE_ROOM') {
Modal.createTrackedDialog('Error Leaving Room', '', ErrorDialog, {
title: _t("Can't leave Server Notices room"),
description: _t(
"This room is used for important messages from the Homeserver, " +
"so you cannot leave it.",
),
});
return;
}
message = results[roomId].message;
}
messages.push(message, React.createElement('BR')); // createElement to avoid using a tsx file in utils
}
Modal.createTrackedDialog('Error Leaving Room', '', ErrorDialog, {
title: _t("Error leaving room"),
description: messages,
});
return;
}
if (RoomViewStore.getRoomId() === roomId) {
dis.dispatch({ action: 'view_home_page' });
}
}

42
src/utils/numbers.ts Normal file
View file

@ -0,0 +1,42 @@
/*
Copyright 2021 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
/**
* Returns the default number if the given value, i, is not a number. Otherwise
* returns the given value.
* @param {*} i The value to check.
* @param {number} def The default value.
* @returns {number} Either the value or the default value, whichever is a number.
*/
export function defaultNumber(i: unknown, def: number): number {
return Number.isFinite(i) ? Number(i) : def;
}
export function clamp(i: number, min: number, max: number): number {
return Math.min(Math.max(i, min), max);
}
export function sum(...i: number[]): number {
return [...i].reduce((p, c) => c + p, 0);
}
export function percentageWithin(pct: number, min: number, max: number): number {
return (pct * (max - min)) + min;
}
export function percentageOf(val: number, min: number, max: number): number {
return (val - min) / (max - min);
}

161
src/utils/objects.ts Normal file
View file

@ -0,0 +1,161 @@
/*
Copyright 2020, 2021 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import { arrayDiff, arrayMerge, arrayUnion } from "./arrays";
type ObjectExcluding<O extends {}, P extends (keyof O)[]> = {[k in Exclude<keyof O, P[number]>]: O[k]};
/**
* Gets a new object which represents the provided object, excluding some properties.
* @param a The object to strip properties of. Must be defined.
* @param props The property names to remove.
* @returns The new object without the provided properties.
*/
export function objectExcluding<O extends {}, P extends Array<keyof O>>(a: O, props: P): ObjectExcluding<O, P> {
// We use a Map to avoid hammering the `delete` keyword, which is slow and painful.
const tempMap = new Map<keyof O, any>(Object.entries(a) as [keyof O, any][]);
for (const prop of props) {
tempMap.delete(prop);
}
// Convert the map to an object again
return Array.from(tempMap.entries()).reduce((c, [k, v]) => {
c[k] = v;
return c;
}, {} as O);
}
/**
* Gets a new object which represents the provided object, with only some properties
* included.
* @param a The object to clone properties of. Must be defined.
* @param props The property names to keep.
* @returns The new object with only the provided properties.
*/
export function objectWithOnly<O extends {}, P extends Array<keyof O>>(a: O, props: P): {[k in P[number]]: O[k]} {
const existingProps = Object.keys(a) as (keyof O)[];
const diff = arrayDiff(existingProps, props);
if (diff.removed.length === 0) {
return objectShallowClone(a);
} else {
return objectExcluding(a, diff.removed) as {[k in P[number]]: O[k]};
}
}
/**
* Clones an object to a caller-controlled depth. When a propertyCloner is supplied, the
* object's properties will be passed through it with the return value used as the new
* object's type. This is intended to be used to deep clone a reference, but without
* having to deep clone the entire object. This function is safe to call recursively within
* the propertyCloner.
* @param a The object to clone. Must be defined.
* @param propertyCloner The function to clone the properties of the object with, optionally.
* First argument is the property key with the second being the current value.
* @returns A cloned object.
*/
export function objectShallowClone<O extends {}>(a: O, propertyCloner?: (k: keyof O, v: O[keyof O]) => any): O {
const newObj = {} as O;
for (const [k, v] of Object.entries(a) as [keyof O, O[keyof O]][]) {
newObj[k] = v;
if (propertyCloner) {
newObj[k] = propertyCloner(k, v);
}
}
return newObj;
}
/**
* Determines if any keys were added, removed, or changed between two objects.
* For changes, simple triple equal comparisons are done, not in-depth
* tree checking.
* @param a The first object. Must be defined.
* @param b The second object. Must be defined.
* @returns True if there's a difference between the objects, false otherwise
*/
export function objectHasDiff<O extends {}>(a: O, b: O): boolean {
if (a === b) return false;
const aKeys = Object.keys(a);
const bKeys = Object.keys(b);
if (aKeys.length !== bKeys.length) return true;
const possibleChanges = arrayUnion(aKeys, bKeys);
// if the amalgamation of both sets of keys has the a different length to the inputs then there must be a change
if (possibleChanges.length !== aKeys.length) return true;
return possibleChanges.some(k => a[k] !== b[k]);
}
type Diff<K> = { changed: K[], added: K[], removed: K[] };
/**
* Determines the keys added, changed, and removed between two objects.
* For changes, simple triple equal comparisons are done, not in-depth
* tree checking.
* @param a The first object. Must be defined.
* @param b The second object. Must be defined.
* @returns The difference between the keys of each object.
*/
export function objectDiff<O extends {}>(a: O, b: O): Diff<keyof O> {
const aKeys = Object.keys(a) as (keyof O)[];
const bKeys = Object.keys(b) as (keyof O)[];
const keyDiff = arrayDiff(aKeys, bKeys);
const possibleChanges = arrayUnion(aKeys, bKeys);
const changes = possibleChanges.filter(k => a[k] !== b[k]);
return { changed: changes, added: keyDiff.added, removed: keyDiff.removed };
}
/**
* Gets all the key changes (added, removed, or value difference) between
* two objects. Triple equals is used to compare values, not in-depth tree
* checking.
* @param a The first object. Must be defined.
* @param b The second object. Must be defined.
* @returns The keys which have been added, removed, or changed between the
* two objects.
*/
export function objectKeyChanges<O extends {}>(a: O, b: O): (keyof O)[] {
const diff = objectDiff(a, b);
return arrayMerge(diff.removed, diff.added, diff.changed);
}
/**
* Clones an object by running it through JSON parsing. Note that this
* will destroy any complicated object types which do not translate to
* JSON.
* @param obj The object to clone.
* @returns The cloned object
*/
export function objectClone<O extends {}>(obj: O): O {
return JSON.parse(JSON.stringify(obj));
}
/**
* Converts a series of entries to an object.
* @param entries The entries to convert.
* @returns The converted object.
*/
// NOTE: Deprecated once we have Object.fromEntries() support.
// @ts-ignore - return type is complaining about non-string keys, but we know better
export function objectFromEntries<K, V>(entries: Iterable<[K, V]>): {[k: K]: V} {
const obj: {
// @ts-ignore - same as return type
[k: K]: V;} = {};
for (const e of entries) {
// @ts-ignore - same as return type
obj[e[0]] = e[1];
}
return obj;
}

View file

@ -1,5 +1,5 @@
/*
Copyright 2019 New Vector Ltd
Copyright 2019, 2021 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
@ -14,12 +14,12 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
export function getHomePageUrl(appConfig) {
import { ConfigOptions } from "../SdkConfig";
export function getHomePageUrl(appConfig: ConfigOptions): string | null {
const pagesConfig = appConfig.embeddedPages;
let pageUrl = null;
if (pagesConfig) {
pageUrl = pagesConfig.homeUrl;
}
let pageUrl = pagesConfig?.homeUrl;
if (!pageUrl) {
// This is a deprecated config option for the home page
// (despite the name, given we also now have a welcome
@ -29,3 +29,8 @@ export function getHomePageUrl(appConfig) {
return pageUrl;
}
export function shouldUseLoginForWelcome(appConfig: ConfigOptions): boolean {
const pagesConfig = appConfig.embeddedPages;
return pagesConfig?.loginForWelcome === true;
}

View file

@ -1,5 +1,5 @@
/*
Copyright 2019 The Matrix.org Foundation C.I.C.
Copyright 2019, 2021 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
@ -14,37 +14,37 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import PermalinkConstructor, {PermalinkParts} from "./PermalinkConstructor";
import PermalinkConstructor, { PermalinkParts } from "./PermalinkConstructor";
/**
* Generates permalinks that self-reference the running webapp
*/
export default class RiotPermalinkConstructor extends PermalinkConstructor {
_riotUrl: string;
export default class ElementPermalinkConstructor extends PermalinkConstructor {
private elementUrl: string;
constructor(riotUrl: string) {
constructor(elementUrl: string) {
super();
this._riotUrl = riotUrl;
this.elementUrl = elementUrl;
if (!this._riotUrl.startsWith("http:") && !this._riotUrl.startsWith("https:")) {
throw new Error("Riot prefix URL does not appear to be an HTTP(S) URL");
if (!this.elementUrl.startsWith("http:") && !this.elementUrl.startsWith("https:")) {
throw new Error("Element prefix URL does not appear to be an HTTP(S) URL");
}
}
forEvent(roomId: string, eventId: string, serverCandidates: string[]): string {
return `${this._riotUrl}/#/room/${roomId}/${eventId}${this.encodeServerCandidates(serverCandidates)}`;
return `${this.elementUrl}/#/room/${roomId}/${eventId}${this.encodeServerCandidates(serverCandidates)}`;
}
forRoom(roomIdOrAlias: string, serverCandidates: string[]): string {
return `${this._riotUrl}/#/room/${roomIdOrAlias}${this.encodeServerCandidates(serverCandidates)}`;
forRoom(roomIdOrAlias: string, serverCandidates?: string[]): string {
return `${this.elementUrl}/#/room/${roomIdOrAlias}${this.encodeServerCandidates(serverCandidates)}`;
}
forUser(userId: string): string {
return `${this._riotUrl}/#/user/${userId}`;
return `${this.elementUrl}/#/user/${userId}`;
}
forGroup(groupId: string): string {
return `${this._riotUrl}/#/group/${groupId}`;
return `${this.elementUrl}/#/group/${groupId}`;
}
forEntity(entityId: string): string {
@ -58,28 +58,45 @@ export default class RiotPermalinkConstructor extends PermalinkConstructor {
}
isPermalinkHost(testHost: string): boolean {
const parsedUrl = new URL(this._riotUrl);
const parsedUrl = new URL(this.elementUrl);
return testHost === (parsedUrl.host || parsedUrl.hostname); // one of the hosts should match
}
encodeServerCandidates(candidates: string[]) {
encodeServerCandidates(candidates?: string[]) {
if (!candidates || candidates.length === 0) return '';
return `?via=${candidates.map(c => encodeURIComponent(c)).join("&via=")}`;
}
// Heavily inspired by/borrowed from the matrix-bot-sdk (with permission):
// https://github.com/turt2live/matrix-js-bot-sdk/blob/7c4665c9a25c2c8e0fe4e509f2616505b5b66a1c/src/Permalinks.ts#L33-L61
// Adapted for Riot's URL format
// Adapted for Element's URL format
parsePermalink(fullUrl: string): PermalinkParts {
if (!fullUrl || !fullUrl.startsWith(this._riotUrl)) {
if (!fullUrl || !fullUrl.startsWith(this.elementUrl)) {
throw new Error("Does not appear to be a permalink");
}
const parts = fullUrl.substring(`${this._riotUrl}/#/`.length).split("/");
const parts = fullUrl.substring(`${this.elementUrl}/#/`.length);
return ElementPermalinkConstructor.parseAppRoute(parts);
}
/**
* Parses an app route (`(user|room|group)/identifer`) to a Matrix entity
* (room, user, group).
* @param {string} route The app route
* @returns {PermalinkParts}
*/
static parseAppRoute(route: string): PermalinkParts {
const parts = route.split("/");
if (parts.length < 2) { // we're expecting an entity and an ID of some kind at least
throw new Error("URL is missing parts");
}
// Split optional query out of last part
const [lastPartMaybeWithQuery] = parts.splice(-1, 1);
const [lastPart, query = ""] = lastPartMaybeWithQuery.split("?");
parts.push(lastPart);
const entityType = parts[0];
const entity = parts[1];
if (entityType === 'user') {
@ -89,20 +106,9 @@ export default class RiotPermalinkConstructor extends PermalinkConstructor {
// Probably a group, no further parsing needed.
return PermalinkParts.forGroup(entity);
} else if (entityType === 'room') {
if (parts.length === 2) {
return PermalinkParts.forRoom(entity, []);
}
// rejoin the rest because v3 events can have slashes (annoyingly)
const eventIdAndQuery = parts.length > 2 ? parts.slice(2).join('/') : "";
const secondaryParts = eventIdAndQuery.split("?");
const eventId = secondaryParts[0];
const query = secondaryParts.length > 1 ? secondaryParts[1] : "";
// TODO: Verify Riot works with via args
const via = query.split("via=").filter(p => !!p);
// Rejoin the rest because v3 events can have slashes (annoyingly)
const eventId = parts.length > 2 ? parts.slice(2).join('/') : "";
const via = query.split(/&?via=/).filter(p => !!p);
return PermalinkParts.forEvent(entity, eventId, via);
} else {
throw new Error("Unknown entity type in permalink");

View file

@ -1,5 +1,5 @@
/*
Copyright 2019 The Matrix.org Foundation C.I.C.
Copyright 2019, 2021 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
@ -19,11 +19,11 @@ limitations under the License.
* TODO: Convert this to a real TypeScript interface
*/
export default class PermalinkConstructor {
forEvent(roomId: string, eventId: string, serverCandidates: string[]): string {
forEvent(roomId: string, eventId: string, serverCandidates: string[] = []): string {
throw new Error("Not implemented");
}
forRoom(roomIdOrAlias: string, serverCandidates: string[]): string {
forRoom(roomIdOrAlias: string, serverCandidates: string[] = []): string {
throw new Error("Not implemented");
}
@ -73,11 +73,19 @@ export class PermalinkParts {
return new PermalinkParts(null, null, null, groupId, null);
}
static forRoom(roomIdOrAlias: string, viaServers: string[]): PermalinkParts {
return new PermalinkParts(roomIdOrAlias, null, null, null, viaServers || []);
static forRoom(roomIdOrAlias: string, viaServers: string[] = []): PermalinkParts {
return new PermalinkParts(roomIdOrAlias, null, null, null, viaServers);
}
static forEvent(roomId: string, eventId: string, viaServers: string[]): PermalinkParts {
return new PermalinkParts(roomId, eventId, null, null, viaServers || []);
static forEvent(roomId: string, eventId: string, viaServers: string[] = []): PermalinkParts {
return new PermalinkParts(roomId, eventId, null, null, viaServers);
}
get primaryEntityId(): string {
return this.roomIdOrAlias || this.userId || this.groupId;
}
get sigil(): string {
return this.primaryEntityId[0];
}
}

View file

@ -1,5 +1,5 @@
/*
Copyright 2019 New Vector Ltd
Copyright 2019, 2021 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
@ -14,12 +14,17 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import {MatrixClientPeg} from "../../MatrixClientPeg";
import isIp from "is-ip";
import * as utils from 'matrix-js-sdk/src/utils';
import SpecPermalinkConstructor, {baseUrl as matrixtoBaseUrl} from "./SpecPermalinkConstructor";
import PermalinkConstructor, {PermalinkParts} from "./PermalinkConstructor";
import RiotPermalinkConstructor from "./RiotPermalinkConstructor";
import * as utils from "matrix-js-sdk/src/utils";
import { Room } from "matrix-js-sdk/src/models/room";
import { EventType } from "matrix-js-sdk/src/@types/event";
import { MatrixEvent } from "matrix-js-sdk/src/models/event";
import { RoomMember } from "matrix-js-sdk/src/models/room-member";
import { MatrixClientPeg } from "../../MatrixClientPeg";
import SpecPermalinkConstructor, { baseUrl as matrixtoBaseUrl } from "./SpecPermalinkConstructor";
import PermalinkConstructor, { PermalinkParts } from "./PermalinkConstructor";
import ElementPermalinkConstructor from "./ElementPermalinkConstructor";
import matrixLinkify from "../../linkify-matrix";
import SdkConfig from "../../SdkConfig";
@ -27,7 +32,6 @@ import SdkConfig from "../../SdkConfig";
// to add to permalinks. The servers are appended as ?via=example.org
const MAX_SERVER_CANDIDATES = 3;
// Permalinks can have servers appended to them so that the user
// receiving them can have a fighting chance at joining the room.
// These servers are called "candidates" at this point because
@ -72,29 +76,35 @@ const MAX_SERVER_CANDIDATES = 3;
// the list and magically have the link work.
export class RoomPermalinkCreator {
private room: Room;
private roomId: string;
private highestPlUserId: string;
private populationMap: { [serverName: string]: number };
private bannedHostsRegexps: RegExp[];
private allowedHostsRegexps: RegExp[];
private _serverCandidates: string[];
private started: boolean;
// We support being given a roomId as a fallback in the event the `room` object
// doesn't exist or is not healthy for us to rely on. For example, loading a
// permalink to a room which the MatrixClient doesn't know about.
constructor(room, roomId = null) {
this._room = room;
this._roomId = room ? room.roomId : roomId;
this._highestPlUserId = null;
this._populationMap = null;
this._bannedHostsRegexps = null;
this._allowedHostsRegexps = null;
constructor(room: Room, roomId: string = null) {
this.room = room;
this.roomId = room ? room.roomId : roomId;
this.highestPlUserId = null;
this.populationMap = null;
this.bannedHostsRegexps = null;
this.allowedHostsRegexps = null;
this._serverCandidates = null;
this._started = false;
this.started = false;
if (!this._roomId) {
if (!this.roomId) {
throw new Error("Failed to resolve a roomId for the permalink creator to use");
}
this.onMembership = this.onMembership.bind(this);
this.onRoomState = this.onRoomState.bind(this);
}
load() {
if (!this._room || !this._room.currentState) {
if (!this.room || !this.room.currentState) {
// Under rare and unknown circumstances it is possible to have a room with no
// currentState, at least potentially at the early stages of joining a room.
// To avoid breaking everything, we'll just warn rather than throw as well as
@ -102,53 +112,68 @@ export class RoomPermalinkCreator {
console.warn("Tried to load a permalink creator with no room state");
return;
}
this._updateAllowedServers();
this._updateHighestPlUser();
this._updatePopulationMap();
this._updateServerCandidates();
this.updateAllowedServers();
this.updateHighestPlUser();
this.updatePopulationMap();
this.updateServerCandidates();
}
start() {
this.load();
this._room.on("RoomMember.membership", this.onMembership);
this._room.on("RoomState.events", this.onRoomState);
this._started = true;
this.room.on("RoomMember.membership", this.onMembership);
this.room.on("RoomState.events", this.onRoomState);
this.started = true;
}
stop() {
this._room.removeListener("RoomMember.membership", this.onMembership);
this._room.removeListener("RoomState.events", this.onRoomState);
this._started = false;
this.room.removeListener("RoomMember.membership", this.onMembership);
this.room.removeListener("RoomState.events", this.onRoomState);
this.started = false;
}
get serverCandidates() {
return this._serverCandidates;
}
isStarted() {
return this._started;
return this.started;
}
forEvent(eventId) {
return getPermalinkConstructor().forEvent(this._roomId, eventId, this._serverCandidates);
forEvent(eventId: string): string {
return getPermalinkConstructor().forEvent(this.roomId, eventId, this._serverCandidates);
}
forRoom() {
return getPermalinkConstructor().forRoom(this._roomId, this._serverCandidates);
forShareableRoom(): string {
if (this.room) {
// Prefer to use canonical alias for permalink if possible
const alias = this.room.getCanonicalAlias();
if (alias) {
return getPermalinkConstructor().forRoom(alias);
}
}
return getPermalinkConstructor().forRoom(this.roomId, this._serverCandidates);
}
onRoomState(event) {
forRoom(): string {
return getPermalinkConstructor().forRoom(this.roomId, this._serverCandidates);
}
private onRoomState = (event: MatrixEvent) => {
switch (event.getType()) {
case "m.room.server_acl":
this._updateAllowedServers();
this._updateHighestPlUser();
this._updatePopulationMap();
this._updateServerCandidates();
case EventType.RoomServerAcl:
this.updateAllowedServers();
this.updateHighestPlUser();
this.updatePopulationMap();
this.updateServerCandidates();
return;
case "m.room.power_levels":
this._updateHighestPlUser();
this._updateServerCandidates();
case EventType.RoomPowerLevels:
this.updateHighestPlUser();
this.updateServerCandidates();
return;
}
}
};
onMembership(evt, member, oldMembership) {
private onMembership = (evt: MatrixEvent, member: RoomMember, oldMembership: string) => {
const userId = member.userId;
const membership = member.membership;
const serverName = getServerName(userId);
@ -156,17 +181,17 @@ export class RoomPermalinkCreator {
const hasLeft = oldMembership === "join" && membership !== "join";
if (hasLeft) {
this._populationMap[serverName]--;
this.populationMap[serverName]--;
} else if (hasJoined) {
this._populationMap[serverName]++;
this.populationMap[serverName]++;
}
this._updateHighestPlUser();
this._updateServerCandidates();
}
this.updateHighestPlUser();
this.updateServerCandidates();
};
_updateHighestPlUser() {
const plEvent = this._room.currentState.getStateEvents("m.room.power_levels", "");
private updateHighestPlUser() {
const plEvent = this.room.currentState.getStateEvents("m.room.power_levels", "");
if (plEvent) {
const content = plEvent.getContent();
if (content) {
@ -174,14 +199,14 @@ export class RoomPermalinkCreator {
if (users) {
const entries = Object.entries(users);
const allowedEntries = entries.filter(([userId]) => {
const member = this._room.getMember(userId);
const member = this.room.getMember(userId);
if (!member || member.membership !== "join") {
return false;
}
const serverName = getServerName(userId);
return !isHostnameIpAddress(serverName) &&
!isHostInRegex(serverName, this._bannedHostsRegexps) &&
isHostInRegex(serverName, this._allowedHostsRegexps);
!isHostInRegex(serverName, this.bannedHostsRegexps) &&
isHostInRegex(serverName, this.allowedHostsRegexps);
});
const maxEntry = allowedEntries.reduce((max, entry) => {
return (entry[1] > max[1]) ? entry : max;
@ -189,20 +214,20 @@ export class RoomPermalinkCreator {
const [userId, powerLevel] = maxEntry;
// object wasn't empty, and max entry wasn't a demotion from the default
if (userId !== null && powerLevel >= 50) {
this._highestPlUserId = userId;
this.highestPlUserId = userId;
return;
}
}
}
}
this._highestPlUserId = null;
this.highestPlUserId = null;
}
_updateAllowedServers() {
private updateAllowedServers() {
const bannedHostsRegexps = [];
let allowedHostsRegexps = [new RegExp(".*")]; // default allow everyone
if (this._room.currentState) {
const aclEvent = this._room.currentState.getStateEvents("m.room.server_acl", "");
if (this.room.currentState) {
const aclEvent = this.room.currentState.getStateEvents("m.room.server_acl", "");
if (aclEvent && aclEvent.getContent()) {
const getRegex = (hostname) => new RegExp("^" + utils.globToRegexp(hostname, false) + "$");
@ -214,35 +239,35 @@ export class RoomPermalinkCreator {
allowed.forEach(h => allowedHostsRegexps.push(getRegex(h)));
}
}
this._bannedHostsRegexps = bannedHostsRegexps;
this._allowedHostsRegexps = allowedHostsRegexps;
this.bannedHostsRegexps = bannedHostsRegexps;
this.allowedHostsRegexps = allowedHostsRegexps;
}
_updatePopulationMap() {
private updatePopulationMap() {
const populationMap: { [server: string]: number } = {};
for (const member of this._room.getJoinedMembers()) {
for (const member of this.room.getJoinedMembers()) {
const serverName = getServerName(member.userId);
if (!populationMap[serverName]) {
populationMap[serverName] = 0;
}
populationMap[serverName]++;
}
this._populationMap = populationMap;
this.populationMap = populationMap;
}
_updateServerCandidates() {
private updateServerCandidates() {
let candidates = [];
if (this._highestPlUserId) {
candidates.push(getServerName(this._highestPlUserId));
if (this.highestPlUserId) {
candidates.push(getServerName(this.highestPlUserId));
}
const serversByPopulation = Object.keys(this._populationMap)
.sort((a, b) => this._populationMap[b] - this._populationMap[a])
const serversByPopulation = Object.keys(this.populationMap)
.sort((a, b) => this.populationMap[b] - this.populationMap[a])
.filter(a => {
return !candidates.includes(a) &&
!isHostnameIpAddress(a) &&
!isHostInRegex(a, this._bannedHostsRegexps) &&
isHostInRegex(a, this._allowedHostsRegexps);
!isHostInRegex(a, this.bannedHostsRegexps) &&
isHostInRegex(a, this.allowedHostsRegexps);
});
const remainingServers = serversByPopulation.slice(0, MAX_SERVER_CANDIDATES - candidates.length);
@ -256,11 +281,11 @@ export function makeGenericPermalink(entityId: string): string {
return getPermalinkConstructor().forEntity(entityId);
}
export function makeUserPermalink(userId) {
export function makeUserPermalink(userId: string): string {
return getPermalinkConstructor().forUser(userId);
}
export function makeRoomPermalink(roomId) {
export function makeRoomPermalink(roomId: string): string {
if (!roomId) {
throw new Error("can't permalink a falsey roomId");
}
@ -276,10 +301,10 @@ export function makeRoomPermalink(roomId) {
}
const permalinkCreator = new RoomPermalinkCreator(room);
permalinkCreator.load();
return permalinkCreator.forRoom();
return permalinkCreator.forShareableRoom();
}
export function makeGroupPermalink(groupId) {
export function makeGroupPermalink(groupId: string): string {
return getPermalinkConstructor().forGroup(groupId);
}
@ -320,18 +345,26 @@ export function tryTransformPermalinkToLocalHref(permalink: string): string {
return permalink;
}
const m = permalink.match(matrixLinkify.VECTOR_URL_PATTERN);
if (m) {
return m[1];
try {
const m = decodeURIComponent(permalink).match(matrixLinkify.ELEMENT_URL_PATTERN);
if (m) {
return m[1];
}
} catch (e) {
// Not a valid URI
return permalink;
}
// A bit of a hack to convert permalinks of unknown origin to Riot links
// A bit of a hack to convert permalinks of unknown origin to Element links
try {
const permalinkParts = parsePermalink(permalink);
if (permalinkParts) {
if (permalinkParts.roomIdOrAlias) {
const eventIdPart = permalinkParts.eventId ? `/${permalinkParts.eventId}` : '';
permalink = `#/room/${permalinkParts.roomIdOrAlias}${eventIdPart}`;
if (permalinkParts.viaServers.length > 0) {
permalink += new SpecPermalinkConstructor().encodeServerCandidates(permalinkParts.viaServers);
}
} else if (permalinkParts.groupId) {
permalink = `#/group/${permalinkParts.groupId}`;
} else if (permalinkParts.userId) {
@ -351,10 +384,10 @@ export function getPrimaryPermalinkEntity(permalink: string): string {
// If not a permalink, try the vector patterns.
if (!permalinkParts) {
const m = permalink.match(matrixLinkify.VECTOR_URL_PATTERN);
const m = permalink.match(matrixLinkify.ELEMENT_URL_PATTERN);
if (m) {
// A bit of a hack, but it gets the job done
const handler = new RiotPermalinkConstructor("http://localhost");
const handler = new ElementPermalinkConstructor("http://localhost");
const entityInfo = m[1].split('#').slice(1).join('#');
permalinkParts = handler.parsePermalink(`http://localhost/#${entityInfo}`);
}
@ -372,43 +405,60 @@ export function getPrimaryPermalinkEntity(permalink: string): string {
}
function getPermalinkConstructor(): PermalinkConstructor {
const riotPrefix = SdkConfig.get()['permalinkPrefix'];
if (riotPrefix && riotPrefix !== matrixtoBaseUrl) {
return new RiotPermalinkConstructor(riotPrefix);
const elementPrefix = SdkConfig.get()['permalinkPrefix'];
if (elementPrefix && elementPrefix !== matrixtoBaseUrl) {
return new ElementPermalinkConstructor(elementPrefix);
}
return new SpecPermalinkConstructor();
}
export function parsePermalink(fullUrl: string): PermalinkParts {
const riotPrefix = SdkConfig.get()['permalinkPrefix'];
if (fullUrl.startsWith(matrixtoBaseUrl)) {
return new SpecPermalinkConstructor().parsePermalink(fullUrl);
} else if (riotPrefix && fullUrl.startsWith(riotPrefix)) {
return new RiotPermalinkConstructor(riotPrefix).parsePermalink(fullUrl);
const elementPrefix = SdkConfig.get()['permalinkPrefix'];
if (decodeURIComponent(fullUrl).startsWith(matrixtoBaseUrl)) {
return new SpecPermalinkConstructor().parsePermalink(decodeURIComponent(fullUrl));
} else if (elementPrefix && fullUrl.startsWith(elementPrefix)) {
return new ElementPermalinkConstructor(elementPrefix).parsePermalink(fullUrl);
}
return null; // not a permalink we can handle
}
function getServerName(userId) {
/**
* Parses an app local link (`#/(user|room|group)/identifer`) to a Matrix entity
* (room, user, group). Such links are produced by `HtmlUtils` when encountering
* links, which calls `tryTransformPermalinkToLocalHref` in this module.
* @param {string} localLink The app local link
* @returns {PermalinkParts}
*/
export function parseAppLocalLink(localLink: string): PermalinkParts {
try {
const segments = localLink.replace("#/", "");
return ElementPermalinkConstructor.parseAppRoute(segments);
} catch (e) {
// Ignore failures
}
return null;
}
function getServerName(userId: string): string {
return userId.split(":").splice(1).join(":");
}
function getHostnameFromMatrixDomain(domain) {
function getHostnameFromMatrixDomain(domain: string): string {
if (!domain) return null;
return new URL(`https://${domain}`).hostname;
}
function isHostInRegex(hostname, regexps) {
function isHostInRegex(hostname: string, regexps: RegExp[]) {
hostname = getHostnameFromMatrixDomain(hostname);
if (!hostname) return true; // assumed
if (regexps.length > 0 && !regexps[0].test) throw new Error(regexps[0]);
if (regexps.length > 0 && !regexps[0].test) throw new Error(regexps[0].toString());
return regexps.filter(h => h.test(hostname)).length > 0;
}
function isHostnameIpAddress(hostname) {
function isHostnameIpAddress(hostname: string): boolean {
hostname = getHostnameFromMatrixDomain(hostname);
if (!hostname) return false;
@ -420,3 +470,9 @@ function isHostnameIpAddress(hostname) {
return isIp(hostname);
}
export const calculateRoomVia = (room: Room) => {
const permalinkCreator = new RoomPermalinkCreator(room);
permalinkCreator.load();
return permalinkCreator.serverCandidates;
};

View file

@ -14,7 +14,7 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import PermalinkConstructor, {PermalinkParts} from "./PermalinkConstructor";
import PermalinkConstructor, { PermalinkParts } from "./PermalinkConstructor";
export const host = "matrix.to";
export const baseUrl = `https://${host}`;

View file

@ -1,5 +1,5 @@
/*
Copyright 2019, 2020 The Matrix.org Foundation C.I.C.
Copyright 2019, 2020, 2021 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
@ -16,25 +16,28 @@ limitations under the License.
import React from "react";
import ReactDOM from 'react-dom';
import {MatrixClientPeg} from '../MatrixClientPeg';
import { PushProcessor } from 'matrix-js-sdk/src/pushprocessor';
import { MatrixEvent } from "matrix-js-sdk/src/models/event";
import { MatrixClientPeg } from '../MatrixClientPeg';
import SettingsStore from "../settings/SettingsStore";
import {PushProcessor} from 'matrix-js-sdk/src/pushprocessor';
import * as sdk from '../index';
import Pill from "../components/views/elements/Pill";
import { parseAppLocalLink } from "./permalinks/Permalinks";
/**
* Recurses depth-first through a DOM tree, converting matrix.to links
* into pills based on the context of a given room. Returns a list of
* the resulting React nodes so they can be unmounted rather than leaking.
*
* @param {Node[]} nodes - a list of sibling DOM nodes to traverse to try
* @param {Element[]} nodes - a list of sibling DOM nodes to traverse to try
* to turn into pills.
* @param {MatrixEvent} mxEvent - the matrix event which the DOM nodes are
* part of representing.
* @param {Node[]} pills: an accumulator of the DOM nodes which contain
* @param {Element[]} pills: an accumulator of the DOM nodes which contain
* React components which have been mounted as part of this.
* The initial caller should pass in an empty array to seed the accumulator.
*/
export function pillifyLinks(nodes, mxEvent, pills) {
export function pillifyLinks(nodes: ArrayLike<Element>, mxEvent: MatrixEvent, pills: Element[]) {
const room = MatrixClientPeg.get().getRoom(mxEvent.getRoomId());
const shouldShowPillAvatar = SettingsStore.getValue("Pill.shouldShowPillAvatar");
let node = nodes[0];
@ -43,10 +46,10 @@ export function pillifyLinks(nodes, mxEvent, pills) {
if (node.tagName === "A" && node.getAttribute("href")) {
const href = node.getAttribute("href");
const parts = parseAppLocalLink(href);
// If the link is a (localised) matrix.to link, replace it with a pill
const Pill = sdk.getComponent('elements.Pill');
if (Pill.isMessagePillUrl(href)) {
// We don't want to pill event permalinks, so those are ignored.
if (parts && !parts.eventId) {
const pillContainer = document.createElement('span');
const pill = <Pill
@ -72,9 +75,7 @@ export function pillifyLinks(nodes, mxEvent, pills) {
// to clear the pills from the last run of pillifyLinks
!node.parentElement.classList.contains("mx_AtRoomPill")
) {
const Pill = sdk.getComponent('elements.Pill');
let currentTextNode = node;
let currentTextNode = node as Node as Text;
const roomNotifTextNodes = [];
// Take a textNode and break it up to make all the instances of @room their
@ -111,7 +112,7 @@ export function pillifyLinks(nodes, mxEvent, pills) {
type={Pill.TYPE_AT_ROOM_MENTION}
inMessage={true}
room={room}
shouldShowPillAvatar={true}
shouldShowPillAvatar={shouldShowPillAvatar}
/>;
ReactDOM.render(pill, pillContainer);
@ -126,10 +127,10 @@ export function pillifyLinks(nodes, mxEvent, pills) {
}
if (node.childNodes && node.childNodes.length && !pillified) {
pillifyLinks(node.childNodes, mxEvent, pills);
pillifyLinks(node.childNodes as NodeListOf<Element>, mxEvent, pills);
}
node = node.nextSibling;
node = node.nextSibling as Element;
}
}
@ -139,12 +140,12 @@ export function pillifyLinks(nodes, mxEvent, pills) {
* It's critical to call this after pillifyLinks, otherwise
* Pills will leak, leaking entire DOM trees via the event
* emitter on BaseAvatar as per
* https://github.com/vector-im/riot-web/issues/12417
* https://github.com/vector-im/element-web/issues/12417
*
* @param {Node[]} pills - array of pill containers whose React
* @param {Element[]} pills - array of pill containers whose React
* components should be unmounted.
*/
export function unmountPills(pills) {
export function unmountPills(pills: Element[]) {
for (const pillContainer of pills) {
ReactDOM.unmountComponentAtNode(pillContainer);
}

26
src/utils/presence.ts Normal file
View file

@ -0,0 +1,26 @@
/*
Copyright 2020 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import { MatrixClientPeg } from "../MatrixClientPeg";
import SdkConfig from "../SdkConfig";
export function isPresenceEnabled() {
const hsUrl = MatrixClientPeg.get().baseUrl;
const urls = SdkConfig.get()['enable_presence_by_hs_url'];
if (!urls) return true;
if (urls[hsUrl] || urls[hsUrl] === undefined) return true;
return false;
}

View file

@ -1,63 +0,0 @@
/*
Copyright 2019 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
// @flow
// Returns a promise which resolves with a given value after the given number of ms
export const sleep = (ms: number, value: any): Promise => new Promise((resolve => { setTimeout(resolve, ms, value); }));
// Returns a promise which resolves when the input promise resolves with its value
// or when the timeout of ms is reached with the value of given timeoutValue
export async function timeout(promise: Promise, timeoutValue: any, ms: number): Promise {
const timeoutPromise = new Promise((resolve) => {
const timeoutId = setTimeout(resolve, ms, timeoutValue);
promise.then(() => {
clearTimeout(timeoutId);
});
});
return Promise.race([promise, timeoutPromise]);
}
// Returns a Deferred
export function defer(): {resolve: () => {}, reject: () => {}, promise: Promise} {
let resolve;
let reject;
const promise = new Promise((_resolve, _reject) => {
resolve = _resolve;
reject = _reject;
});
return {resolve, reject, promise};
}
// Promise.allSettled polyfill until browser support is stable in Firefox
export function allSettled(promises: Promise[]): {status: string, value?: any, reason?: any}[] {
if (Promise.allSettled) {
return Promise.allSettled(promises);
}
return Promise.all(promises.map((promise) => {
return promise.then(value => ({
status: "fulfilled",
value,
})).catch(reason => ({
status: "rejected",
reason,
}));
}));
}

46
src/utils/promise.ts Normal file
View file

@ -0,0 +1,46 @@
/*
Copyright 2019, 2020 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
// Returns a promise which resolves when the input promise resolves with its value
// or when the timeout of ms is reached with the value of given timeoutValue
export async function timeout<T>(promise: Promise<T>, timeoutValue: T, ms: number): Promise<T> {
const timeoutPromise = new Promise<T>((resolve) => {
const timeoutId = setTimeout(resolve, ms, timeoutValue);
promise.then(() => {
clearTimeout(timeoutId);
});
});
return Promise.race([promise, timeoutPromise]);
}
// Helper method to retry a Promise a given number of times or until a predicate fails
export async function retry<T, E extends Error>(fn: () => Promise<T>, num: number, predicate?: (e: E) => boolean) {
let lastErr: E;
for (let i = 0; i < num; i++) {
try {
const v = await fn();
// If `await fn()` throws then we won't reach here
return v;
} catch (err) {
if (predicate && !predicate(err)) {
throw err;
}
lastErr = err;
}
}
throw lastErr;
}

View file

@ -0,0 +1,34 @@
/*
Copyright 2020 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import { MatrixEvent } from "matrix-js-sdk/src/models/event";
import { MatrixClient } from "matrix-js-sdk/src/client";
/**
* Determines if a read receipt update event includes the client's own user.
* @param event The event to check.
* @param client The client to check against.
* @returns True if the read receipt update includes the client, false otherwise.
*/
export function readReceiptChangeIsFor(event: MatrixEvent, client: MatrixClient): boolean {
const myUserId = client.getUserId();
for (const eventId of Object.keys(event.getContent())) {
const receiptUsers = Object.keys(event.getContent()[eventId]['m.read'] || {});
if (receiptUsers.includes(myUserId)) {
return true;
}
}
}

View file

@ -30,9 +30,11 @@ import * as sdk from '../index';
* @param {string} name The dot-path name of the component being replaced.
* @param {React.Component} origComponent The component that can be replaced
* with a skinned version. If no skinned version is available, this component
* will be used.
* will be used. Note that this is automatically provided to the function and
* thus is optional for purposes of types.
* @returns {ClassDecorator} The decorator.
*/
export function replaceableComponent(name: string, origComponent: React.Component) {
export function replaceableComponent(name: string, origComponent?: React.Component): ClassDecorator {
// Decorators return a function to override the class (origComponent). This
// ultimately assumes that `getComponent()` won't throw an error and instead
// return a falsey value like `null` when the skin doesn't have a component.

34
src/utils/sets.ts Normal file
View file

@ -0,0 +1,34 @@
/*
Copyright 2021 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
/**
* Determines if two sets are different through a shallow comparison.
* @param a The first set. Must be defined.
* @param b The second set. Must be defined.
* @returns True if they are different, false otherwise.
*/
export function setHasDiff<T>(a: Set<T>, b: Set<T>): boolean {
if (a.size === b.size) {
// When the lengths are equal, check to see if either set is missing an element from the other.
if (Array.from(b).some(i => !a.has(i))) return true;
if (Array.from(a).some(i => !b.has(i))) return true;
// if all the keys are common, say so
return false;
} else {
return true; // different lengths means they are naturally diverged
}
}

105
src/utils/space.tsx Normal file
View file

@ -0,0 +1,105 @@
/*
Copyright 2021 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import React from "react";
import { Room } from "matrix-js-sdk/src/models/room";
import { MatrixClient } from "matrix-js-sdk/src/client";
import { EventType } from "matrix-js-sdk/src/@types/event";
import { calculateRoomVia } from "../utils/permalinks/Permalinks";
import Modal from "../Modal";
import SpaceSettingsDialog from "../components/views/dialogs/SpaceSettingsDialog";
import AddExistingToSpaceDialog from "../components/views/dialogs/AddExistingToSpaceDialog";
import CreateRoomDialog from "../components/views/dialogs/CreateRoomDialog";
import createRoom, { IOpts } from "../createRoom";
import { _t } from "../languageHandler";
import SpacePublicShare from "../components/views/spaces/SpacePublicShare";
import InfoDialog from "../components/views/dialogs/InfoDialog";
import { showRoomInviteDialog } from "../RoomInvite";
export const shouldShowSpaceSettings = (cli: MatrixClient, space: Room) => {
const userId = cli.getUserId();
return space.getMyMembership() === "join"
&& (space.currentState.maySendStateEvent(EventType.RoomAvatar, userId)
|| space.currentState.maySendStateEvent(EventType.RoomName, userId)
|| space.currentState.maySendStateEvent(EventType.RoomTopic, userId)
|| space.currentState.maySendStateEvent(EventType.RoomJoinRules, userId));
};
export const makeSpaceParentEvent = (room: Room, canonical = false) => ({
type: EventType.SpaceParent,
content: {
"via": calculateRoomVia(room),
"canonical": canonical,
},
state_key: room.roomId,
});
export const showSpaceSettings = (cli: MatrixClient, space: Room) => {
Modal.createTrackedDialog("Space Settings", "", SpaceSettingsDialog, {
matrixClient: cli,
space,
}, /*className=*/null, /*isPriority=*/false, /*isStatic=*/true);
};
export const showAddExistingRooms = async (cli: MatrixClient, space: Room) => {
return Modal.createTrackedDialog(
"Space Landing",
"Add Existing",
AddExistingToSpaceDialog,
{
matrixClient: cli,
onCreateRoomClick: showCreateNewRoom,
space,
},
"mx_AddExistingToSpaceDialog_wrapper",
).finished;
};
export const showCreateNewRoom = async (cli: MatrixClient, space: Room) => {
const modal = Modal.createTrackedDialog<[boolean, IOpts]>(
"Space Landing",
"Create Room",
CreateRoomDialog,
{
defaultPublic: space.getJoinRule() === "public",
parentSpace: space,
},
);
const [shouldCreate, opts] = await modal.finished;
if (shouldCreate) {
await createRoom(opts);
}
return shouldCreate;
};
export const showSpaceInvite = (space: Room, initialText = "") => {
if (space.getJoinRule() === "public") {
const modal = Modal.createTrackedDialog("Space Invite", "User Menu", InfoDialog, {
title: _t("Invite to %(spaceName)s", { spaceName: space.name }),
description: <React.Fragment>
<span>{ _t("Share your public space") }</span>
<SpacePublicShare space={space} onFinished={() => modal.close()} />
</React.Fragment>,
fixedWidth: false,
button: false,
className: "mx_SpacePanel_sharePublicSpace",
hasCloseButton: true,
});
} else {
showRoomInviteDialog(space.roomId, initialText);
}
};

View file

@ -0,0 +1,148 @@
/*
Copyright 2021 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import { alphabetPad, baseToString, stringToBase, DEFAULT_ALPHABET } from "matrix-js-sdk/src/utils";
import { moveElement } from "./arrays";
export function midPointsBetweenStrings(
a: string,
b: string,
count: number,
maxLen: number,
alphabet = DEFAULT_ALPHABET,
): string[] {
const padN = Math.min(Math.max(a.length, b.length), maxLen);
const padA = alphabetPad(a, padN, alphabet);
const padB = alphabetPad(b, padN, alphabet);
const baseA = stringToBase(padA, alphabet);
const baseB = stringToBase(padB, alphabet);
if (baseB - baseA - BigInt(1) < count) {
if (padN < maxLen) {
// this recurses once at most due to the new limit of n+1
return midPointsBetweenStrings(
alphabetPad(padA, padN + 1, alphabet),
alphabetPad(padB, padN + 1, alphabet),
count,
padN + 1,
alphabet,
);
}
return [];
}
const step = (baseB - baseA) / BigInt(count + 1);
const start = BigInt(baseA + step);
return Array(count).fill(undefined).map((_, i) => baseToString(start + (BigInt(i) * step), alphabet));
}
interface IEntry {
index: number;
order: string;
}
export const reorderLexicographically = (
orders: Array<string | undefined>,
fromIndex: number,
toIndex: number,
maxLen = 50,
): IEntry[] => {
// sanity check inputs
if (
fromIndex < 0 || toIndex < 0 ||
fromIndex > orders.length || toIndex > orders.length ||
fromIndex === toIndex
) {
return [];
}
// zip orders with their indices to simplify later index wrangling
const ordersWithIndices: IEntry[] = orders.map((order, index) => ({ index, order }));
// apply the fundamental order update to the zipped array
const newOrder = moveElement(ordersWithIndices, fromIndex, toIndex);
// check if we have to fill undefined orders to complete placement
const orderToLeftUndefined = newOrder[toIndex - 1]?.order === undefined;
let leftBoundIdx = toIndex;
let rightBoundIdx = toIndex;
let canMoveLeft = true;
const nextBase = newOrder[toIndex + 1]?.order !== undefined
? stringToBase(newOrder[toIndex + 1].order)
: BigInt(Number.MAX_VALUE);
// check how far left we would have to mutate to fit in that direction
for (let i = toIndex - 1, j = 1; i >= 0; i--, j++) {
if (newOrder[i]?.order !== undefined && nextBase - stringToBase(newOrder[i].order) > j) break;
leftBoundIdx = i;
}
// verify the left move would be sufficient
const firstOrderBase = newOrder[0].order === undefined ? undefined : stringToBase(newOrder[0].order);
const bigToIndex = BigInt(toIndex);
if (leftBoundIdx === 0 &&
firstOrderBase !== undefined &&
nextBase - firstOrderBase <= bigToIndex &&
firstOrderBase <= bigToIndex
) {
canMoveLeft = false;
}
const canDisplaceRight = !orderToLeftUndefined;
let canMoveRight = canDisplaceRight;
if (canDisplaceRight) {
const prevBase = newOrder[toIndex - 1]?.order !== undefined
? stringToBase(newOrder[toIndex - 1]?.order)
: BigInt(Number.MIN_VALUE);
// check how far right we would have to mutate to fit in that direction
for (let i = toIndex + 1, j = 1; i < newOrder.length; i++, j++) {
if (newOrder[i]?.order === undefined || stringToBase(newOrder[i].order) - prevBase > j) break;
rightBoundIdx = i;
}
// verify the right move would be sufficient
if (rightBoundIdx === newOrder.length - 1 &&
(newOrder[rightBoundIdx]
? stringToBase(newOrder[rightBoundIdx].order)
: BigInt(Number.MAX_VALUE)) - prevBase <= (rightBoundIdx - toIndex)
) {
canMoveRight = false;
}
}
// pick the cheaper direction
const leftDiff = canMoveLeft ? toIndex - leftBoundIdx : Number.MAX_SAFE_INTEGER;
const rightDiff = canMoveRight ? rightBoundIdx - toIndex : Number.MAX_SAFE_INTEGER;
if (orderToLeftUndefined || leftDiff < rightDiff) {
rightBoundIdx = toIndex;
} else {
leftBoundIdx = toIndex;
}
const prevOrder = newOrder[leftBoundIdx - 1]?.order ?? "";
const nextOrder = newOrder[rightBoundIdx + 1]?.order
?? DEFAULT_ALPHABET.charAt(DEFAULT_ALPHABET.length - 1).repeat(prevOrder.length || 1);
const changes = midPointsBetweenStrings(prevOrder, nextOrder, 1 + rightBoundIdx - leftBoundIdx, maxLen);
return changes.map((order, i) => ({
index: newOrder[leftBoundIdx + i].index,
order,
}));
};

85
src/utils/strings.ts Normal file
View file

@ -0,0 +1,85 @@
/*
Copyright 2020 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
/**
* Copy plaintext to user's clipboard
* It will overwrite user's selection range
* In certain browsers it may only work if triggered by a user action or may ask user for permissions
* Tries to use new async clipboard API if available
* @param text the plaintext to put in the user's clipboard
*/
export async function copyPlaintext(text: string): Promise<boolean> {
try {
if (navigator && navigator.clipboard && navigator.clipboard.writeText) {
await navigator.clipboard.writeText(text);
return true;
} else {
const textArea = document.createElement("textarea");
textArea.value = text;
// Avoid scrolling to bottom
textArea.style.top = "0";
textArea.style.left = "0";
textArea.style.position = "fixed";
document.body.appendChild(textArea);
const selection = document.getSelection();
const range = document.createRange();
// range.selectNodeContents(textArea);
range.selectNode(textArea);
selection.removeAllRanges();
selection.addRange(range);
const successful = document.execCommand("copy");
selection.removeAllRanges();
document.body.removeChild(textArea);
return successful;
}
} catch (e) {
console.error("copyPlaintext failed", e);
}
return false;
}
export function selectText(target: Element) {
const range = document.createRange();
range.selectNodeContents(target);
const selection = window.getSelection();
selection.removeAllRanges();
selection.addRange(range);
}
/**
* Copy rich text to user's clipboard
* It will overwrite user's selection range
* In certain browsers it may only work if triggered by a user action or may ask user for permissions
* @param ref pointer to the node to copy
*/
export function copyNode(ref: Element): boolean {
selectText(ref);
return document.execCommand('copy');
}
const collator = new Intl.Collator();
/**
* Performant language-sensitive string comparison
* @param a the first string to compare
* @param b the second string to compare
*/
export function compare(a: string, b: string): number {
return collator.compare(a, b);
}

View file

@ -14,16 +14,14 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import * as projectNameGenerator from "project-name-generator";
/**
* Generates a human readable identifier. This should not be used for anything
* which needs secure/cryptographic random: just a level uniquness that is offered
* by something like Date.now().
* @returns {string} The randomly generated ID
/* Simple utils for formatting style values
*/
export function generateHumanReadableId(): string {
return projectNameGenerator({words: 3}).raw.map(w => {
return w[0].toUpperCase() + w.substring(1).toLowerCase();
}).join('');
// converts a pixel value to rem.
export function toRem(pixelValue: number): string {
return pixelValue / 10 + "rem";
}
export function toPx(pixelValue: number): string {
return pixelValue + "px";
}