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

24
src/@types/common.ts Normal file
View file

@ -0,0 +1,24 @@
/*
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 { JSXElementConstructor } from "react";
// Based on https://stackoverflow.com/a/53229857/3532235
export type Without<T, U> = {[P in Exclude<keyof T, keyof U>]?: never};
export type XOR<T, U> = (T | U) extends object ? (Without<T, U> & U) | (Without<U, T> & T) : T | U;
export type Writeable<T> = { -readonly [P in keyof T]: T[P] };
export type ComponentClass = keyof JSX.IntrinsicElements | JSXElementConstructor<any>;

38
src/@types/diff-dom.ts Normal file
View file

@ -0,0 +1,38 @@
/*
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.
*/
declare module "diff-dom" {
export interface IDiff {
action: string;
name: string;
text?: string;
route: number[];
value: string;
element: unknown;
oldValue: string;
newValue: string;
}
interface IOpts {
}
export class DiffDOM {
public constructor(opts?: IOpts);
public apply(tree: unknown, diffs: IDiff[]): unknown;
public undo(tree: unknown, diffs: IDiff[]): unknown;
public diff(a: HTMLElement | string, b: HTMLElement | string): IDiff[];
}
}

169
src/@types/global.d.ts vendored
View file

@ -1,5 +1,5 @@
/*
Copyright 2020 New Vector Ltd
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,18 +14,175 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import * as ModernizrStatic from "modernizr";
import "matrix-js-sdk/src/@types/global"; // load matrix-js-sdk's type extensions first
// Load types for the WG CSS Font Loading APIs https://github.com/Microsoft/TypeScript/issues/13569
import "@types/css-font-loading-module";
import "@types/modernizr";
import ContentMessages from "../ContentMessages";
import { IMatrixClientPeg } from "../MatrixClientPeg";
import ToastStore from "../stores/ToastStore";
import DeviceListener from "../DeviceListener";
import { RoomListStoreClass } from "../stores/room-list/RoomListStore";
import { PlatformPeg } from "../PlatformPeg";
import RoomListLayoutStore from "../stores/room-list/RoomListLayoutStore";
import { IntegrationManagers } from "../integrations/IntegrationManagers";
import { ModalManager } from "../Modal";
import SettingsStore from "../settings/SettingsStore";
import { ActiveRoomObserver } from "../ActiveRoomObserver";
import { Notifier } from "../Notifier";
import type { Renderer } from "react-dom";
import RightPanelStore from "../stores/RightPanelStore";
import WidgetStore from "../stores/WidgetStore";
import CallHandler from "../CallHandler";
import { Analytics } from "../Analytics";
import CountlyAnalytics from "../CountlyAnalytics";
import UserActivity from "../UserActivity";
import { ModalWidgetStore } from "../stores/ModalWidgetStore";
import { WidgetLayoutStore } from "../stores/widgets/WidgetLayoutStore";
import VoipUserMapper from "../VoipUserMapper";
import { SpaceStoreClass } from "../stores/SpaceStore";
import TypingStore from "../stores/TypingStore";
import { EventIndexPeg } from "../indexing/EventIndexPeg";
import { VoiceRecordingStore } from "../stores/VoiceRecordingStore";
import PerformanceMonitor from "../performance";
import UIStore from "../stores/UIStore";
import { SetupEncryptionStore } from "../stores/SetupEncryptionStore";
import { RoomScrollStateStore } from "../stores/RoomScrollStateStore";
declare global {
interface Window {
Modernizr: ModernizrStatic;
matrixChat: ReturnType<Renderer>;
mxMatrixClientPeg: IMatrixClientPeg;
Olm: {
init: () => Promise<void>;
};
// Needed for Safari, unknown to TypeScript
webkitAudioContext: typeof AudioContext;
mxContentMessages: ContentMessages;
mxToastStore: ToastStore;
mxDeviceListener: DeviceListener;
mxRoomListStore: RoomListStoreClass;
mxRoomListLayoutStore: RoomListLayoutStore;
mxActiveRoomObserver: ActiveRoomObserver;
mxPlatformPeg: PlatformPeg;
mxIntegrationManagers: typeof IntegrationManagers;
singletonModalManager: ModalManager;
mxSettingsStore: SettingsStore;
mxNotifier: typeof Notifier;
mxRightPanelStore: RightPanelStore;
mxWidgetStore: WidgetStore;
mxWidgetLayoutStore: WidgetLayoutStore;
mxCallHandler: CallHandler;
mxAnalytics: Analytics;
mxCountlyAnalytics: typeof CountlyAnalytics;
mxUserActivity: UserActivity;
mxModalWidgetStore: ModalWidgetStore;
mxVoipUserMapper: VoipUserMapper;
mxSpaceStore: SpaceStoreClass;
mxVoiceRecordingStore: VoiceRecordingStore;
mxTypingStore: TypingStore;
mxEventIndexPeg: EventIndexPeg;
mxPerformanceMonitor: PerformanceMonitor;
mxPerformanceEntryNames: any;
mxUIStore: UIStore;
mxSetupEncryptionStore?: SetupEncryptionStore;
mxRoomScrollStateStore?: RoomScrollStateStore;
}
// workaround for https://github.com/microsoft/TypeScript/issues/30933
interface ObjectConstructor {
fromEntries?(xs: [string|number|symbol, any][]): object
interface Document {
// https://developer.mozilla.org/en-US/docs/Web/API/Document/hasStorageAccess
hasStorageAccess?: () => Promise<boolean>;
// https://developer.mozilla.org/en-US/docs/Web/API/Document/requestStorageAccess
requestStorageAccess?: () => Promise<undefined>;
// Safari & IE11 only have this prefixed: we used prefixed versions
// previously so let's continue to support them for now
webkitExitFullscreen(): Promise<void>;
msExitFullscreen(): Promise<void>;
readonly webkitFullscreenElement: Element | null;
readonly msFullscreenElement: Element | null;
}
interface Navigator {
userLanguage?: string;
// https://github.com/Microsoft/TypeScript/issues/19473
// https://developer.mozilla.org/en-US/docs/Web/API/MediaSession
mediaSession: any;
}
interface StorageEstimate {
usageDetails?: {[key: string]: number};
}
interface HTMLAudioElement {
type?: string;
// sinkId & setSinkId are experimental and typescript doesn't know about them
sinkId: string;
setSinkId(outputId: string);
}
interface HTMLVideoElement {
type?: string;
// sinkId & setSinkId are experimental and typescript doesn't know about them
sinkId: string;
setSinkId(outputId: string);
}
// Add Chrome-specific `instant` ScrollBehaviour
type _ScrollBehavior = ScrollBehavior | "instant";
interface _ScrollOptions {
behavior?: _ScrollBehavior;
}
interface _ScrollIntoViewOptions extends _ScrollOptions {
block?: ScrollLogicalPosition;
inline?: ScrollLogicalPosition;
}
interface Element {
// Safari & IE11 only have this prefixed: we used prefixed versions
// previously so let's continue to support them for now
webkitRequestFullScreen(options?: FullscreenOptions): Promise<void>;
msRequestFullscreen(options?: FullscreenOptions): Promise<void>;
scrollIntoView(arg?: boolean | _ScrollIntoViewOptions): void;
}
interface Error {
// https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Error/fileName
fileName?: string;
// https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Error/lineNumber
lineNumber?: number;
// https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Error/columnNumber
columnNumber?: number;
}
// https://github.com/microsoft/TypeScript/issues/28308#issuecomment-650802278
interface AudioWorkletProcessor {
readonly port: MessagePort;
process(
inputs: Float32Array[][],
outputs: Float32Array[][],
parameters: Record<string, Float32Array>
): boolean;
}
// https://github.com/microsoft/TypeScript/issues/28308#issuecomment-650802278
const AudioWorkletProcessor: {
prototype: AudioWorkletProcessor;
new (options?: AudioWorkletNodeOptions): AudioWorkletProcessor;
};
// https://github.com/microsoft/TypeScript/issues/28308#issuecomment-650802278
function registerProcessor(
name: string,
processorCtor: (new (
options?: AudioWorkletNodeOptions
) => AudioWorkletProcessor) & {
parameterDescriptors?: AudioParamDescriptor[];
}
);
}

38
src/@types/polyfill.ts Normal file
View file

@ -0,0 +1,38 @@
/*
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.
*/
// This is intended to fix re-resizer because of its unguarded `instanceof TouchEvent` checks.
export function polyfillTouchEvent() {
// Firefox doesn't have touch events without touch devices being present, so create a fake
// one we can rely on lying about.
if (!window.TouchEvent) {
// We have no intention of actually using this, so just lie.
window.TouchEvent = class TouchEvent extends UIEvent {
public get altKey(): boolean { return false; }
public get changedTouches(): any { return []; }
public get ctrlKey(): boolean { return false; }
public get metaKey(): boolean { return false; }
public get shiftKey(): boolean { return false; }
public get targetTouches(): any { return []; }
public get touches(): any { return []; }
public get rotation(): number { return 0.0; }
public get scale(): number { return 0.0; }
constructor(eventType: string, params?: any) {
super(eventType, params);
}
};
}
}

View file

@ -0,0 +1,23 @@
/*
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 sanitizeHtml from 'sanitize-html';
export interface IExtendedSanitizeOptions extends sanitizeHtml.IOptions {
// This option only exists in 2.x RCs so far, so not yet present in the
// separate type definition module.
nestingLimit?: number;
}

View file

@ -1,5 +1,5 @@
/*
Copyright 2016 OpenMarket Ltd
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.
@ -14,8 +14,10 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
const EMAIL_ADDRESS_REGEX = /^[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}$/i;
declare module "*.worker.ts" {
class WebpackWorker extends Worker {
constructor();
}
export function looksValid(email) {
return EMAIL_ADDRESS_REGEX.test(email);
export default WebpackWorker;
}

View file

@ -16,6 +16,8 @@ limitations under the License.
import RoomViewStore from './stores/RoomViewStore';
type Listener = (isActive: boolean) => void;
/**
* Consumes changes from the RoomViewStore and notifies specific things
* about when the active room changes. Unlike listening for RoomViewStore
@ -25,53 +27,57 @@ import RoomViewStore from './stores/RoomViewStore';
* TODO: If we introduce an observer for something else, factor out
* the adding / removing of listeners & emitting into a common class.
*/
class ActiveRoomObserver {
export class ActiveRoomObserver {
private listeners: {[key: string]: Listener[]} = {};
private _activeRoomId = RoomViewStore.getRoomId();
private readonly roomStoreToken: string;
constructor() {
this._listeners = {};
this._activeRoomId = RoomViewStore.getRoomId();
// TODO: We could self-destruct when the last listener goes away, or at least
// stop listening.
this._roomStoreToken = RoomViewStore.addListener(this._onRoomViewStoreUpdate.bind(this));
// TODO: We could self-destruct when the last listener goes away, or at least stop listening.
this.roomStoreToken = RoomViewStore.addListener(this.onRoomViewStoreUpdate);
}
addListener(roomId, listener) {
if (!this._listeners[roomId]) this._listeners[roomId] = [];
this._listeners[roomId].push(listener);
public get activeRoomId(): string {
return this._activeRoomId;
}
removeListener(roomId, listener) {
if (this._listeners[roomId]) {
const i = this._listeners[roomId].indexOf(listener);
public addListener(roomId, listener) {
if (!this.listeners[roomId]) this.listeners[roomId] = [];
this.listeners[roomId].push(listener);
}
public removeListener(roomId, listener) {
if (this.listeners[roomId]) {
const i = this.listeners[roomId].indexOf(listener);
if (i > -1) {
this._listeners[roomId].splice(i, 1);
this.listeners[roomId].splice(i, 1);
}
} else {
console.warn("Unregistering unrecognised listener (roomId=" + roomId + ")");
}
}
_emit(roomId) {
if (!this._listeners[roomId]) return;
private emit(roomId, isActive: boolean) {
if (!this.listeners[roomId]) return;
for (const l of this._listeners[roomId]) {
l.call();
for (const l of this.listeners[roomId]) {
l.call(null, isActive);
}
}
_onRoomViewStoreUpdate() {
private onRoomViewStoreUpdate = () => {
// emit for the old room ID
if (this._activeRoomId) this._emit(this._activeRoomId);
if (this._activeRoomId) this.emit(this._activeRoomId, false);
// update our cache
this._activeRoomId = RoomViewStore.getRoomId();
// and emit for the new one
if (this._activeRoomId) this._emit(this._activeRoomId);
}
if (this._activeRoomId) this.emit(this._activeRoomId, true);
};
}
if (global.mx_ActiveRoomObserver === undefined) {
global.mx_ActiveRoomObserver = new ActiveRoomObserver();
if (window.mxActiveRoomObserver === undefined) {
window.mxActiveRoomObserver = new ActiveRoomObserver();
}
export default global.mx_ActiveRoomObserver;
export default window.mxActiveRoomObserver;

View file

@ -16,12 +16,12 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import {MatrixClientPeg} from './MatrixClientPeg';
import { MatrixClientPeg } from './MatrixClientPeg';
import * as sdk from './index';
import Modal from './Modal';
import { _t } from './languageHandler';
import IdentityAuthClient from './IdentityAuthClient';
import {SSOAuthEntry} from "./components/views/auth/InteractiveAuthEntryComponents";
import { SSOAuthEntry } from "./components/views/auth/InteractiveAuthEntryComponents";
function getIdServerDomain() {
return MatrixClientPeg.get().idBaseUrl.split("://")[1];
@ -189,7 +189,6 @@ export default class AddThreepid {
// pop up an interactive auth dialog
const InteractiveAuthDialog = sdk.getComponent("dialogs.InteractiveAuthDialog");
const dialogAesthetics = {
[SSOAuthEntry.PHASE_PREAUTH]: {
title: _t("Use Single Sign On to continue"),
@ -249,7 +248,7 @@ export default class AddThreepid {
/**
* Takes a phone number verification code as entered by the user and validates
* it with the ID server, then if successful, adds the phone number.
* it with the identity server, then if successful, adds the phone number.
* @param {string} msisdnToken phone number verification code as entered by the user
* @return {Promise} Resolves if the phone number was added. Rejects with an object
* with a "message" property which contains a human-readable message detailing why

View file

@ -17,7 +17,7 @@ limitations under the License.
import React from 'react';
import { getCurrentLanguage, _t, _td } from './languageHandler';
import { getCurrentLanguage, _t, _td, IVariables } from './languageHandler';
import PlatformPeg from './PlatformPeg';
import SdkConfig from './SdkConfig';
import Modal from './Modal';
@ -27,7 +27,7 @@ const hashRegex = /#\/(groups?|room|user|settings|register|login|forgot_password
const hashVarRegex = /#\/(group|room|user)\/.*$/;
// Remove all but the first item in the hash path. Redact unexpected hashes.
function getRedactedHash(hash) {
function getRedactedHash(hash: string): string {
// Don't leak URLs we aren't expecting - they could contain tokens/PII
const match = hashRegex.exec(hash);
if (!match) {
@ -44,7 +44,7 @@ function getRedactedHash(hash) {
// Return the current origin, path and hash separated with a `/`. This does
// not include query parameters.
function getRedactedUrl() {
function getRedactedUrl(): string {
const { origin, hash } = window.location;
let { pathname } = window.location;
@ -56,7 +56,25 @@ function getRedactedUrl() {
return origin + pathname + getRedactedHash(hash);
}
const customVariables = {
interface IData {
/* eslint-disable camelcase */
gt_ms?: string;
e_c?: string;
e_a?: string;
e_n?: string;
e_v?: string;
ping?: string;
/* eslint-enable camelcase */
}
interface IVariable {
id: number;
expl: string; // explanation
example: string; // example value
getTextVariables?(): IVariables; // object to pass as 2nd argument to `_t`
}
const customVariables: Record<string, IVariable> = {
// The Matomo installation at https://matomo.riot.im is currently configured
// with a limit of 10 custom variables.
'App Platform': {
@ -66,7 +84,10 @@ const customVariables = {
},
'App Version': {
id: 2,
expl: _td('The version of Riot'),
expl: _td('The version of %(brand)s'),
getTextVariables: () => ({
brand: SdkConfig.get().brand,
}),
example: '15.0.0',
},
'User Type': {
@ -96,7 +117,10 @@ const customVariables = {
},
'Touch Input': {
id: 8,
expl: _td("Whether you're using Riot on a device where touch is the primary input mechanism"),
expl: _td("Whether you're using %(brand)s on a device where touch is the primary input mechanism"),
getTextVariables: () => ({
brand: SdkConfig.get().brand,
}),
example: 'false',
},
'Breadcrumbs': {
@ -106,12 +130,15 @@ const customVariables = {
},
'Installed PWA': {
id: 10,
expl: _td("Whether you're using Riot as an installed Progressive Web App"),
expl: _td("Whether you're using %(brand)s as an installed Progressive Web App"),
getTextVariables: () => ({
brand: SdkConfig.get().brand,
}),
example: 'false',
},
};
function whitelistRedact(whitelist, str) {
function whitelistRedact(whitelist: string[], str: string): string {
if (whitelist.includes(str)) return str;
return '<redacted>';
}
@ -121,7 +148,7 @@ const CREATION_TS_KEY = "mx_Riot_Analytics_cts";
const VISIT_COUNT_KEY = "mx_Riot_Analytics_vc";
const LAST_VISIT_TS_KEY = "mx_Riot_Analytics_lvts";
function getUid() {
function getUid(): string {
try {
let data = localStorage && localStorage.getItem(UID_KEY);
if (!data && localStorage) {
@ -136,94 +163,105 @@ function getUid() {
const HEARTBEAT_INTERVAL = 30 * 1000; // seconds
class Analytics {
export class Analytics {
private baseUrl: URL = null;
private siteId: string = null;
private visitVariables: Record<number, [string, string]> = {}; // {[id: number]: [name: string, value: string]}
private firstPage = true;
private heartbeatIntervalID: number = null;
private readonly creationTs: string;
private readonly lastVisitTs: string;
private readonly visitCount: string;
constructor() {
this.baseUrl = null;
this.siteId = null;
this.visitVariables = {};
this.firstPage = true;
this._heartbeatIntervalID = null;
this.creationTs = localStorage && localStorage.getItem(CREATION_TS_KEY);
if (!this.creationTs && localStorage) {
localStorage.setItem(CREATION_TS_KEY, this.creationTs = new Date().getTime());
localStorage.setItem(CREATION_TS_KEY, this.creationTs = String(new Date().getTime()));
}
this.lastVisitTs = localStorage && localStorage.getItem(LAST_VISIT_TS_KEY);
this.visitCount = localStorage && localStorage.getItem(VISIT_COUNT_KEY) || 0;
this.visitCount = localStorage && localStorage.getItem(VISIT_COUNT_KEY) || "0";
this.visitCount = String(parseInt(this.visitCount, 10) + 1); // increment
if (localStorage) {
localStorage.setItem(VISIT_COUNT_KEY, parseInt(this.visitCount, 10) + 1);
localStorage.setItem(VISIT_COUNT_KEY, this.visitCount);
}
}
get disabled() {
public get disabled() {
return !this.baseUrl;
}
public canEnable() {
const config = SdkConfig.get();
return navigator.doNotTrack !== "1" && config && config.piwik && config.piwik.url && config.piwik.siteId;
}
/**
* Enable Analytics if initialized but disabled
* otherwise try and initalize, no-op if piwik config missing
*/
async enable() {
public async enable() {
if (!this.disabled) return;
if (!this.canEnable()) return;
const config = SdkConfig.get();
if (!config || !config.piwik || !config.piwik.url || !config.piwik.siteId) return;
this.baseUrl = new URL("piwik.php", config.piwik.url);
// set constants
this.baseUrl.searchParams.set("rec", 1); // rec is required for tracking
this.baseUrl.searchParams.set("rec", "1"); // rec is required for tracking
this.baseUrl.searchParams.set("idsite", config.piwik.siteId); // rec is required for tracking
this.baseUrl.searchParams.set("apiv", 1); // API version to use
this.baseUrl.searchParams.set("send_image", 0); // we want a 204, not a tiny GIF
this.baseUrl.searchParams.set("apiv", "1"); // API version to use
this.baseUrl.searchParams.set("send_image", "0"); // we want a 204, not a tiny GIF
// set user parameters
this.baseUrl.searchParams.set("_id", getUid()); // uuid
this.baseUrl.searchParams.set("_idts", this.creationTs); // first ts
this.baseUrl.searchParams.set("_idvc", parseInt(this.visitCount, 10)+ 1); // visit count
this.baseUrl.searchParams.set("_idvc", this.visitCount); // visit count
if (this.lastVisitTs) {
this.baseUrl.searchParams.set("_viewts", this.lastVisitTs); // last visit ts
}
const platform = PlatformPeg.get();
this._setVisitVariable('App Platform', platform.getHumanReadableName());
this.setVisitVariable('App Platform', platform.getHumanReadableName());
try {
this._setVisitVariable('App Version', await platform.getAppVersion());
this.setVisitVariable('App Version', await platform.getAppVersion());
} catch (e) {
this._setVisitVariable('App Version', 'unknown');
this.setVisitVariable('App Version', 'unknown');
}
this._setVisitVariable('Chosen Language', getCurrentLanguage());
this.setVisitVariable('Chosen Language', getCurrentLanguage());
if (window.location.hostname === 'riot.im') {
this._setVisitVariable('Instance', window.location.pathname);
const hostname = window.location.hostname;
if (hostname === 'riot.im') {
this.setVisitVariable('Instance', window.location.pathname);
} else if (hostname.endsWith('.element.io')) {
this.setVisitVariable('Instance', hostname.replace('.element.io', ''));
}
let installedPWA = "unknown";
try {
// Known to work at least for desktop Chrome
installedPWA = window.matchMedia('(display-mode: standalone)').matches;
installedPWA = String(window.matchMedia('(display-mode: standalone)').matches);
} catch (e) { }
this._setVisitVariable('Installed PWA', installedPWA);
this.setVisitVariable('Installed PWA', installedPWA);
let touchInput = "unknown";
try {
// MDN claims broad support across browsers
touchInput = window.matchMedia('(pointer: coarse)').matches;
touchInput = String(window.matchMedia('(pointer: coarse)').matches);
} catch (e) { }
this._setVisitVariable('Touch Input', touchInput);
this.setVisitVariable('Touch Input', touchInput);
// start heartbeat
this._heartbeatIntervalID = window.setInterval(this.ping.bind(this), HEARTBEAT_INTERVAL);
this.heartbeatIntervalID = window.setInterval(this.ping.bind(this), HEARTBEAT_INTERVAL);
}
/**
* Disable Analytics, stop the heartbeat and clear identifiers from localStorage
*/
disable() {
public disable() {
if (this.disabled) return;
this.trackEvent('Analytics', 'opt-out');
window.clearInterval(this._heartbeatIntervalID);
window.clearInterval(this.heartbeatIntervalID);
this.baseUrl = null;
this.visitVariables = {};
localStorage.removeItem(UID_KEY);
@ -232,7 +270,7 @@ class Analytics {
localStorage.removeItem(LAST_VISIT_TS_KEY);
}
async _track(data) {
private async _track(data: IData) {
if (this.disabled) return;
const now = new Date();
@ -248,13 +286,13 @@ class Analytics {
s: now.getSeconds(),
};
const url = new URL(this.baseUrl);
const url = new URL(this.baseUrl.toString()); // copy
for (const key in params) {
url.searchParams.set(key, params[key]);
}
try {
await window.fetch(url, {
await window.fetch(url.toString(), {
method: "GET",
mode: "no-cors",
cache: "no-cache",
@ -265,14 +303,14 @@ class Analytics {
}
}
ping() {
public ping() {
this._track({
ping: 1,
ping: "1",
});
localStorage.setItem(LAST_VISIT_TS_KEY, new Date().getTime()); // update last visit ts
localStorage.setItem(LAST_VISIT_TS_KEY, String(new Date().getTime())); // update last visit ts
}
trackPageChange(generationTimeMs) {
public trackPageChange(generationTimeMs?: number) {
if (this.disabled) return;
if (this.firstPage) {
// De-duplicate first page
@ -287,11 +325,11 @@ class Analytics {
}
this._track({
gt_ms: generationTimeMs,
gt_ms: String(generationTimeMs),
});
}
trackEvent(category, action, name, value) {
public trackEvent(category: string, action: string, name?: string, value?: string) {
if (this.disabled) return;
this._track({
e_c: category,
@ -301,12 +339,12 @@ class Analytics {
});
}
_setVisitVariable(key, value) {
private setVisitVariable(key: keyof typeof customVariables, value: string) {
if (this.disabled) return;
this.visitVariables[customVariables[key].id] = [key, value];
}
setLoggedIn(isGuest, homeserverUrl, identityServerUrl) {
public setLoggedIn(isGuest: boolean, homeserverUrl: string) {
if (this.disabled) return;
const config = SdkConfig.get();
@ -314,16 +352,16 @@ class Analytics {
const whitelistedHSUrls = config.piwik.whitelistedHSUrls || [];
this._setVisitVariable('User Type', isGuest ? 'Guest' : 'Logged In');
this._setVisitVariable('Homeserver URL', whitelistRedact(whitelistedHSUrls, homeserverUrl));
this.setVisitVariable('User Type', isGuest ? 'Guest' : 'Logged In');
this.setVisitVariable('Homeserver URL', whitelistRedact(whitelistedHSUrls, homeserverUrl));
}
setBreadcrumbs(state) {
public setBreadcrumbs(state: boolean) {
if (this.disabled) return;
this._setVisitVariable('Breadcrumbs', state ? 'enabled' : 'disabled');
this.setVisitVariable('Breadcrumbs', state ? 'enabled' : 'disabled');
}
showDetailsModal = () => {
public showDetailsModal = () => {
let rows = [];
if (!this.disabled) {
rows = Object.values(this.visitVariables);
@ -344,7 +382,7 @@ class Analytics {
'e.g. <CurrentPageURL>',
{},
{
CurrentPageURL: getRedactedUrl(),
CurrentPageURL: getRedactedUrl,
},
),
},
@ -352,16 +390,22 @@ class Analytics {
{ expl: _td('Your device resolution'), value: resolution },
];
// FIXME: Using an import will result in test failures
const ErrorDialog = sdk.getComponent('dialogs.ErrorDialog');
Modal.createTrackedDialog('Analytics Details', '', ErrorDialog, {
title: _t('Analytics'),
description: <div className="mx_AnalyticsModal">
<div>
{ _t('The information being sent to us to help make Riot better includes:') }
</div>
<div>{_t('The information being sent to us to help make %(brand)s better includes:', {
brand: SdkConfig.get().brand,
})}</div>
<table>
{ rows.map((row) => <tr key={row[0]}>
<td>{ _t(customVariables[row[0]].expl) }</td>
<td>{_t(
customVariables[row[0]].expl,
customVariables[row[0]].getTextVariables ?
customVariables[row[0]].getTextVariables() :
null,
)}</td>
{ row[1] !== undefined && <td><code>{ row[1] }</code></td> }
</tr>) }
{ otherVariables.map((item, index) =>
@ -380,7 +424,7 @@ class Analytics {
};
}
if (!global.mxAnalytics) {
global.mxAnalytics = new Analytics();
if (!window.mxAnalytics) {
window.mxAnalytics = new Analytics();
}
export default global.mxAnalytics;
export default window.mxAnalytics;

View file

@ -1,6 +1,5 @@
/*
Copyright 2015, 2016 OpenMarket Ltd
Copyright 2020 The Matrix.org Foundation C.I.C.
Copyright 2015-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,70 +14,76 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import createReactClass from 'create-react-class';
import React, { ComponentType } from "react";
import * as sdk from './index';
import PropTypes from 'prop-types';
import { _t } from './languageHandler';
import { IDialogProps } from "./components/views/dialogs/IDialogProps";
type AsyncImport<T> = { default: T };
interface IProps extends IDialogProps {
// A promise which resolves with the real component
prom: Promise<ComponentType | AsyncImport<ComponentType>>;
}
interface IState {
component?: ComponentType;
error?: Error;
}
/**
* Wrap an asynchronous loader function with a react component which shows a
* spinner until the real component loads.
*/
export default createReactClass({
propTypes: {
/** A promise which resolves with the real component
*/
prom: PropTypes.object.isRequired,
},
export default class AsyncWrapper extends React.Component<IProps, IState> {
private unmounted = false;
getInitialState: function() {
return {
component: null,
error: null,
};
},
public state = {
component: null,
error: null,
};
componentDidMount: function() {
this._unmounted = false;
componentDidMount() {
// XXX: temporary logging to try to diagnose
// https://github.com/vector-im/riot-web/issues/3148
// https://github.com/vector-im/element-web/issues/3148
console.log('Starting load of AsyncWrapper for modal');
this.props.prom.then((result) => {
if (this._unmounted) {
return;
}
if (this.unmounted) return;
// Take the 'default' member if it's there, then we support
// passing in just an import()ed module, since ES6 async import
// always returns a module *namespace*.
const component = result.default ? result.default : result;
this.setState({component});
const component = (result as AsyncImport<ComponentType>).default
? (result as AsyncImport<ComponentType>).default
: result as ComponentType;
this.setState({ component });
}).catch((e) => {
console.warn('AsyncWrapper promise failed', e);
this.setState({error: e});
this.setState({ error: e });
});
},
}
componentWillUnmount: function() {
this._unmounted = true;
},
componentWillUnmount() {
this.unmounted = true;
}
_onWrapperCancelClick: function() {
private onWrapperCancelClick = () => {
this.props.onFinished(false);
},
};
render: function() {
render() {
if (this.state.component) {
const Component = this.state.component;
return <Component {...this.props} />;
} else if (this.state.error) {
// FIXME: Using an import will result in test failures
const BaseDialog = sdk.getComponent('views.dialogs.BaseDialog');
const DialogButtons = sdk.getComponent('views.elements.DialogButtons');
return <BaseDialog onFinished={this.props.onFinished}
title={_t("Error")}
>
{_t("Unable to load! Check your network connectivity and try again.")}
return <BaseDialog onFinished={this.props.onFinished} title={_t("Error")}>
{ _t("Unable to load! Check your network connectivity and try again.") }
<DialogButtons primaryButton={_t("Dismiss")}
onPrimaryButtonClick={this._onWrapperCancelClick}
onPrimaryButtonClick={this.onWrapperCancelClick}
hasCancel={false}
/>
</BaseDialog>;
@ -87,6 +92,6 @@ export default createReactClass({
const Spinner = sdk.getComponent("elements.Spinner");
return <Spinner />;
}
},
});
}
}

View file

@ -1,137 +0,0 @@
/*
Copyright 2015, 2016 OpenMarket 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.
*/
'use strict';
import {MatrixClientPeg} from './MatrixClientPeg';
import DMRoomMap from './utils/DMRoomMap';
import {getHttpUriForMxc} from "matrix-js-sdk/src/content-repo";
export function avatarUrlForMember(member, width, height, resizeMethod) {
let url;
if (member && member.getAvatarUrl) {
url = member.getAvatarUrl(
MatrixClientPeg.get().getHomeserverUrl(),
Math.floor(width * window.devicePixelRatio),
Math.floor(height * window.devicePixelRatio),
resizeMethod,
false,
false,
);
}
if (!url) {
// member can be null here currently since on invites, the JS SDK
// does not have enough info to build a RoomMember object for
// the inviter.
url = defaultAvatarUrlForString(member ? member.userId : '');
}
return url;
}
export function avatarUrlForUser(user, width, height, resizeMethod) {
const url = getHttpUriForMxc(
MatrixClientPeg.get().getHomeserverUrl(), user.avatarUrl,
Math.floor(width * window.devicePixelRatio),
Math.floor(height * window.devicePixelRatio),
resizeMethod,
);
if (!url || url.length === 0) {
return null;
}
return url;
}
export function defaultAvatarUrlForString(s) {
const images = ['03b381', '368bd6', 'ac3ba8'];
let total = 0;
for (let i = 0; i < s.length; ++i) {
total += s.charCodeAt(i);
}
return require('../res/img/' + images[total % images.length] + '.png');
}
/**
* returns the first (non-sigil) character of 'name',
* converted to uppercase
* @param {string} name
* @return {string} the first letter
*/
export function getInitialLetter(name) {
if (!name) {
// XXX: We should find out what causes the name to sometimes be falsy.
console.trace("`name` argument to `getInitialLetter` not supplied");
return undefined;
}
if (name.length < 1) {
return undefined;
}
let idx = 0;
const initial = name[0];
if ((initial === '@' || initial === '#' || initial === '+') && name[1]) {
idx++;
}
// string.codePointAt(0) would do this, but that isn't supported by
// some browsers (notably PhantomJS).
let chars = 1;
const first = name.charCodeAt(idx);
// check if its the start of a surrogate pair
if (first >= 0xD800 && first <= 0xDBFF && name[idx+1]) {
const second = name.charCodeAt(idx+1);
if (second >= 0xDC00 && second <= 0xDFFF) {
chars++;
}
}
const firstChar = name.substring(idx, idx+chars);
return firstChar.toUpperCase();
}
export function avatarUrlForRoom(room, width, height, resizeMethod) {
if (!room) return null; // null-guard
const explicitRoomAvatar = room.getAvatarUrl(
MatrixClientPeg.get().getHomeserverUrl(),
width,
height,
resizeMethod,
false,
);
if (explicitRoomAvatar) {
return explicitRoomAvatar;
}
let otherMember = null;
const otherUserId = DMRoomMap.shared().getUserIdForRoomId(room.roomId);
if (otherUserId) {
otherMember = room.getMember(otherUserId);
} else {
// if the room is not marked as a 1:1, but only has max 2 members
// then still try to show any avatar (pref. other member)
otherMember = room.getAvatarFallbackMember();
}
if (otherMember) {
return otherMember.getAvatarUrl(
MatrixClientPeg.get().getHomeserverUrl(),
width,
height,
resizeMethod,
false,
);
}
return null;
}

171
src/Avatar.ts Normal file
View file

@ -0,0 +1,171 @@
/*
Copyright 2015, 2016 OpenMarket 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 { RoomMember } from "matrix-js-sdk/src/models/room-member";
import { User } from "matrix-js-sdk/src/models/user";
import { Room } from "matrix-js-sdk/src/models/room";
import { ResizeMethod } from "matrix-js-sdk/src/@types/partials";
import DMRoomMap from './utils/DMRoomMap';
import { mediaFromMxc } from "./customisations/Media";
import SpaceStore from "./stores/SpaceStore";
// Not to be used for BaseAvatar urls as that has similar default avatar fallback already
export function avatarUrlForMember(
member: RoomMember,
width: number,
height: number,
resizeMethod: ResizeMethod,
): string {
let url: string;
if (member?.getMxcAvatarUrl()) {
url = mediaFromMxc(member.getMxcAvatarUrl()).getThumbnailOfSourceHttp(width, height, resizeMethod);
}
if (!url) {
// member can be null here currently since on invites, the JS SDK
// does not have enough info to build a RoomMember object for
// the inviter.
url = defaultAvatarUrlForString(member ? member.userId : '');
}
return url;
}
export function avatarUrlForUser(
user: Pick<User, "avatarUrl">,
width: number,
height: number,
resizeMethod?: ResizeMethod,
): string | null {
if (!user.avatarUrl) return null;
return mediaFromMxc(user.avatarUrl).getThumbnailOfSourceHttp(width, height, resizeMethod);
}
function isValidHexColor(color: string): boolean {
return typeof color === "string" &&
(color.length === 7 || color.length === 9) &&
color.charAt(0) === "#" &&
!color.substr(1).split("").some(c => isNaN(parseInt(c, 16)));
}
function urlForColor(color: string): string {
const size = 40;
const canvas = document.createElement("canvas");
canvas.width = size;
canvas.height = size;
const ctx = canvas.getContext("2d");
// bail out when using jsdom in unit tests
if (!ctx) {
return "";
}
ctx.fillStyle = color;
ctx.fillRect(0, 0, size, size);
return canvas.toDataURL();
}
// XXX: Ideally we'd clear this cache when the theme changes
// but since this function is at global scope, it's a bit
// hard to install a listener here, even if there were a clear event to listen to
const colorToDataURLCache = new Map<string, string>();
export function defaultAvatarUrlForString(s: string): string {
if (!s) return ""; // XXX: should never happen but empirically does by evidence of a rageshake
const defaultColors = ['#0DBD8B', '#368bd6', '#ac3ba8'];
let total = 0;
for (let i = 0; i < s.length; ++i) {
total += s.charCodeAt(i);
}
const colorIndex = total % defaultColors.length;
// overwritten color value in custom themes
const cssVariable = `--avatar-background-colors_${colorIndex}`;
const cssValue = document.body.style.getPropertyValue(cssVariable);
const color = cssValue || defaultColors[colorIndex];
let dataUrl = colorToDataURLCache.get(color);
if (!dataUrl) {
// validate color as this can come from account_data
// with custom theming
if (isValidHexColor(color)) {
dataUrl = urlForColor(color);
colorToDataURLCache.set(color, dataUrl);
} else {
dataUrl = "";
}
}
return dataUrl;
}
/**
* returns the first (non-sigil) character of 'name',
* converted to uppercase
* @param {string} name
* @return {string} the first letter
*/
export function getInitialLetter(name: string): string {
if (!name) {
// XXX: We should find out what causes the name to sometimes be falsy.
console.trace("`name` argument to `getInitialLetter` not supplied");
return undefined;
}
if (name.length < 1) {
return undefined;
}
let idx = 0;
const initial = name[0];
if ((initial === '@' || initial === '#' || initial === '+') && name[1]) {
idx++;
}
// string.codePointAt(0) would do this, but that isn't supported by
// some browsers (notably PhantomJS).
let chars = 1;
const first = name.charCodeAt(idx);
// check if its the start of a surrogate pair
if (first >= 0xD800 && first <= 0xDBFF && name[idx+1]) {
const second = name.charCodeAt(idx+1);
if (second >= 0xDC00 && second <= 0xDFFF) {
chars++;
}
}
const firstChar = name.substring(idx, idx+chars);
return firstChar.toUpperCase();
}
export function avatarUrlForRoom(room: Room, width: number, height: number, resizeMethod?: ResizeMethod) {
if (!room) return null; // null-guard
if (room.getMxcAvatarUrl()) {
return mediaFromMxc(room.getMxcAvatarUrl()).getThumbnailOfSourceHttp(width, height, resizeMethod);
}
// space rooms cannot be DMs so skip the rest
if (SpaceStore.spacesEnabled && room.isSpaceRoom()) return null;
let otherMember = null;
const otherUserId = DMRoomMap.shared().getUserIdForRoomId(room.roomId);
if (otherUserId) {
otherMember = room.getMember(otherUserId);
} else {
// if the room is not marked as a 1:1, but only has max 2 members
// then still try to show any avatar (pref. other member)
otherMember = room.getAvatarFallbackMember();
}
if (otherMember?.getMxcAvatarUrl()) {
return mediaFromMxc(otherMember.getMxcAvatarUrl()).getThumbnailOfSourceHttp(width, height, resizeMethod);
}
return null;
}

View file

@ -1,191 +0,0 @@
// @flow
/*
Copyright 2016 Aviral Dasgupta
Copyright 2016 OpenMarket Ltd
Copyright 2018 New Vector 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.
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";
import dis from './dispatcher';
import BaseEventIndexManager from './indexing/BaseEventIndexManager';
/**
* Base class for classes that provide platform-specific functionality
* eg. Setting an application badge or displaying notifications
*
* Instances of this class are provided by the application.
*/
export default class BasePlatform {
constructor() {
this.notificationCount = 0;
this.errorDidOccur = false;
dis.register(this._onAction.bind(this));
}
_onAction(payload: Object) {
switch (payload.action) {
case 'on_client_not_viable':
case 'on_logged_out':
this.setNotificationCount(0);
break;
}
}
// Used primarily for Analytics
getHumanReadableName(): string {
return 'Base Platform';
}
setNotificationCount(count: number) {
this.notificationCount = count;
}
setErrorStatus(errorDidOccur: boolean) {
this.errorDidOccur = errorDidOccur;
}
/**
* Returns true if the platform supports displaying
* notifications, otherwise false.
* @returns {boolean} whether the platform supports displaying notifications
*/
supportsNotifications(): boolean {
return false;
}
/**
* Returns true if the application currently has permission
* to display notifications. Otherwise false.
* @returns {boolean} whether the application has permission to display notifications
*/
maySendNotifications(): boolean {
return false;
}
/**
* Requests permission to send notifications. Returns
* a promise that is resolved when the user has responded
* to the request. The promise has a single string argument
* that is 'granted' if the user allowed the request or
* 'denied' otherwise.
*/
requestNotificationPermission(): Promise<string> {
}
displayNotification(title: string, msg: string, avatarUrl: string, room: Object) {
}
loudNotification(ev: Event, room: Object) {
}
/**
* Returns a promise that resolves to a string representing
* the current version of the application.
*/
getAppVersion(): Promise<string> {
throw new Error("getAppVersion not implemented!");
}
/*
* If it's not expected that capturing the screen will work
* with getUserMedia, return a string explaining why not.
* Otherwise, return null.
*/
screenCaptureErrorString(): string {
return "Not implemented";
}
/**
* Restarts the application, without neccessarily reloading
* any application code
*/
reload() {
throw new Error("reload not implemented!");
}
supportsAutoLaunch(): boolean {
return false;
}
// XXX: Surely this should be a setting like any other?
async getAutoLaunchEnabled(): boolean {
return false;
}
async setAutoLaunchEnabled(enabled: boolean): void {
throw new Error("Unimplemented");
}
supportsAutoHideMenuBar(): boolean {
return false;
}
async getAutoHideMenuBarEnabled(): boolean {
return false;
}
async setAutoHideMenuBarEnabled(enabled: boolean): void {
throw new Error("Unimplemented");
}
supportsMinimizeToTray(): boolean {
return false;
}
async getMinimizeToTrayEnabled(): boolean {
return false;
}
async setMinimizeToTrayEnabled(enabled: boolean): void {
throw new Error("Unimplemented");
}
/**
* Get our platform specific EventIndexManager.
*
* @return {BaseEventIndexManager} The EventIndex manager for our platform,
* can be null if the platform doesn't support event indexing.
*/
getEventIndexingManager(): BaseEventIndexManager | null {
return null;
}
setLanguage(preferredLangs: string[]) {}
getSSOCallbackUrl(hsUrl: string, isUrl: string): URL {
const url = new URL(window.location.href);
// XXX: at this point, the fragment will always be #/login, which is no
// use to anyone. Ideally, we would get the intended fragment from
// MatrixChat.screenAfterLogin so that you could follow #/room links etc
// through an SSO login.
url.hash = "";
url.searchParams.set("homeserver", hsUrl);
url.searchParams.set("identityServer", isUrl);
return url;
}
/**
* Begin Single Sign On flows.
* @param {MatrixClient} mxClient the matrix client using which we should start the flow
* @param {"sso"|"cas"} loginType the type of SSO it is, CAS/SSO.
*/
startSingleSignOn(mxClient: MatrixClient, loginType: "sso"|"cas") {
const callbackUrl = this.getSSOCallbackUrl(mxClient.getHomeserverUrl(), mxClient.getIdentityServerUrl());
window.location.href = mxClient.getSsoLoginUrl(callbackUrl.toString(), loginType); // redirect to SSO
}
}

399
src/BasePlatform.ts Normal file
View file

@ -0,0 +1,399 @@
/*
Copyright 2016 Aviral Dasgupta
Copyright 2016 OpenMarket Ltd
Copyright 2018 New Vector 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.
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 { encodeUnpaddedBase64 } from "matrix-js-sdk/src/crypto/olmlib";
import dis from './dispatcher/dispatcher';
import BaseEventIndexManager from './indexing/BaseEventIndexManager';
import { ActionPayload } from "./dispatcher/payloads";
import { CheckUpdatesPayload } from "./dispatcher/payloads/CheckUpdatesPayload";
import { Action } from "./dispatcher/actions";
import { hideToast as hideUpdateToast } from "./toasts/UpdateToast";
import { MatrixClientPeg } from "./MatrixClientPeg";
import { idbLoad, idbSave, idbDelete } from "./utils/StorageManager";
export const SSO_HOMESERVER_URL_KEY = "mx_sso_hs_url";
export const SSO_ID_SERVER_URL_KEY = "mx_sso_is_url";
export const SSO_IDP_ID_KEY = "mx_sso_idp_id";
export enum UpdateCheckStatus {
Checking = "CHECKING",
Error = "ERROR",
NotAvailable = "NOTAVAILABLE",
Downloading = "DOWNLOADING",
Ready = "READY",
}
const UPDATE_DEFER_KEY = "mx_defer_update";
/**
* Base class for classes that provide platform-specific functionality
* eg. Setting an application badge or displaying notifications
*
* Instances of this class are provided by the application.
*/
export default abstract class BasePlatform {
protected notificationCount = 0;
protected errorDidOccur = false;
constructor() {
dis.register(this.onAction);
this.startUpdateCheck = this.startUpdateCheck.bind(this);
}
abstract getConfig(): Promise<{}>;
abstract getDefaultDeviceDisplayName(): string;
protected onAction = (payload: ActionPayload) => {
switch (payload.action) {
case 'on_client_not_viable':
case 'on_logged_out':
this.setNotificationCount(0);
break;
}
};
// Used primarily for Analytics
abstract getHumanReadableName(): string;
setNotificationCount(count: number) {
this.notificationCount = count;
}
setErrorStatus(errorDidOccur: boolean) {
this.errorDidOccur = errorDidOccur;
}
/**
* Whether we can call checkForUpdate on this platform build
*/
async canSelfUpdate(): Promise<boolean> {
return false;
}
startUpdateCheck() {
hideUpdateToast();
localStorage.removeItem(UPDATE_DEFER_KEY);
dis.dispatch<CheckUpdatesPayload>({
action: Action.CheckUpdates,
status: UpdateCheckStatus.Checking,
});
}
/**
* Update the currently running app to the latest available version
* and replace this instance of the app with the new version.
*/
installUpdate() {
}
/**
* Check if the version update has been deferred and that deferment is still in effect
* @param newVersion the version string to check
*/
protected shouldShowUpdate(newVersion: string): boolean {
// If the user registered on this client in the last 24 hours then do not show them the update toast
if (MatrixClientPeg.userRegisteredWithinLastHours(24)) return false;
try {
const [version, deferUntil] = JSON.parse(localStorage.getItem(UPDATE_DEFER_KEY));
return newVersion !== version || Date.now() > deferUntil;
} catch (e) {
return true;
}
}
/**
* Ignore the pending update and don't prompt about this version
* until the next morning (8am).
*/
deferUpdate(newVersion: string) {
const date = new Date(Date.now() + 24 * 60 * 60 * 1000);
date.setHours(8, 0, 0, 0); // set to next 8am
localStorage.setItem(UPDATE_DEFER_KEY, JSON.stringify([newVersion, date.getTime()]));
hideUpdateToast();
}
/**
* Return true if platform supports multi-language
* spell-checking, otherwise false.
*/
supportsMultiLanguageSpellCheck(): boolean {
return false;
}
/**
* Returns true if the platform supports displaying
* notifications, otherwise false.
* @returns {boolean} whether the platform supports displaying notifications
*/
supportsNotifications(): boolean {
return false;
}
/**
* Returns true if the application currently has permission
* to display notifications. Otherwise false.
* @returns {boolean} whether the application has permission to display notifications
*/
maySendNotifications(): boolean {
return false;
}
/**
* Requests permission to send notifications. Returns
* a promise that is resolved when the user has responded
* to the request. The promise has a single string argument
* that is 'granted' if the user allowed the request or
* 'denied' otherwise.
*/
abstract requestNotificationPermission(): Promise<string>;
abstract displayNotification(title: string, msg: string, avatarUrl: string, room: Object);
loudNotification(ev: Event, room: Object) {
}
clearNotification(notif: Notification) {
// Some browsers don't support this, e.g Safari on iOS
// https://developer.mozilla.org/en-US/docs/Web/API/Notification/close
if (notif.close) {
notif.close();
}
}
/**
* Returns a promise that resolves to a string representing the current version of the application.
*/
abstract getAppVersion(): Promise<string>;
/*
* If it's not expected that capturing the screen will work
* with getUserMedia, return a string explaining why not.
* Otherwise, return null.
*/
screenCaptureErrorString(): string {
return "Not implemented";
}
/**
* Restarts the application, without neccessarily reloading
* any application code
*/
abstract reload();
supportsAutoLaunch(): boolean {
return false;
}
// XXX: Surely this should be a setting like any other?
async getAutoLaunchEnabled(): Promise<boolean> {
return false;
}
async setAutoLaunchEnabled(enabled: boolean): Promise<void> {
throw new Error("Unimplemented");
}
supportsWarnBeforeExit(): boolean {
return false;
}
async shouldWarnBeforeExit(): Promise<boolean> {
return false;
}
async setWarnBeforeExit(enabled: boolean): Promise<void> {
throw new Error("Unimplemented");
}
supportsAutoHideMenuBar(): boolean {
return false;
}
async getAutoHideMenuBarEnabled(): Promise<boolean> {
return false;
}
async setAutoHideMenuBarEnabled(enabled: boolean): Promise<void> {
throw new Error("Unimplemented");
}
supportsMinimizeToTray(): boolean {
return false;
}
async getMinimizeToTrayEnabled(): Promise<boolean> {
return false;
}
async setMinimizeToTrayEnabled(enabled: boolean): Promise<void> {
throw new Error("Unimplemented");
}
/**
* Get our platform specific EventIndexManager.
*
* @return {BaseEventIndexManager} The EventIndex manager for our platform,
* can be null if the platform doesn't support event indexing.
*/
getEventIndexingManager(): BaseEventIndexManager | null {
return null;
}
async setLanguage(preferredLangs: string[]) {}
setSpellCheckLanguages(preferredLangs: string[]) {}
getSpellCheckLanguages(): Promise<string[]> | null {
return null;
}
getAvailableSpellCheckLanguages(): Promise<string[]> | null {
return null;
}
protected getSSOCallbackUrl(fragmentAfterLogin: string): URL {
const url = new URL(window.location.href);
url.hash = fragmentAfterLogin || "";
return url;
}
/**
* Begin Single Sign On flows.
* @param {MatrixClient} mxClient the matrix client using which we should start the flow
* @param {"sso"|"cas"} loginType the type of SSO it is, CAS/SSO.
* @param {string} fragmentAfterLogin the hash to pass to the app during sso callback.
* @param {string} idpId The ID of the Identity Provider being targeted, optional.
*/
startSingleSignOn(mxClient: MatrixClient, loginType: "sso" | "cas", fragmentAfterLogin: string, idpId?: string) {
// persist hs url and is url for when the user is returned to the app with the login token
localStorage.setItem(SSO_HOMESERVER_URL_KEY, mxClient.getHomeserverUrl());
if (mxClient.getIdentityServerUrl()) {
localStorage.setItem(SSO_ID_SERVER_URL_KEY, mxClient.getIdentityServerUrl());
}
if (idpId) {
localStorage.setItem(SSO_IDP_ID_KEY, idpId);
}
const callbackUrl = this.getSSOCallbackUrl(fragmentAfterLogin);
window.location.href = mxClient.getSsoLoginUrl(callbackUrl.toString(), loginType, idpId); // redirect to SSO
}
onKeyDown(ev: KeyboardEvent): boolean {
return false; // no shortcuts implemented
}
/**
* Get a previously stored pickle key. The pickle key is used for
* encrypting libolm objects.
* @param {string} userId the user ID for the user that the pickle key is for.
* @param {string} userId the device ID that the pickle key is for.
* @returns {string|null} the previously stored pickle key, or null if no
* pickle key has been stored.
*/
async getPickleKey(userId: string, deviceId: string): Promise<string | null> {
if (!window.crypto || !window.crypto.subtle) {
return null;
}
let data;
try {
data = await idbLoad("pickleKey", [userId, deviceId]);
} catch (e) {}
if (!data) {
return null;
}
if (!data.encrypted || !data.iv || !data.cryptoKey) {
console.error("Badly formatted pickle key");
return null;
}
const additionalData = new Uint8Array(userId.length + deviceId.length + 1);
for (let i = 0; i < userId.length; i++) {
additionalData[i] = userId.charCodeAt(i);
}
additionalData[userId.length] = 124; // "|"
for (let i = 0; i < deviceId.length; i++) {
additionalData[userId.length + 1 + i] = deviceId.charCodeAt(i);
}
try {
const key = await crypto.subtle.decrypt(
{ name: "AES-GCM", iv: data.iv, additionalData }, data.cryptoKey,
data.encrypted,
);
return encodeUnpaddedBase64(key);
} catch (e) {
console.error("Error decrypting pickle key");
return null;
}
}
/**
* Create and store a pickle key for encrypting libolm objects.
* @param {string} userId the user ID for the user that the pickle key is for.
* @param {string} deviceId the device ID that the pickle key is for.
* @returns {string|null} the pickle key, or null if the platform does not
* support storing pickle keys.
*/
async createPickleKey(userId: string, deviceId: string): Promise<string | null> {
if (!window.crypto || !window.crypto.subtle) {
return null;
}
const crypto = window.crypto;
const randomArray = new Uint8Array(32);
crypto.getRandomValues(randomArray);
const cryptoKey = await crypto.subtle.generateKey(
{ name: "AES-GCM", length: 256 }, false, ["encrypt", "decrypt"],
);
const iv = new Uint8Array(32);
crypto.getRandomValues(iv);
const additionalData = new Uint8Array(userId.length + deviceId.length + 1);
for (let i = 0; i < userId.length; i++) {
additionalData[i] = userId.charCodeAt(i);
}
additionalData[userId.length] = 124; // "|"
for (let i = 0; i < deviceId.length; i++) {
additionalData[userId.length + 1 + i] = deviceId.charCodeAt(i);
}
const encrypted = await crypto.subtle.encrypt(
{ name: "AES-GCM", iv, additionalData }, cryptoKey, randomArray,
);
try {
await idbSave("pickleKey", [userId, deviceId], { encrypted, iv, cryptoKey });
} catch (e) {
return null;
}
return encodeUnpaddedBase64(randomArray);
}
/**
* Delete a previously stored pickle key from storage.
* @param {string} userId the user ID for the user that the pickle key is for.
* @param {string} userId the device ID that the pickle key is for.
*/
async destroyPickleKey(userId: string, deviceId: string): Promise<void> {
try {
await idbDelete("pickleKey", [userId, deviceId]);
} catch (e) {}
}
}

60
src/BlurhashEncoder.ts Normal file
View file

@ -0,0 +1,60 @@
/*
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 { defer, IDeferred } from "matrix-js-sdk/src/utils";
// @ts-ignore - `.ts` is needed here to make TS happy
import BlurhashWorker from "./workers/blurhash.worker.ts";
interface IBlurhashWorkerResponse {
seq: number;
blurhash: string;
}
export class BlurhashEncoder {
private static internalInstance = new BlurhashEncoder();
public static get instance(): BlurhashEncoder {
return BlurhashEncoder.internalInstance;
}
private readonly worker: Worker;
private seq = 0;
private pendingDeferredMap = new Map<number, IDeferred<string>>();
constructor() {
this.worker = new BlurhashWorker();
this.worker.onmessage = this.onMessage;
}
private onMessage = (ev: MessageEvent<IBlurhashWorkerResponse>) => {
const { seq, blurhash } = ev.data;
const deferred = this.pendingDeferredMap.get(seq);
if (deferred) {
this.pendingDeferredMap.delete(seq);
deferred.resolve(blurhash);
}
};
public getBlurhash(imageData: ImageData): Promise<string> {
const seq = this.seq++;
const deferred = defer<string>();
this.pendingDeferredMap.set(seq, deferred);
this.worker.postMessage({ seq, imageData });
return deferred.promise;
}
}

View file

@ -1,551 +0,0 @@
/*
Copyright 2015, 2016 OpenMarket Ltd
Copyright 2017, 2018 New Vector Ltd
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.
*/
/*
* Manages a list of all the currently active calls.
*
* This handler dispatches when voip calls are added/updated/removed from this list:
* {
* action: 'call_state'
* room_id: <room ID of the call>
* }
*
* To know the state of the call, this handler exposes a getter to
* obtain the call for a room:
* var call = CallHandler.getCall(roomId)
* var state = call.call_state; // ringing|ringback|connected|ended|busy|stop_ringback|stop_ringing
*
* This handler listens for and handles the following actions:
* {
* action: 'place_call',
* type: 'voice|video',
* room_id: <room that the place call button was pressed in>
* }
*
* {
* action: 'incoming_call'
* call: MatrixCall
* }
*
* {
* action: 'hangup'
* room_id: <room that the hangup button was pressed in>
* }
*
* {
* action: 'answer'
* room_id: <room that the answer button was pressed in>
* }
*/
import {MatrixClientPeg} from './MatrixClientPeg';
import PlatformPeg from './PlatformPeg';
import Modal from './Modal';
import * as sdk from './index';
import { _t } from './languageHandler';
import Matrix from 'matrix-js-sdk';
import dis from './dispatcher';
import { showUnknownDeviceDialogForCalls } from './cryptodevices';
import WidgetUtils from './utils/WidgetUtils';
import WidgetEchoStore from './stores/WidgetEchoStore';
import SettingsStore, { SettingLevel } from './settings/SettingsStore';
import {generateHumanReadableId} from "./utils/NamingUtils";
import {Jitsi} from "./widgets/Jitsi";
global.mxCalls = {
//room_id: MatrixCall
};
const calls = global.mxCalls;
let ConferenceHandler = null;
const audioPromises = {};
function play(audioId) {
// TODO: Attach an invisible element for this instead
// which listens?
const audio = document.getElementById(audioId);
if (audio) {
const playAudio = async () => {
try {
// This still causes the chrome debugger to break on promise rejection if
// the promise is rejected, even though we're catching the exception.
await audio.play();
} catch (e) {
// This is usually because the user hasn't interacted with the document,
// or chrome doesn't think so and is denying the request. Not sure what
// we can really do here...
// https://github.com/vector-im/riot-web/issues/7657
console.log("Unable to play audio clip", e);
}
};
if (audioPromises[audioId]) {
audioPromises[audioId] = audioPromises[audioId].then(()=>{
audio.load();
return playAudio();
});
} else {
audioPromises[audioId] = playAudio();
}
}
}
function pause(audioId) {
// TODO: Attach an invisible element for this instead
// which listens?
const audio = document.getElementById(audioId);
if (audio) {
if (audioPromises[audioId]) {
audioPromises[audioId] = audioPromises[audioId].then(()=>audio.pause());
} else {
// pause doesn't actually return a promise, but might as well do this for symmetry with play();
audioPromises[audioId] = audio.pause();
}
}
}
function _reAttemptCall(call) {
if (call.direction === 'outbound') {
dis.dispatch({
action: 'place_call',
room_id: call.roomId,
type: call.type,
});
} else {
call.answer();
}
}
function _setCallListeners(call) {
call.on("error", function(err) {
console.error("Call error:", err);
if (err.code === 'unknown_devices') {
const QuestionDialog = sdk.getComponent("dialogs.QuestionDialog");
Modal.createTrackedDialog('Call Failed', '', QuestionDialog, {
title: _t('Call Failed'),
description: _t(
"There are unknown sessions in this room: "+
"if you proceed without verifying them, it will be "+
"possible for someone to eavesdrop on your call.",
),
button: _t('Review Sessions'),
onFinished: function(confirmed) {
if (confirmed) {
const room = MatrixClientPeg.get().getRoom(call.roomId);
showUnknownDeviceDialogForCalls(
MatrixClientPeg.get(),
room,
() => {
_reAttemptCall(call);
},
call.direction === 'outbound' ? _t("Call Anyway") : _t("Answer Anyway"),
call.direction === 'outbound' ? _t("Call") : _t("Answer"),
);
}
},
});
} else {
if (
MatrixClientPeg.get().getTurnServers().length === 0 &&
SettingsStore.getValue("fallbackICEServerAllowed") === null
) {
_showICEFallbackPrompt();
return;
}
const ErrorDialog = sdk.getComponent("dialogs.ErrorDialog");
Modal.createTrackedDialog('Call Failed', '', ErrorDialog, {
title: _t('Call Failed'),
description: err.message,
});
}
});
call.on("hangup", function() {
_setCallState(undefined, call.roomId, "ended");
});
// map web rtc states to dummy UI state
// ringing|ringback|connected|ended|busy|stop_ringback|stop_ringing
call.on("state", function(newState, oldState) {
if (newState === "ringing") {
_setCallState(call, call.roomId, "ringing");
pause("ringbackAudio");
} else if (newState === "invite_sent") {
_setCallState(call, call.roomId, "ringback");
play("ringbackAudio");
} else if (newState === "ended" && oldState === "connected") {
_setCallState(undefined, call.roomId, "ended");
pause("ringbackAudio");
play("callendAudio");
} else if (newState === "ended" && oldState === "invite_sent" &&
(call.hangupParty === "remote" ||
(call.hangupParty === "local" && call.hangupReason === "invite_timeout")
)) {
_setCallState(call, call.roomId, "busy");
pause("ringbackAudio");
play("busyAudio");
const ErrorDialog = sdk.getComponent("dialogs.ErrorDialog");
Modal.createTrackedDialog('Call Handler', 'Call Timeout', ErrorDialog, {
title: _t('Call Timeout'),
description: _t('The remote side failed to pick up') + '.',
});
} else if (oldState === "invite_sent") {
_setCallState(call, call.roomId, "stop_ringback");
pause("ringbackAudio");
} else if (oldState === "ringing") {
_setCallState(call, call.roomId, "stop_ringing");
pause("ringbackAudio");
} else if (newState === "connected") {
_setCallState(call, call.roomId, "connected");
pause("ringbackAudio");
}
});
}
function _setCallState(call, roomId, status) {
console.log(
`Call state in ${roomId} changed to ${status} (${call ? call.call_state : "-"})`,
);
calls[roomId] = call;
if (status === "ringing") {
play("ringAudio");
} else if (call && call.call_state === "ringing") {
pause("ringAudio");
}
if (call) {
call.call_state = status;
}
dis.dispatch({
action: 'call_state',
room_id: roomId,
state: status,
});
}
function _showICEFallbackPrompt() {
const cli = MatrixClientPeg.get();
const QuestionDialog = sdk.getComponent("dialogs.QuestionDialog");
const code = sub => <code>{sub}</code>;
Modal.createTrackedDialog('No TURN servers', '', QuestionDialog, {
title: _t("Call failed due to misconfigured server"),
description: <div>
<p>{_t(
"Please ask the administrator of your homeserver " +
"(<code>%(homeserverDomain)s</code>) to configure a TURN server in " +
"order for calls to work reliably.",
{ homeserverDomain: cli.getDomain() }, { code },
)}</p>
<p>{_t(
"Alternatively, you can try to use the public server at " +
"<code>turn.matrix.org</code>, but this will not be as reliable, and " +
"it will share your IP address with that server. You can also manage " +
"this in Settings.",
null, { code },
)}</p>
</div>,
button: _t('Try using turn.matrix.org'),
cancelButton: _t('OK'),
onFinished: (allow) => {
SettingsStore.setValue("fallbackICEServerAllowed", null, SettingLevel.DEVICE, allow);
cli.setFallbackICEServerAllowed(allow);
},
}, null, true);
}
function _onAction(payload) {
function placeCall(newCall) {
_setCallListeners(newCall);
if (payload.type === 'voice') {
newCall.placeVoiceCall();
} else if (payload.type === 'video') {
newCall.placeVideoCall(
payload.remote_element,
payload.local_element,
);
} else if (payload.type === 'screensharing') {
const screenCapErrorString = PlatformPeg.get().screenCaptureErrorString();
if (screenCapErrorString) {
_setCallState(undefined, newCall.roomId, "ended");
console.log("Can't capture screen: " + screenCapErrorString);
const ErrorDialog = sdk.getComponent("dialogs.ErrorDialog");
Modal.createTrackedDialog('Call Handler', 'Unable to capture screen', ErrorDialog, {
title: _t('Unable to capture screen'),
description: screenCapErrorString,
});
return;
}
newCall.placeScreenSharingCall(
payload.remote_element,
payload.local_element,
);
} else {
console.error("Unknown conf call type: %s", payload.type);
}
}
switch (payload.action) {
case 'place_call':
{
if (callHandler.getAnyActiveCall()) {
const ErrorDialog = sdk.getComponent("dialogs.ErrorDialog");
Modal.createTrackedDialog('Call Handler', 'Existing Call', ErrorDialog, {
title: _t('Existing Call'),
description: _t('You are already in a call.'),
});
return; // don't allow >1 call to be placed.
}
// if the runtime env doesn't do VoIP, whine.
if (!MatrixClientPeg.get().supportsVoip()) {
const ErrorDialog = sdk.getComponent("dialogs.ErrorDialog");
Modal.createTrackedDialog('Call Handler', 'VoIP is unsupported', ErrorDialog, {
title: _t('VoIP is unsupported'),
description: _t('You cannot place VoIP calls in this browser.'),
});
return;
}
const room = MatrixClientPeg.get().getRoom(payload.room_id);
if (!room) {
console.error("Room %s does not exist.", payload.room_id);
return;
}
const members = room.getJoinedMembers();
if (members.length <= 1) {
const ErrorDialog = sdk.getComponent("dialogs.ErrorDialog");
Modal.createTrackedDialog('Call Handler', 'Cannot place call with self', ErrorDialog, {
description: _t('You cannot place a call with yourself.'),
});
return;
} else if (members.length === 2) {
console.info("Place %s call in %s", payload.type, payload.room_id);
const call = Matrix.createNewMatrixCall(MatrixClientPeg.get(), payload.room_id);
placeCall(call);
} else { // > 2
dis.dispatch({
action: "place_conference_call",
room_id: payload.room_id,
type: payload.type,
remote_element: payload.remote_element,
local_element: payload.local_element,
});
}
}
break;
case 'place_conference_call':
console.info("Place conference call in %s", payload.room_id);
_startCallApp(payload.room_id, payload.type);
break;
case 'incoming_call':
{
if (callHandler.getAnyActiveCall()) {
// ignore multiple incoming calls. in future, we may want a line-1/line-2 setup.
// we avoid rejecting with "busy" in case the user wants to answer it on a different device.
// in future we could signal a "local busy" as a warning to the caller.
// see https://github.com/vector-im/vector-web/issues/1964
return;
}
// if the runtime env doesn't do VoIP, stop here.
if (!MatrixClientPeg.get().supportsVoip()) {
return;
}
const call = payload.call;
_setCallListeners(call);
_setCallState(call, call.roomId, "ringing");
}
break;
case 'hangup':
if (!calls[payload.room_id]) {
return; // no call to hangup
}
calls[payload.room_id].hangup();
_setCallState(null, payload.room_id, "ended");
break;
case 'answer':
if (!calls[payload.room_id]) {
return; // no call to answer
}
calls[payload.room_id].answer();
_setCallState(calls[payload.room_id], payload.room_id, "connected");
dis.dispatch({
action: "view_room",
room_id: payload.room_id,
});
break;
}
}
async function _startCallApp(roomId, type) {
dis.dispatch({
action: 'appsDrawer',
show: true,
});
const room = MatrixClientPeg.get().getRoom(roomId);
const currentRoomWidgets = WidgetUtils.getRoomWidgets(room);
if (WidgetEchoStore.roomHasPendingWidgetsOfType(roomId, currentRoomWidgets, 'jitsi')) {
const ErrorDialog = sdk.getComponent("dialogs.ErrorDialog");
Modal.createTrackedDialog('Call already in progress', '', ErrorDialog, {
title: _t('Call in Progress'),
description: _t('A call is currently being placed!'),
});
return;
}
const currentJitsiWidgets = currentRoomWidgets.filter((ev) => {
return ev.getContent().type === 'jitsi';
});
if (currentJitsiWidgets.length > 0) {
console.warn(
"Refusing to start conference call widget in " + roomId +
" a conference call widget is already present",
);
const ErrorDialog = sdk.getComponent("dialogs.ErrorDialog");
Modal.createTrackedDialog('Already have Jitsi Widget', '', ErrorDialog, {
title: _t('Call in Progress'),
description: _t('A call is already in progress!'),
});
return;
}
const confId = `JitsiConference${generateHumanReadableId()}`;
const jitsiDomain = Jitsi.getInstance().preferredDomain;
let widgetUrl = WidgetUtils.getLocalJitsiWrapperUrl();
// TODO: Remove URL hacks when the mobile clients eventually support v2 widgets
const parsedUrl = new URL(widgetUrl);
parsedUrl.search = ''; // set to empty string to make the URL class use searchParams instead
parsedUrl.searchParams.set('confId', confId);
widgetUrl = parsedUrl.toString();
const widgetData = {
conferenceId: confId,
isAudioOnly: type === 'voice',
domain: jitsiDomain,
};
const widgetId = (
'jitsi_' +
MatrixClientPeg.get().credentials.userId +
'_' +
Date.now()
);
WidgetUtils.setRoomWidget(roomId, widgetId, 'jitsi', widgetUrl, 'Jitsi', widgetData).then(() => {
console.log('Jitsi widget added');
}).catch((e) => {
if (e.errcode === 'M_FORBIDDEN') {
const ErrorDialog = sdk.getComponent("dialogs.ErrorDialog");
Modal.createTrackedDialog('Call Failed', '', ErrorDialog, {
title: _t('Permission Required'),
description: _t("You do not have permission to start a conference call in this room"),
});
}
console.error(e);
});
}
// FIXME: Nasty way of making sure we only register
// with the dispatcher once
if (!global.mxCallHandler) {
dis.register(_onAction);
// add empty handlers for media actions, otherwise the media keys
// end up causing the audio elements with our ring/ringback etc
// audio clips in to play.
if (navigator.mediaSession) {
navigator.mediaSession.setActionHandler('play', function() {});
navigator.mediaSession.setActionHandler('pause', function() {});
navigator.mediaSession.setActionHandler('seekbackward', function() {});
navigator.mediaSession.setActionHandler('seekforward', function() {});
navigator.mediaSession.setActionHandler('previoustrack', function() {});
navigator.mediaSession.setActionHandler('nexttrack', function() {});
}
}
const callHandler = {
getCallForRoom: function(roomId) {
let call = callHandler.getCall(roomId);
if (call) return call;
if (ConferenceHandler) {
call = ConferenceHandler.getConferenceCallForRoom(roomId);
}
if (call) return call;
return null;
},
getCall: function(roomId) {
return calls[roomId] || null;
},
getAnyActiveCall: function() {
const roomsWithCalls = Object.keys(calls);
for (let i = 0; i < roomsWithCalls.length; i++) {
if (calls[roomsWithCalls[i]] &&
calls[roomsWithCalls[i]].call_state !== "ended") {
return calls[roomsWithCalls[i]];
}
}
return null;
},
/**
* The conference handler is a module that deals with implementation-specific
* multi-party calling implementations. Riot passes in its own which creates
* a one-to-one call with a freeswitch conference bridge. As of July 2018,
* the de-facto way of conference calling is a Jitsi widget, so this is
* deprecated. It reamins here for two reasons:
* 1. So Riot still supports joining existing freeswitch conference calls
* (but doesn't support creating them). After a transition period, we can
* remove support for joining them too.
* 2. To hide the one-to-one rooms that old-style conferencing creates. This
* is much harder to remove: probably either we make Riot leave & forget these
* rooms after we remove support for joining freeswitch conferences, or we
* accept that random rooms with cryptic users will suddently appear for
* anyone who's ever used conference calling, or we are stuck with this
* code forever.
*
* @param {object} confHandler The conference handler object
*/
setConferenceHandler: function(confHandler) {
ConferenceHandler = confHandler;
},
getConferenceHandler: function() {
return ConferenceHandler;
},
};
// Only things in here which actually need to be global are the
// calls list (done separately) and making sure we only register
// with the dispatcher once (which uses this mechanism but checks
// separately). This could be tidied up.
if (global.mxCallHandler === undefined) {
global.mxCallHandler = callHandler;
}
export default global.mxCallHandler;

1086
src/CallHandler.tsx Normal file

File diff suppressed because it is too large Load diff

View file

@ -1,87 +0,0 @@
/*
Copyright 2017 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.
*/
import * as Matrix from 'matrix-js-sdk';
import SettingsStore, {SettingLevel} from "./settings/SettingsStore";
export default {
hasAnyLabeledDevices: async function() {
const devices = await navigator.mediaDevices.enumerateDevices();
return devices.some(d => !!d.label);
},
getDevices: function() {
// Only needed for Electron atm, though should work in modern browsers
// once permission has been granted to the webapp
return navigator.mediaDevices.enumerateDevices().then(function(devices) {
const audiooutput = [];
const audioinput = [];
const videoinput = [];
devices.forEach((device) => {
switch (device.kind) {
case 'audiooutput': audiooutput.push(device); break;
case 'audioinput': audioinput.push(device); break;
case 'videoinput': videoinput.push(device); break;
}
});
// console.log("Loaded WebRTC Devices", mediaDevices);
return {
audiooutput,
audioinput,
videoinput,
};
}, (error) => { console.log('Unable to refresh WebRTC Devices: ', error); });
},
loadDevices: function() {
const audioOutDeviceId = SettingsStore.getValue("webrtc_audiooutput");
const audioDeviceId = SettingsStore.getValue("webrtc_audioinput");
const videoDeviceId = SettingsStore.getValue("webrtc_videoinput");
Matrix.setMatrixCallAudioOutput(audioOutDeviceId);
Matrix.setMatrixCallAudioInput(audioDeviceId);
Matrix.setMatrixCallVideoInput(videoDeviceId);
},
setAudioOutput: function(deviceId) {
SettingsStore.setValue("webrtc_audiooutput", null, SettingLevel.DEVICE, deviceId);
Matrix.setMatrixCallAudioOutput(deviceId);
},
setAudioInput: function(deviceId) {
SettingsStore.setValue("webrtc_audioinput", null, SettingLevel.DEVICE, deviceId);
Matrix.setMatrixCallAudioInput(deviceId);
},
setVideoInput: function(deviceId) {
SettingsStore.setValue("webrtc_videoinput", null, SettingLevel.DEVICE, deviceId);
Matrix.setMatrixCallVideoInput(deviceId);
},
getAudioOutput: function() {
return SettingsStore.getValueAt(SettingLevel.DEVICE, "webrtc_audiooutput");
},
getAudioInput: function() {
return SettingsStore.getValueAt(SettingLevel.DEVICE, "webrtc_audioinput");
},
getVideoInput: function() {
return SettingsStore.getValueAt(SettingLevel.DEVICE, "webrtc_videoinput");
},
};

View file

@ -1,6 +1,7 @@
/*
Copyright 2015, 2016 OpenMarket Ltd
Copyright 2019 New Vector 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.
@ -15,20 +16,29 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
'use strict';
import React from "react";
import { MatrixClient } from "matrix-js-sdk/src/client";
import extend from './extend';
import dis from './dispatcher';
import {MatrixClientPeg} from './MatrixClientPeg';
import dis from './dispatcher/dispatcher';
import * as sdk from './index';
import { _t } from './languageHandler';
import Modal from './Modal';
import RoomViewStore from './stores/RoomViewStore';
import encrypt from "browser-encrypt-attachment";
import extractPngChunks from "png-chunks-extract";
// Polyfill for Canvas.toBlob API using Canvas.toDataURL
import "blueimp-canvas-to-blob";
import Spinner from "./components/views/elements/Spinner";
import { Action } from "./dispatcher/actions";
import CountlyAnalytics from "./CountlyAnalytics";
import {
UploadCanceledPayload,
UploadErrorPayload,
UploadFinishedPayload,
UploadProgressPayload,
UploadStartedPayload,
} from "./dispatcher/payloads/UploadPayload";
import { IUpload } from "./models/IUpload";
import { IAbortablePromise, IImageInfo } from "matrix-js-sdk/src/@types/partials";
import { BlurhashEncoder } from "./BlurhashEncoder";
const MAX_WIDTH = 800;
const MAX_HEIGHT = 600;
@ -37,8 +47,43 @@ const MAX_HEIGHT = 600;
// 5669 px (x-axis) , 5669 px (y-axis) , per metre
const PHYS_HIDPI = [0x00, 0x00, 0x16, 0x25, 0x00, 0x00, 0x16, 0x25, 0x01];
export const BLURHASH_FIELD = "xyz.amorgan.blurhash"; // MSC2448
export class UploadCanceledError extends Error {}
type ThumbnailableElement = HTMLImageElement | HTMLVideoElement;
interface IMediaConfig {
"m.upload.size"?: number;
}
interface IContent {
body: string;
msgtype: string;
info: {
size: number;
mimetype?: string;
};
file?: string;
url?: string;
}
interface IThumbnail {
info: {
// eslint-disable-next-line camelcase
thumbnail_info: {
w: number;
h: number;
mimetype: string;
size: number;
};
w: number;
h: number;
[BLURHASH_FIELD]: string;
};
thumbnail: Blob;
}
/**
* Create a thumbnail for a image DOM element.
* The image will be smaller than MAX_WIDTH and MAX_HEIGHT.
@ -51,45 +96,68 @@ export class UploadCanceledError extends Error {}
* about the original image and the thumbnail.
*
* @param {HTMLElement} element The element to thumbnail.
* @param {integer} inputWidth The width of the image in the input element.
* @param {integer} inputHeight the width of the image in the input element.
* @param {number} inputWidth The width of the image in the input element.
* @param {number} inputHeight the width of the image in the input element.
* @param {String} mimeType The mimeType to save the blob as.
* @return {Promise} A promise that resolves with an object with an info key
* and a thumbnail key.
*/
function createThumbnail(element, inputWidth, inputHeight, mimeType) {
return new Promise((resolve) => {
let targetWidth = inputWidth;
let targetHeight = inputHeight;
if (targetHeight > MAX_HEIGHT) {
targetWidth = Math.floor(targetWidth * (MAX_HEIGHT / targetHeight));
targetHeight = MAX_HEIGHT;
}
if (targetWidth > MAX_WIDTH) {
targetHeight = Math.floor(targetHeight * (MAX_WIDTH / targetWidth));
targetWidth = MAX_WIDTH;
}
async function createThumbnail(
element: ThumbnailableElement,
inputWidth: number,
inputHeight: number,
mimeType: string,
): Promise<IThumbnail> {
let targetWidth = inputWidth;
let targetHeight = inputHeight;
if (targetHeight > MAX_HEIGHT) {
targetWidth = Math.floor(targetWidth * (MAX_HEIGHT / targetHeight));
targetHeight = MAX_HEIGHT;
}
if (targetWidth > MAX_WIDTH) {
targetHeight = Math.floor(targetHeight * (MAX_WIDTH / targetWidth));
targetWidth = MAX_WIDTH;
}
const canvas = document.createElement("canvas");
let canvas: HTMLCanvasElement | OffscreenCanvas;
if (window.OffscreenCanvas) {
canvas = new window.OffscreenCanvas(targetWidth, targetHeight);
} else {
canvas = document.createElement("canvas");
canvas.width = targetWidth;
canvas.height = targetHeight;
canvas.getContext("2d").drawImage(element, 0, 0, targetWidth, targetHeight);
canvas.toBlob(function(thumbnail) {
resolve({
info: {
thumbnail_info: {
w: targetWidth,
h: targetHeight,
mimetype: thumbnail.type,
size: thumbnail.size,
},
w: inputWidth,
h: inputHeight,
},
thumbnail: thumbnail,
});
}, mimeType);
});
}
const context = canvas.getContext("2d");
context.drawImage(element, 0, 0, targetWidth, targetHeight);
let thumbnailPromise: Promise<Blob>;
if (window.OffscreenCanvas) {
thumbnailPromise = (canvas as OffscreenCanvas).convertToBlob({ type: mimeType });
} else {
thumbnailPromise = new Promise<Blob>(resolve => (canvas as HTMLCanvasElement).toBlob(resolve, mimeType));
}
const imageData = context.getImageData(0, 0, targetWidth, targetHeight);
// thumbnailPromise and blurhash promise are being awaited concurrently
const blurhash = await BlurhashEncoder.instance.getBlurhash(imageData);
const thumbnail = await thumbnailPromise;
return {
info: {
thumbnail_info: {
w: targetWidth,
h: targetHeight,
mimetype: thumbnail.type,
size: thumbnail.size,
},
w: inputWidth,
h: inputHeight,
[BLURHASH_FIELD]: blurhash,
},
thumbnail,
};
}
/**
@ -98,7 +166,7 @@ function createThumbnail(element, inputWidth, inputHeight, mimeType) {
* @param {File} imageFile The file to load in an image element.
* @return {Promise} A promise that resolves with the html image element.
*/
async function loadImageElement(imageFile) {
async function loadImageElement(imageFile: File) {
// Load the file into an html element
const img = document.createElement("img");
const objectUrl = URL.createObjectURL(imageFile);
@ -128,8 +196,7 @@ async function loadImageElement(imageFile) {
for (const chunk of chunks) {
if (chunk.name === 'pHYs') {
if (chunk.data.byteLength !== PHYS_HIDPI.length) return;
const hidpi = chunk.data.every((val, i) => val === PHYS_HIDPI[i]);
return hidpi;
return chunk.data.every((val, i) => val === PHYS_HIDPI[i]);
}
}
return false;
@ -139,7 +206,7 @@ async function loadImageElement(imageFile) {
const [hidpi] = await Promise.all([parsePromise, imgPromise]);
const width = hidpi ? (img.width >> 1) : img.width;
const height = hidpi ? (img.height >> 1) : img.height;
return {width, height, img};
return { width, height, img };
}
/**
@ -152,17 +219,17 @@ async function loadImageElement(imageFile) {
*/
function infoForImageFile(matrixClient, roomId, imageFile) {
let thumbnailType = "image/png";
if (imageFile.type == "image/jpeg") {
if (imageFile.type === "image/jpeg") {
thumbnailType = "image/jpeg";
}
let imageInfo;
return loadImageElement(imageFile).then(function(r) {
return loadImageElement(imageFile).then((r) => {
return createThumbnail(r.img, r.width, r.height, thumbnailType);
}).then(function(result) {
}).then((result) => {
imageInfo = result.info;
return uploadFile(matrixClient, roomId, result.thumbnail);
}).then(function(result) {
}).then((result) => {
imageInfo.thumbnail_url = result.url;
imageInfo.thumbnail_file = result.file;
return imageInfo;
@ -170,29 +237,35 @@ function infoForImageFile(matrixClient, roomId, imageFile) {
}
/**
* Load a file into a newly created video element.
* Load a file into a newly created video element and pull some strings
* in an attempt to guarantee the first frame will be showing.
*
* @param {File} videoFile The file to load in an video element.
* @return {Promise} A promise that resolves with the video image element.
*/
function loadVideoElement(videoFile) {
function loadVideoElement(videoFile): Promise<HTMLVideoElement> {
return new Promise((resolve, reject) => {
// Load the file into an html element
const video = document.createElement("video");
video.preload = "metadata";
video.playsInline = true;
video.muted = true;
const reader = new FileReader();
reader.onload = function(e) {
video.src = e.target.result;
// Once ready, returns its size
reader.onload = function(ev) {
// Wait until we have enough data to thumbnail the first frame.
video.onloadeddata = function() {
video.onloadeddata = async function() {
resolve(video);
video.pause();
};
video.onerror = function(e) {
reject(e);
};
video.src = ev.target.result as string;
video.load();
video.play();
};
reader.onerror = function(e) {
reject(e);
@ -213,12 +286,12 @@ function infoForVideoFile(matrixClient, roomId, videoFile) {
const thumbnailType = "image/jpeg";
let videoInfo;
return loadVideoElement(videoFile).then(function(video) {
return loadVideoElement(videoFile).then((video) => {
return createThumbnail(video, video.videoWidth, video.videoHeight, thumbnailType);
}).then(function(result) {
}).then((result) => {
videoInfo = result.info;
return uploadFile(matrixClient, roomId, result.thumbnail);
}).then(function(result) {
}).then((result) => {
videoInfo.thumbnail_url = result.url;
videoInfo.thumbnail_file = result.file;
return videoInfo;
@ -231,11 +304,11 @@ function infoForVideoFile(matrixClient, roomId, videoFile) {
* @return {Promise} A promise that resolves with an ArrayBuffer when the file
* is read.
*/
function readFileAsArrayBuffer(file) {
function readFileAsArrayBuffer(file: File | Blob): Promise<ArrayBuffer> {
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onload = function(e) {
resolve(e.target.result);
resolve(e.target.result as ArrayBuffer);
};
reader.onerror = function(e) {
reject(e);
@ -257,11 +330,16 @@ function readFileAsArrayBuffer(file) {
* If the file is unencrypted then the object will have a "url" key.
* If the file is encrypted then the object will have a "file" key.
*/
function uploadFile(matrixClient, roomId, file, progressHandler) {
export function uploadFile(
matrixClient: MatrixClient,
roomId: string,
file: File | Blob,
progressHandler?: any, // TODO: Types
): IAbortablePromise<{url?: string, file?: any}> { // TODO: Types
let canceled = false;
if (matrixClient.isRoomEncrypted(roomId)) {
// If the room is encrypted then encrypt the file before uploading it.
// First read the file into memory.
let canceled = false;
let uploadPromise;
let encryptInfo;
const prom = readFileAsArrayBuffer(file).then(function(data) {
@ -278,9 +356,9 @@ function uploadFile(matrixClient, roomId, file, progressHandler) {
progressHandler: progressHandler,
includeFilename: false,
});
return uploadPromise;
}).then(function(url) {
if (canceled) throw new UploadCanceledError();
// If the attachment is encrypted then bundle the URL along
// with the information needed to decrypt the attachment and
// add it under a file key.
@ -288,11 +366,11 @@ function uploadFile(matrixClient, roomId, file, progressHandler) {
if (file.type) {
encryptInfo.mimetype = file.type;
}
return {"file": encryptInfo};
});
return { "file": encryptInfo };
}) as IAbortablePromise<{ file: any }>;
prom.abort = () => {
canceled = true;
if (uploadPromise) MatrixClientPeg.get().cancelUpload(uploadPromise);
if (uploadPromise) matrixClient.cancelUpload(uploadPromise);
};
return prom;
} else {
@ -300,104 +378,76 @@ function uploadFile(matrixClient, roomId, file, progressHandler) {
progressHandler: progressHandler,
});
const promise1 = basePromise.then(function(url) {
if (canceled) throw new UploadCanceledError();
// If the attachment isn't encrypted then include the URL directly.
return {"url": url};
});
// XXX: copy over the abort method to the new promise
promise1.abort = basePromise.abort;
return { url };
}) as IAbortablePromise<{ url: string }>;
promise1.abort = () => {
canceled = true;
matrixClient.cancelUpload(basePromise);
};
return promise1;
}
}
export default class ContentMessages {
constructor() {
this.inprogress = [];
this.nextId = 0;
this._mediaConfig = null;
}
private inprogress: IUpload[] = [];
private mediaConfig: IMediaConfig = null;
static sharedInstance() {
if (global.mx_ContentMessages === undefined) {
global.mx_ContentMessages = new ContentMessages();
}
return global.mx_ContentMessages;
}
_isFileSizeAcceptable(file) {
if (this._mediaConfig !== null &&
this._mediaConfig["m.upload.size"] !== undefined &&
file.size > this._mediaConfig["m.upload.size"]) {
return false;
}
return true;
}
_ensureMediaConfigFetched() {
if (this._mediaConfig !== null) return;
console.log("[Media Config] Fetching");
return MatrixClientPeg.get().getMediaConfig().then((config) => {
console.log("[Media Config] Fetched config:", config);
return config;
}).catch(() => {
// Media repo can't or won't report limits, so provide an empty object (no limits).
console.log("[Media Config] Could not fetch config, so not limiting uploads.");
return {};
}).then((config) => {
this._mediaConfig = config;
});
}
sendStickerContentToRoom(url, roomId, info, text, matrixClient) {
return MatrixClientPeg.get().sendStickerMessage(roomId, url, info, text).catch((e) => {
sendStickerContentToRoom(url: string, roomId: string, info: IImageInfo, text: string, matrixClient: MatrixClient) {
const startTime = CountlyAnalytics.getTimestamp();
const prom = matrixClient.sendStickerMessage(roomId, url, info, text).catch((e) => {
console.warn(`Failed to send content with URL ${url} to room ${roomId}`, e);
throw e;
});
CountlyAnalytics.instance.trackSendMessage(startTime, prom, roomId, false, false, { msgtype: "m.sticker" });
return prom;
}
getUploadLimit() {
if (this._mediaConfig !== null && this._mediaConfig["m.upload.size"] !== undefined) {
return this._mediaConfig["m.upload.size"];
if (this.mediaConfig !== null && this.mediaConfig["m.upload.size"] !== undefined) {
return this.mediaConfig["m.upload.size"];
} else {
return null;
}
}
async sendContentListToRoom(files, roomId, matrixClient) {
async sendContentListToRoom(files: File[], roomId: string, matrixClient: MatrixClient) {
if (matrixClient.isGuest()) {
dis.dispatch({action: 'require_registration'});
dis.dispatch({ action: 'require_registration' });
return;
}
const isQuoting = Boolean(RoomViewStore.getQuotingEvent());
if (isQuoting) {
// FIXME: Using an import will result in Element crashing
const QuestionDialog = sdk.getComponent("dialogs.QuestionDialog");
const shouldUpload = await new Promise((resolve) => {
Modal.createTrackedDialog('Upload Reply Warning', '', QuestionDialog, {
title: _t('Replying With Files'),
description: (
<div>{_t(
'At this time it is not possible to reply with a file. ' +
'Would you like to upload this file without replying?',
)}</div>
),
hasCancelButton: true,
button: _t("Continue"),
onFinished: (shouldUpload) => {
resolve(shouldUpload);
},
});
const { finished } = Modal.createTrackedDialog<[boolean]>('Upload Reply Warning', '', QuestionDialog, {
title: _t('Replying With Files'),
description: (
<div>{_t(
'At this time it is not possible to reply with a file. ' +
'Would you like to upload this file without replying?',
)}</div>
),
hasCancelButton: true,
button: _t("Continue"),
});
const [shouldUpload] = await finished;
if (!shouldUpload) return;
}
await this._ensureMediaConfigFetched();
if (!this.mediaConfig) { // hot-path optimization to not flash a spinner if we don't need to
const modal = Modal.createDialog(Spinner, null, 'mx_Dialog_spinner');
await this.ensureMediaConfigFetched(matrixClient);
modal.close();
}
const tooBigFiles = [];
const okFiles = [];
for (let i = 0; i < files.length; ++i) {
if (this._isFileSizeAcceptable(files[i])) {
if (this.isFileSizeAcceptable(files[i])) {
okFiles.push(files[i]);
} else {
tooBigFiles.push(files[i]);
@ -405,54 +455,70 @@ export default class ContentMessages {
}
if (tooBigFiles.length > 0) {
// FIXME: Using an import will result in Element crashing
const UploadFailureDialog = sdk.getComponent("dialogs.UploadFailureDialog");
const uploadFailureDialogPromise = new Promise((resolve) => {
Modal.createTrackedDialog('Upload Failure', '', UploadFailureDialog, {
badFiles: tooBigFiles,
totalFiles: files.length,
contentMessages: this,
onFinished: (shouldContinue) => {
resolve(shouldContinue);
},
});
const { finished } = Modal.createTrackedDialog<[boolean]>('Upload Failure', '', UploadFailureDialog, {
badFiles: tooBigFiles,
totalFiles: files.length,
contentMessages: this,
});
const shouldContinue = await uploadFailureDialogPromise;
const [shouldContinue] = await finished;
if (!shouldContinue) return;
}
const UploadConfirmDialog = sdk.getComponent("dialogs.UploadConfirmDialog");
let uploadAll = false;
// Promise to complete before sending next file into room, used for synchronisation of file-sending
// to match the order the files were specified in
let promBefore = Promise.resolve();
let promBefore: Promise<any> = Promise.resolve();
for (let i = 0; i < okFiles.length; ++i) {
const file = okFiles[i];
if (!uploadAll) {
const shouldContinue = await new Promise((resolve) => {
Modal.createTrackedDialog('Upload Files confirmation', '', UploadConfirmDialog, {
// FIXME: Using an import will result in Element crashing
const UploadConfirmDialog = sdk.getComponent("dialogs.UploadConfirmDialog");
const { finished } = Modal.createTrackedDialog<[boolean, boolean]>('Upload Files confirmation',
'', UploadConfirmDialog, {
file,
currentIndex: i,
totalFiles: okFiles.length,
onFinished: (shouldContinue, shouldUploadAll) => {
if (shouldUploadAll) {
uploadAll = true;
}
resolve(shouldContinue);
},
});
});
},
);
const [shouldContinue, shouldUploadAll] = await finished;
if (!shouldContinue) break;
if (shouldUploadAll) {
uploadAll = true;
}
}
promBefore = this._sendContentToRoom(file, roomId, matrixClient, promBefore);
promBefore = this.sendContentToRoom(file, roomId, matrixClient, promBefore);
}
}
_sendContentToRoom(file, roomId, matrixClient, promBefore) {
const content = {
getCurrentUploads() {
return this.inprogress.filter(u => !u.canceled);
}
cancelUpload(promise: Promise<any>, matrixClient: MatrixClient) {
let upload: IUpload;
for (let i = 0; i < this.inprogress.length; ++i) {
if (this.inprogress[i].promise === promise) {
upload = this.inprogress[i];
break;
}
}
if (upload) {
upload.canceled = true;
matrixClient.cancelUpload(upload.promise);
dis.dispatch<UploadCanceledPayload>({ action: Action.UploadCanceled, upload });
}
}
private sendContentToRoom(file: File, roomId: string, matrixClient: MatrixClient, promBefore: Promise<any>) {
const startTime = CountlyAnalytics.getTimestamp();
const content: IContent = {
body: file.name || 'Attachment',
info: {
size: file.size,
},
msgtype: "", // set later
};
// if we have a mime type for the file, add it to the message metadata
@ -460,26 +526,26 @@ export default class ContentMessages {
content.info.mimetype = file.type;
}
const prom = new Promise((resolve) => {
if (file.type.indexOf('image/') == 0) {
const prom = new Promise<void>((resolve) => {
if (file.type.indexOf('image/') === 0) {
content.msgtype = 'm.image';
infoForImageFile(matrixClient, roomId, file).then((imageInfo)=>{
extend(content.info, imageInfo);
infoForImageFile(matrixClient, roomId, file).then((imageInfo) => {
Object.assign(content.info, imageInfo);
resolve();
}, (error)=>{
console.error(error);
}, (e) => {
console.error(e);
content.msgtype = 'm.file';
resolve();
});
} else if (file.type.indexOf('audio/') == 0) {
} else if (file.type.indexOf('audio/') === 0) {
content.msgtype = 'm.audio';
resolve();
} else if (file.type.indexOf('video/') == 0) {
} else if (file.type.indexOf('video/') === 0) {
content.msgtype = 'm.video';
infoForVideoFile(matrixClient, roomId, file).then((videoInfo)=>{
extend(content.info, videoInfo);
infoForVideoFile(matrixClient, roomId, file).then((videoInfo) => {
Object.assign(content.info, videoInfo);
resolve();
}, (error)=>{
}, (e) => {
content.msgtype = 'm.file';
resolve();
});
@ -487,54 +553,62 @@ export default class ContentMessages {
content.msgtype = 'm.file';
resolve();
}
});
}) as IAbortablePromise<void>;
const upload = {
// create temporary abort handler for before the actual upload gets passed off to js-sdk
prom.abort = () => {
upload.canceled = true;
};
const upload: IUpload = {
fileName: file.name || 'Attachment',
roomId: roomId,
total: 0,
total: file.size,
loaded: 0,
promise: prom,
};
this.inprogress.push(upload);
dis.dispatch({action: 'upload_started'});
dis.dispatch<UploadStartedPayload>({ action: Action.UploadStarted, upload });
// Focus the composer view
dis.dispatch({action: 'focus_composer'});
let error;
dis.fire(Action.FocusSendMessageComposer);
function onProgress(ev) {
upload.total = ev.total;
upload.loaded = ev.loaded;
dis.dispatch({action: 'upload_progress', upload: upload});
dis.dispatch<UploadProgressPayload>({ action: Action.UploadProgress, upload });
}
let error;
return prom.then(function() {
if (upload.canceled) throw new UploadCanceledError();
// XXX: upload.promise must be the promise that
// is returned by uploadFile as it has an abort()
// method hacked onto it.
upload.promise = uploadFile(
matrixClient, roomId, file, onProgress,
);
upload.promise = uploadFile(matrixClient, roomId, file, onProgress);
return upload.promise.then(function(result) {
content.file = result.file;
content.url = result.url;
});
}).then((url) => {
}).then(() => {
// Await previous message being sent into the room
return promBefore;
}).then(function() {
return matrixClient.sendMessage(roomId, content);
if (upload.canceled) throw new UploadCanceledError();
const prom = matrixClient.sendMessage(roomId, content);
CountlyAnalytics.instance.trackSendMessage(startTime, prom, roomId, false, false, content);
return prom;
}, function(err) {
error = err;
if (!upload.canceled) {
let desc = _t("The file '%(fileName)s' failed to upload.", {fileName: upload.fileName});
if (err.http_status == 413) {
let desc = _t("The file '%(fileName)s' failed to upload.", { fileName: upload.fileName });
if (err.http_status === 413) {
desc = _t(
"The file '%(fileName)s' exceeds this homeserver's size limit for uploads",
{fileName: upload.fileName},
{ fileName: upload.fileName },
);
}
// FIXME: Using an import will result in Element crashing
const ErrorDialog = sdk.getComponent("dialogs.ErrorDialog");
Modal.createTrackedDialog('Upload failed', '', ErrorDialog, {
title: _t('Upload Failed'),
@ -542,11 +616,9 @@ export default class ContentMessages {
});
}
}).finally(() => {
const inprogressKeys = Object.keys(this.inprogress);
for (let i = 0; i < this.inprogress.length; ++i) {
const k = inprogressKeys[i];
if (this.inprogress[k].promise === upload.promise) {
this.inprogress.splice(k, 1);
if (this.inprogress[i].promise === upload.promise) {
this.inprogress.splice(i, 1);
break;
}
}
@ -555,34 +627,45 @@ export default class ContentMessages {
// clear the media size limit so we fetch it again next time
// we try to upload
if (error && error.http_status === 413) {
this._mediaConfig = null;
this.mediaConfig = null;
}
dis.dispatch({action: 'upload_failed', upload, error});
dis.dispatch<UploadErrorPayload>({ action: Action.UploadFailed, upload, error });
} else {
dis.dispatch({action: 'upload_finished', upload});
dis.dispatch({action: 'message_sent'});
dis.dispatch<UploadFinishedPayload>({ action: Action.UploadFinished, upload });
dis.dispatch({ action: 'message_sent' });
}
});
}
getCurrentUploads() {
return this.inprogress.filter(u => !u.canceled);
private isFileSizeAcceptable(file: File) {
if (this.mediaConfig !== null &&
this.mediaConfig["m.upload.size"] !== undefined &&
file.size > this.mediaConfig["m.upload.size"]) {
return false;
}
return true;
}
cancelUpload(promise) {
const inprogressKeys = Object.keys(this.inprogress);
let upload;
for (let i = 0; i < this.inprogress.length; ++i) {
const k = inprogressKeys[i];
if (this.inprogress[k].promise === promise) {
upload = this.inprogress[k];
break;
}
}
if (upload) {
upload.canceled = true;
MatrixClientPeg.get().cancelUpload(upload.promise);
dis.dispatch({action: 'upload_canceled', upload});
private ensureMediaConfigFetched(matrixClient: MatrixClient) {
if (this.mediaConfig !== null) return;
console.log("[Media Config] Fetching");
return matrixClient.getMediaConfig().then((config) => {
console.log("[Media Config] Fetched config:", config);
return config;
}).catch(() => {
// Media repo can't or won't report limits, so provide an empty object (no limits).
console.log("[Media Config] Could not fetch config, so not limiting uploads.");
return {};
}).then((config) => {
this.mediaConfig = config;
});
}
static sharedInstance() {
if (window.mxContentMessages === undefined) {
window.mxContentMessages = new ContentMessages();
}
return window.mxContentMessages;
}
}

972
src/CountlyAnalytics.ts Normal file
View file

@ -0,0 +1,972 @@
/*
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 { randomString } from "matrix-js-sdk/src/randomstring";
import { IContent } from "matrix-js-sdk/src/models/event";
import { sleep } from "matrix-js-sdk/src/utils";
import { getCurrentLanguage } from './languageHandler';
import PlatformPeg from './PlatformPeg';
import SdkConfig from './SdkConfig';
import { MatrixClientPeg } from "./MatrixClientPeg";
import RoomViewStore from "./stores/RoomViewStore";
import { Action } from "./dispatcher/actions";
const INACTIVITY_TIME = 20; // seconds
const HEARTBEAT_INTERVAL = 5_000; // ms
const SESSION_UPDATE_INTERVAL = 60; // seconds
const MAX_PENDING_EVENTS = 1000;
enum Orientation {
Landscape = "landscape",
Portrait = "portrait",
}
/* eslint-disable camelcase */
interface IMetrics {
_resolution?: string;
_app_version?: string;
_density?: number;
_ua?: string;
_locale?: string;
}
interface IEvent {
key: string;
count: number;
sum?: number;
dur?: number;
segmentation?: Record<string, unknown>;
timestamp?: number; // TODO should we use the timestamp when we start or end for the event timestamp
hour?: unknown;
dow?: unknown;
}
interface IViewEvent extends IEvent {
key: "[CLY]_view";
}
interface IOrientationEvent extends IEvent {
key: "[CLY]_orientation";
segmentation: {
mode: Orientation;
};
}
interface IStarRatingEvent extends IEvent {
key: "[CLY]_star_rating";
segmentation: {
// we just care about collecting feedback, no need to associate with a feedback widget
widget_id?: string;
contactMe?: boolean;
email?: string;
rating: 1 | 2 | 3 | 4 | 5;
comment: string;
};
}
type Value = string | number | boolean;
interface IOperationInc {
"$inc": number;
}
interface IOperationMul {
"$mul": number;
}
interface IOperationMax {
"$max": number;
}
interface IOperationMin {
"$min": number;
}
interface IOperationSetOnce {
"$setOnce": Value;
}
interface IOperationPush {
"$push": Value | Value[];
}
interface IOperationAddToSet {
"$addToSet": Value | Value[];
}
interface IOperationPull {
"$pull": Value | Value[];
}
type Operation =
IOperationInc |
IOperationMul |
IOperationMax |
IOperationMin |
IOperationSetOnce |
IOperationPush |
IOperationAddToSet |
IOperationPull;
interface IUserDetails {
name?: string;
username?: string;
email?: string;
organization?: string;
phone?: string;
picture?: string;
gender?: string;
byear?: number;
custom?: Record<string, Value | Operation>; // `.` and `$` will be stripped out
}
interface ICrash {
_resolution?: string;
_app_version: string;
_ram_current?: number;
_ram_total?: number;
_disk_current?: number;
_disk_total?: number;
_orientation?: Orientation;
_online?: boolean;
_muted?: boolean;
_background?: boolean;
_view?: string;
_name?: string;
_error: string;
_nonfatal?: boolean;
_logs?: string;
_run?: number;
_custom?: Record<string, string>;
}
interface IParams {
// APP_KEY of an app for which to report
app_key: string;
// User identifier
device_id: string;
// Should provide value 1 to indicate session start
begin_session?: number;
// JSON object as string to provide metrics to track with the user
metrics?: string;
// Provides session duration in seconds, can be used as heartbeat to update current sessions duration, recommended time every 60 seconds
session_duration?: number;
// Should provide value 1 to indicate session end
end_session?: number;
// 10 digit UTC timestamp for recording past data.
timestamp?: number;
// current user local hour (0 - 23)
hour?: number;
// day of the week (0-sunday, 1 - monday, ... 6 - saturday)
dow?: number;
// JSON array as string containing event objects
events?: string; // IEvent[]
// JSON object as string containing information about users
user_details?: string;
// provide when changing device ID, so server would merge the data
old_device_id?: string;
// See ICrash
crash?: string;
}
interface IRoomSegments extends Record<string, Value> {
room_id: string; // hashed
num_users: number;
is_encrypted: boolean;
is_public: boolean;
}
interface ISendMessageEvent extends IEvent {
key: "send_message";
dur: number; // how long it to send (until remote echo)
segmentation: IRoomSegments & {
is_edit: boolean;
is_reply: boolean;
msgtype: string;
format?: string;
};
}
interface IRoomDirectoryEvent extends IEvent {
key: "room_directory";
}
interface IRoomDirectoryDoneEvent extends IEvent {
key: "room_directory_done";
dur: number; // time spent in the room directory modal
}
interface IRoomDirectorySearchEvent extends IEvent {
key: "room_directory_search";
sum: number; // number of search results
segmentation: {
query_length: number;
query_num_words: number;
};
}
interface IStartCallEvent extends IEvent {
key: "start_call";
segmentation: IRoomSegments & {
is_video: boolean;
is_jitsi: boolean;
};
}
interface IJoinCallEvent extends IEvent {
key: "join_call";
segmentation: IRoomSegments & {
is_video: boolean;
is_jitsi: boolean;
};
}
interface IBeginInviteEvent extends IEvent {
key: "begin_invite";
segmentation: IRoomSegments;
}
interface ISendInviteEvent extends IEvent {
key: "send_invite";
sum: number; // quantity that was invited
segmentation: IRoomSegments;
}
interface ICreateRoomEvent extends IEvent {
key: "create_room";
dur: number; // how long it took to create (until remote echo)
segmentation: {
room_id: string; // hashed
num_users: number;
is_encrypted: boolean;
is_public: boolean;
};
}
interface IJoinRoomEvent extends IEvent {
key: Action.JoinRoom;
dur: number; // how long it took to join (until remote echo)
segmentation: {
room_id: string; // hashed
num_users: number;
is_encrypted: boolean;
is_public: boolean;
type: "room_directory" | "slash_command" | "link" | "invite";
};
}
/* eslint-enable camelcase */
const hashHex = async (input: string): Promise<string> => {
const buf = new TextEncoder().encode(input);
const digestBuf = await window.crypto.subtle.digest("sha-256", buf);
return [...new Uint8Array(digestBuf)].map((b: number) => b.toString(16).padStart(2, "0")).join("");
};
const knownScreens = new Set([
"register", "login", "forgot_password", "soft_logout", "new", "settings", "welcome", "home", "start", "directory",
"start_sso", "start_cas", "groups", "complete_security", "post_registration", "room", "user", "group",
]);
interface IViewData {
name: string;
url: string;
meta: Record<string, string>;
}
// Apply fn to all hash path parts after the 1st one
async function getViewData(anonymous = true): Promise<IViewData> {
const rand = randomString(8);
const { origin, hash } = window.location;
let { pathname } = window.location;
// Redact paths which could contain unexpected PII
if (origin.startsWith('file://')) {
pathname = `/<redacted_${rand}>/`; // XXX: inject rand because Count.ly doesn't like X->X transitions
}
let [_, screen, ...parts] = hash.split("/");
if (!knownScreens.has(screen)) {
screen = `<redacted_${rand}>`;
}
for (let i = 0; i < parts.length; i++) {
parts[i] = anonymous ? `<redacted_${rand}>` : await hashHex(parts[i]);
}
const hashStr = `${_}/${screen}/${parts.join("/")}`;
const url = origin + pathname + hashStr;
const meta = {};
let name = "$/" + hash;
switch (screen) {
case "room": {
name = "view_room";
const roomId = RoomViewStore.getRoomId();
name += " " + parts[0]; // XXX: workaround Count.ly missing X->X transitions
meta["room_id"] = parts[0];
Object.assign(meta, getRoomStats(roomId));
break;
}
}
return { name, url, meta };
}
const getRoomStats = (roomId: string) => {
const cli = MatrixClientPeg.get();
const room = cli?.getRoom(roomId);
return {
"num_users": room?.getJoinedMemberCount(),
"is_encrypted": cli?.isRoomEncrypted(roomId),
// eslint-disable-next-line camelcase
"is_public": room?.currentState.getStateEvents("m.room.join_rules", "")?.getContent()?.join_rule === "public",
};
};
// async wrapper for regex-powered String.prototype.replace
const strReplaceAsync = async (str: string, regex: RegExp, fn: (...args: string[]) => Promise<string>) => {
const promises: Promise<string>[] = [];
// dry-run to calculate the replace values
str.replace(regex, (...args: string[]) => {
promises.push(fn(...args));
return "";
});
const values = await Promise.all(promises);
return str.replace(regex, () => values.shift());
};
export default class CountlyAnalytics {
private baseUrl: URL = null;
private appKey: string = null;
private userKey: string = null;
private anonymous: boolean;
private appPlatform: string;
private appVersion = "unknown";
private initTime = CountlyAnalytics.getTimestamp();
private firstPage = true;
private heartbeatIntervalId: number;
private activityIntervalId: number;
private trackTime = true;
private lastBeat: number;
private storedDuration = 0;
private lastView: string;
private lastViewTime = 0;
private lastViewStoredDuration = 0;
private sessionStarted = false;
private heartbeatEnabled = false;
private inactivityCounter = 0;
private pendingEvents: IEvent[] = [];
private static internalInstance = new CountlyAnalytics();
public static get instance(): CountlyAnalytics {
return CountlyAnalytics.internalInstance;
}
public get disabled() {
return !this.baseUrl;
}
public canEnable() {
const config = SdkConfig.get();
return Boolean(navigator.doNotTrack !== "1" && config?.countly?.url && config?.countly?.appKey);
}
private async changeUserKey(userKey: string, merge = false) {
const oldUserKey = this.userKey;
this.userKey = userKey;
if (oldUserKey && merge) {
await this.request({ old_device_id: oldUserKey });
}
}
public async enable(anonymous = true) {
if (!this.disabled && this.anonymous === anonymous) return;
if (!this.canEnable()) return;
if (!this.disabled) {
// flush request queue as our userKey is going to change, no need to await it
this.request();
}
const config = SdkConfig.get();
this.baseUrl = new URL("/i", config.countly.url);
this.appKey = config.countly.appKey;
this.anonymous = anonymous;
if (anonymous) {
await this.changeUserKey(randomString(64));
} else {
await this.changeUserKey(await hashHex(MatrixClientPeg.get().getUserId()), true);
}
const platform = PlatformPeg.get();
this.appPlatform = platform.getHumanReadableName();
try {
this.appVersion = await platform.getAppVersion();
} catch (e) {
console.warn("Failed to get app version, using 'unknown'");
}
// start heartbeat
this.heartbeatIntervalId = setInterval(this.heartbeat.bind(this), HEARTBEAT_INTERVAL);
this.trackSessions();
this.trackErrors();
}
public async disable() {
if (this.disabled) return;
await this.track("Opt-Out" );
this.endSession();
window.clearInterval(this.heartbeatIntervalId);
window.clearTimeout(this.activityIntervalId);
this.baseUrl = null;
// remove listeners bound in trackSessions()
window.removeEventListener("beforeunload", this.endSession);
window.removeEventListener("unload", this.endSession);
window.removeEventListener("visibilitychange", this.onVisibilityChange);
window.removeEventListener("mousemove", this.onUserActivity);
window.removeEventListener("click", this.onUserActivity);
window.removeEventListener("keydown", this.onUserActivity);
window.removeEventListener("scroll", this.onUserActivity);
}
public reportFeedback(rating: 1 | 2 | 3 | 4 | 5, comment: string) {
this.track<IStarRatingEvent>("[CLY]_star_rating", { rating, comment }, null, {}, true);
}
public trackPageChange(generationTimeMs?: number) {
if (this.disabled) return;
// TODO use generationTimeMs
this.trackPageView();
}
private async trackPageView() {
this.reportViewDuration();
await sleep(0); // XXX: we sleep here because otherwise we get the old hash and not the new one
const viewData = await getViewData(this.anonymous);
const page = viewData.name;
this.lastView = page;
this.lastViewTime = CountlyAnalytics.getTimestamp();
const segments = {
...viewData.meta,
name: page,
visit: 1,
domain: window.location.hostname,
view: viewData.url,
segment: this.appPlatform,
start: this.firstPage,
};
if (this.firstPage) {
this.firstPage = false;
}
this.track<IViewEvent>("[CLY]_view", segments);
}
public static getTimestamp() {
return Math.floor(new Date().getTime() / 1000);
}
// store the last ms timestamp returned
// we do this to prevent the ts from ever decreasing in the case of system time changing
private lastMsTs = 0;
private getMsTimestamp() {
const ts = new Date().getTime();
if (this.lastMsTs >= ts) {
// increment ts as to keep our data points well-ordered
this.lastMsTs++;
} else {
this.lastMsTs = ts;
}
return this.lastMsTs;
}
public async recordError(err: Error | string, fatal = false) {
if (this.disabled || this.anonymous) return;
let error = "";
if (typeof err === "object") {
if (typeof err.stack !== "undefined") {
error = err.stack;
} else {
if (typeof err.name !== "undefined") {
error += err.name + ":";
}
if (typeof err.message !== "undefined") {
error += err.message + "\n";
}
if (typeof err.fileName !== "undefined") {
error += "in " + err.fileName + "\n";
}
if (typeof err.lineNumber !== "undefined") {
error += "on " + err.lineNumber;
}
if (typeof err.columnNumber !== "undefined") {
error += ":" + err.columnNumber;
}
}
} else {
error = err + "";
}
// sanitize the error from identifiers
error = await strReplaceAsync(error, /([!@+#]).+?:[\w:.]+/g, async (substring: string, glyph: string) => {
return glyph + await hashHex(substring.substring(1));
});
const metrics = this.getMetrics();
const ob: ICrash = {
_resolution: metrics?._resolution,
_error: error,
_app_version: this.appVersion,
_run: CountlyAnalytics.getTimestamp() - this.initTime,
_nonfatal: !fatal,
_view: this.lastView,
};
if (typeof navigator.onLine !== "undefined") {
ob._online = navigator.onLine;
}
ob._background = document.hasFocus();
this.request({ crash: JSON.stringify(ob) });
}
private trackErrors() {
//override global uncaught error handler
window.onerror = (msg, url, line, col, err) => {
if (typeof err !== "undefined") {
this.recordError(err, false);
} else {
let error = "";
if (typeof msg !== "undefined") {
error += msg + "\n";
}
if (typeof url !== "undefined") {
error += "at " + url;
}
if (typeof line !== "undefined") {
error += ":" + line;
}
if (typeof col !== "undefined") {
error += ":" + col;
}
error += "\n";
try {
const stack = [];
// eslint-disable-next-line no-caller
let f = arguments.callee.caller;
while (f) {
stack.push(f.name);
f = f.caller;
}
error += stack.join("\n");
} catch (ex) {
//silent error
}
this.recordError(error, false);
}
};
window.addEventListener('unhandledrejection', (event) => {
this.recordError(new Error(`Unhandled rejection (reason: ${event.reason?.stack || event.reason}).`), true);
});
}
private heartbeat() {
const args: Pick<IParams, "session_duration"> = {};
// extend session if needed
if (this.sessionStarted && this.trackTime) {
const last = CountlyAnalytics.getTimestamp();
if (last - this.lastBeat >= SESSION_UPDATE_INTERVAL) {
args.session_duration = last - this.lastBeat;
this.lastBeat = last;
}
}
// process event queue
if (this.pendingEvents.length > 0 || args.session_duration) {
this.request(args);
}
}
private async request(
args: Omit<IParams, "app_key" | "device_id" | "timestamp" | "hour" | "dow">
& Partial<Pick<IParams, "device_id">> = {},
) {
const request: IParams = {
app_key: this.appKey,
device_id: this.userKey,
...this.getTimeParams(),
...args,
};
if (this.pendingEvents.length > 0) {
const EVENT_BATCH_SIZE = 10;
const events = this.pendingEvents.splice(0, EVENT_BATCH_SIZE);
request.events = JSON.stringify(events);
}
const params = new URLSearchParams(request as {});
try {
await window.fetch(this.baseUrl.toString(), {
method: "POST",
mode: "no-cors",
cache: "no-cache",
redirect: "follow",
headers: {
"Content-Type": "application/x-www-form-urlencoded",
},
body: params,
});
} catch (e) {
console.error("Analytics error: ", e);
}
}
private getTimeParams(): Pick<IParams, "timestamp" | "hour" | "dow"> {
const date = new Date();
return {
timestamp: this.getMsTimestamp(),
hour: date.getHours(),
dow: date.getDay(),
};
}
private queue(args: Omit<IEvent, "timestamp" | "hour" | "dow" | "count"> & Partial<Pick<IEvent, "count">>) {
const { count = 1, ...rest } = args;
const ev = {
...this.getTimeParams(),
...rest,
count,
platform: this.appPlatform,
app_version: this.appVersion,
};
this.pendingEvents.push(ev);
if (this.pendingEvents.length > MAX_PENDING_EVENTS) {
this.pendingEvents.shift();
}
}
private getOrientation = (): Orientation => {
return window.matchMedia("(orientation: landscape)").matches
? Orientation.Landscape
: Orientation.Portrait;
};
private reportOrientation = () => {
this.track<IOrientationEvent>("[CLY]_orientation", {
mode: this.getOrientation(),
});
};
private startTime() {
if (!this.trackTime) {
this.trackTime = true;
this.lastBeat = CountlyAnalytics.getTimestamp() - this.storedDuration;
this.lastViewTime = CountlyAnalytics.getTimestamp() - this.lastViewStoredDuration;
this.lastViewStoredDuration = 0;
}
}
private stopTime() {
if (this.trackTime) {
this.trackTime = false;
this.storedDuration = CountlyAnalytics.getTimestamp() - this.lastBeat;
this.lastViewStoredDuration = CountlyAnalytics.getTimestamp() - this.lastViewTime;
}
}
private getMetrics(): IMetrics {
if (this.anonymous) return undefined;
const metrics: IMetrics = {};
// getting app version
metrics._app_version = this.appVersion;
metrics._ua = navigator.userAgent;
// getting resolution
if (screen.width && screen.height) {
metrics._resolution = `${screen.width}x${screen.height}`;
}
// getting density ratio
if (window.devicePixelRatio) {
metrics._density = window.devicePixelRatio;
}
// getting locale
metrics._locale = getCurrentLanguage();
return metrics;
}
private async beginSession(heartbeat = true) {
if (!this.sessionStarted) {
this.reportOrientation();
window.addEventListener("resize", this.reportOrientation);
this.lastBeat = CountlyAnalytics.getTimestamp();
this.sessionStarted = true;
this.heartbeatEnabled = heartbeat;
const userDetails: IUserDetails = {
custom: {
"home_server": MatrixClientPeg.get() && MatrixClientPeg.getHomeserverName(), // TODO hash?
"anonymous": this.anonymous,
},
};
const request: Parameters<typeof CountlyAnalytics.prototype.request>[0] = {
begin_session: 1,
user_details: JSON.stringify(userDetails),
};
const metrics = this.getMetrics();
if (metrics) {
request.metrics = JSON.stringify(metrics);
}
await this.request(request);
}
}
private reportViewDuration() {
if (this.lastView) {
this.track<IViewEvent>("[CLY]_view", {
name: this.lastView,
}, null, {
dur: this.trackTime ? CountlyAnalytics.getTimestamp() - this.lastViewTime : this.lastViewStoredDuration,
});
this.lastView = null;
}
}
private endSession = () => {
if (this.sessionStarted) {
window.removeEventListener("resize", this.reportOrientation);
this.reportViewDuration();
this.request({
end_session: 1,
session_duration: CountlyAnalytics.getTimestamp() - this.lastBeat,
});
}
this.sessionStarted = false;
};
private onVisibilityChange = () => {
if (document.hidden) {
this.stopTime();
} else {
this.startTime();
}
};
private onUserActivity = () => {
if (this.inactivityCounter >= INACTIVITY_TIME) {
this.startTime();
}
this.inactivityCounter = 0;
};
private trackSessions() {
this.beginSession();
this.startTime();
window.addEventListener("beforeunload", this.endSession);
window.addEventListener("unload", this.endSession);
window.addEventListener("visibilitychange", this.onVisibilityChange);
window.addEventListener("mousemove", this.onUserActivity);
window.addEventListener("click", this.onUserActivity);
window.addEventListener("keydown", this.onUserActivity);
// Using the passive option to not block the main thread
// https://developer.mozilla.org/en-US/docs/Web/API/EventTarget/addEventListener#improving_scrolling_performance_with_passive_listeners
window.addEventListener("scroll", this.onUserActivity, { passive: true });
this.activityIntervalId = setInterval(() => {
this.inactivityCounter++;
if (this.inactivityCounter >= INACTIVITY_TIME) {
this.stopTime();
}
}, 60_000);
}
public trackBeginInvite(roomId: string) {
this.track<IBeginInviteEvent>("begin_invite", {}, roomId);
}
public trackSendInvite(startTime: number, roomId: string, qty: number) {
this.track<ISendInviteEvent>("send_invite", {}, roomId, {
dur: CountlyAnalytics.getTimestamp() - startTime,
sum: qty,
});
}
public async trackRoomCreate(startTime: number, roomId: string) {
if (this.disabled) return;
let endTime = CountlyAnalytics.getTimestamp();
const cli = MatrixClientPeg.get();
if (!cli.getRoom(roomId)) {
await new Promise<void>(resolve => {
const handler = (room) => {
if (room.roomId === roomId) {
cli.off("Room", handler);
resolve();
}
};
cli.on("Room", handler);
});
endTime = CountlyAnalytics.getTimestamp();
}
this.track<ICreateRoomEvent>("create_room", {}, roomId, {
dur: endTime - startTime,
});
}
public trackRoomJoin(startTime: number, roomId: string, type: IJoinRoomEvent["segmentation"]["type"]) {
this.track<IJoinRoomEvent>(Action.JoinRoom, { type }, roomId, {
dur: CountlyAnalytics.getTimestamp() - startTime,
});
}
public async trackSendMessage(
startTime: number,
// eslint-disable-next-line camelcase
sendPromise: Promise<{event_id: string}>,
roomId: string,
isEdit: boolean,
isReply: boolean,
content: IContent,
) {
if (this.disabled) return;
const cli = MatrixClientPeg.get();
const room = cli.getRoom(roomId);
const eventId = (await sendPromise).event_id;
let endTime = CountlyAnalytics.getTimestamp();
if (!room.findEventById(eventId)) {
await new Promise<void>(resolve => {
const handler = (ev) => {
if (ev.getId() === eventId) {
room.off("Room.localEchoUpdated", handler);
resolve();
}
};
room.on("Room.localEchoUpdated", handler);
});
endTime = CountlyAnalytics.getTimestamp();
}
this.track<ISendMessageEvent>("send_message", {
is_edit: isEdit,
is_reply: isReply,
msgtype: content.msgtype,
format: content.format,
}, roomId, {
dur: endTime - startTime,
});
}
public trackStartCall(roomId: string, isVideo = false, isJitsi = false) {
this.track<IStartCallEvent>("start_call", {
is_video: isVideo,
is_jitsi: isJitsi,
}, roomId);
}
public trackJoinCall(roomId: string, isVideo = false, isJitsi = false) {
this.track<IJoinCallEvent>("join_call", {
is_video: isVideo,
is_jitsi: isJitsi,
}, roomId);
}
public trackRoomDirectoryBegin() {
this.track<IRoomDirectoryEvent>("room_directory");
}
public trackRoomDirectory(startTime: number) {
this.track<IRoomDirectoryDoneEvent>("room_directory_done", {}, null, {
dur: CountlyAnalytics.getTimestamp() - startTime,
});
}
public trackRoomDirectorySearch(numResults: number, query: string) {
this.track<IRoomDirectorySearchEvent>("room_directory_search", {
query_length: query.length,
query_num_words: query.split(" ").length,
}, null, {
sum: numResults,
});
}
public async track<E extends IEvent>(
key: E["key"],
segments?: Omit<E["segmentation"], "room_id" | "num_users" | "is_encrypted" | "is_public">,
roomId?: string,
args?: Partial<Pick<E, "dur" | "sum" | "timestamp">>,
anonymous = false,
) {
if (this.disabled && !anonymous) return;
let segmentation = segments || {};
if (roomId) {
segmentation = {
room_id: await hashHex(roomId),
...getRoomStats(roomId),
...segments,
};
}
this.queue({
key,
count: 1,
segmentation,
...args,
});
// if this event can be sent anonymously and we are disabled then dispatch it right away
if (this.disabled && anonymous) {
await this.request({ device_id: randomString(64) });
}
}
}
// expose on window for easy access from the console
window.mxCountlyAnalytics = CountlyAnalytics;

View file

@ -1,267 +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.
*/
import Modal from './Modal';
import * as sdk from './index';
import {MatrixClientPeg} from './MatrixClientPeg';
import { deriveKey } from 'matrix-js-sdk/src/crypto/key_passphrase';
import { decodeRecoveryKey } from 'matrix-js-sdk/src/crypto/recoverykey';
import { _t } from './languageHandler';
import SettingsStore from './settings/SettingsStore';
import {encodeBase64} from "matrix-js-sdk/src/crypto/olmlib";
// This stores the secret storage private keys in memory for the JS SDK. This is
// only meant to act as a cache to avoid prompting the user multiple times
// during the same single operation. Use `accessSecretStorage` below to scope a
// single secret storage operation, as it will clear the cached keys once the
// operation ends.
let secretStorageKeys = {};
let secretStorageBeingAccessed = false;
function isCachingAllowed() {
return (
secretStorageBeingAccessed ||
SettingsStore.getValue("keepSecretStoragePassphraseForSession")
);
}
export class AccessCancelledError extends Error {
constructor() {
super("Secret storage access canceled");
}
}
async function confirmToDismiss(name) {
let description;
if (name === "m.cross_signing.user_signing") {
description = _t("If you cancel now, you won't complete verifying the other user.");
} else if (name === "m.cross_signing.self_signing") {
description = _t("If you cancel now, you won't complete verifying your other session.");
} else {
description = _t("If you cancel now, you won't complete your secret storage operation.");
}
const QuestionDialog = sdk.getComponent("dialogs.QuestionDialog");
const [sure] = await Modal.createDialog(QuestionDialog, {
title: _t("Cancel entering passphrase?"),
description,
danger: true,
cancelButton: _t("Enter passphrase"),
button: _t("Cancel"),
}).finished;
return sure;
}
async function getSecretStorageKey({ keys: keyInfos }, ssssItemName) {
const keyInfoEntries = Object.entries(keyInfos);
if (keyInfoEntries.length > 1) {
throw new Error("Multiple storage key requests not implemented");
}
const [name, info] = keyInfoEntries[0];
// Check the in-memory cache
if (isCachingAllowed() && secretStorageKeys[name]) {
return [name, secretStorageKeys[name]];
}
const inputToKey = async ({ passphrase, recoveryKey }) => {
if (passphrase) {
return deriveKey(
passphrase,
info.passphrase.salt,
info.passphrase.iterations,
);
} else {
return decodeRecoveryKey(recoveryKey);
}
};
const AccessSecretStorageDialog =
sdk.getComponent("dialogs.secretstorage.AccessSecretStorageDialog");
const { finished } = Modal.createTrackedDialog("Access Secret Storage dialog", "",
AccessSecretStorageDialog,
/* props= */
{
keyInfo: info,
checkPrivateKey: async (input) => {
const key = await inputToKey(input);
return await MatrixClientPeg.get().checkSecretStorageKey(key, info);
},
},
/* className= */ null,
/* isPriorityModal= */ false,
/* isStaticModal= */ false,
/* options= */ {
onBeforeClose: async (reason) => {
if (reason === "backgroundClick") {
return confirmToDismiss(ssssItemName);
}
return true;
},
},
);
const [input] = await finished;
if (!input) {
throw new AccessCancelledError();
}
const key = await inputToKey(input);
// Save to cache to avoid future prompts in the current session
if (isCachingAllowed()) {
secretStorageKeys[name] = key;
}
return [name, key];
}
const onSecretRequested = async function({
user_id: userId,
device_id: deviceId,
request_id: requestId,
name,
device_trust: deviceTrust,
}) {
console.log("onSecretRequested", userId, deviceId, requestId, name, deviceTrust);
const client = MatrixClientPeg.get();
if (userId !== client.getUserId()) {
return;
}
if (!deviceTrust || !deviceTrust.isVerified()) {
console.log(`CrossSigningManager: Ignoring request from untrusted device ${deviceId}`);
return;
}
if (name.startsWith("m.cross_signing")) {
const callbacks = client.getCrossSigningCacheCallbacks();
if (!callbacks.getCrossSigningKeyCache) return;
/* Explicit enumeration here is deliberate never share the master key! */
if (name === "m.cross_signing.self_signing") {
const key = await callbacks.getCrossSigningKeyCache("self_signing");
if (!key) {
console.log(
`self_signing requested by ${deviceId}, but not found in cache`,
);
}
return key && encodeBase64(key);
} else if (name === "m.cross_signing.user_signing") {
const key = await callbacks.getCrossSigningKeyCache("user_signing");
if (!key) {
console.log(
`user_signing requested by ${deviceId}, but not found in cache`,
);
}
return key && encodeBase64(key);
}
} else if (name === "m.megolm_backup.v1") {
const key = await client._crypto.getSessionBackupPrivateKey();
if (!key) {
console.log(
`session backup key requested by ${deviceId}, but not found in cache`,
);
}
return key && encodeBase64(key);
}
console.warn("onSecretRequested didn't recognise the secret named ", name);
};
export const crossSigningCallbacks = {
getSecretStorageKey,
onSecretRequested,
};
export async function promptForBackupPassphrase() {
let key;
const RestoreKeyBackupDialog = sdk.getComponent('dialogs.keybackup.RestoreKeyBackupDialog');
const { finished } = Modal.createTrackedDialog('Restore Backup', '', RestoreKeyBackupDialog, {
showSummary: false, keyCallback: k => key = k,
}, null, /* priority = */ false, /* static = */ true);
const success = await finished;
if (!success) throw new Error("Key backup prompt cancelled");
return key;
}
/**
* This helper should be used whenever you need to access secret storage. It
* ensures that secret storage (and also cross-signing since they each depend on
* each other in a cycle of sorts) have been bootstrapped before running the
* provided function.
*
* Bootstrapping secret storage may take one of these paths:
* 1. Create secret storage from a passphrase and store cross-signing keys
* in secret storage.
* 2. Access existing secret storage by requesting passphrase and accessing
* cross-signing keys as needed.
* 3. All keys are loaded and there's nothing to do.
*
* Additionally, the secret storage keys are cached during the scope of this function
* to ensure the user is prompted only once for their secret storage
* passphrase. The cache is then cleared once the provided function completes.
*
* @param {Function} [func] An operation to perform once secret storage has been
* bootstrapped. Optional.
* @param {bool} [forceReset] Reset secret storage even if it's already set up
*/
export async function accessSecretStorage(func = async () => { }, forceReset = false) {
const cli = MatrixClientPeg.get();
secretStorageBeingAccessed = true;
try {
if (!await cli.hasSecretStorageKey() || forceReset) {
// This dialog calls bootstrap itself after guiding the user through
// passphrase creation.
const { finished } = Modal.createTrackedDialogAsync('Create Secret Storage dialog', '',
import("./async-components/views/dialogs/secretstorage/CreateSecretStorageDialog"),
{
force: forceReset,
},
null, /* priority = */ false, /* static = */ true,
);
const [confirmed] = await finished;
if (!confirmed) {
throw new Error("Secret storage creation canceled");
}
} else {
const InteractiveAuthDialog = sdk.getComponent("dialogs.InteractiveAuthDialog");
await cli.bootstrapSecretStorage({
authUploadDeviceSigningKeys: async (makeRequest) => {
const { finished } = Modal.createTrackedDialog(
'Cross-signing keys dialog', '', InteractiveAuthDialog,
{
title: _t("Setting up keys"),
matrixClient: MatrixClientPeg.get(),
makeRequest,
},
);
const [confirmed] = await finished;
if (!confirmed) {
throw new Error("Cross-signing key upload auth canceled");
}
},
getBackupPassphrase: promptForBackupPassphrase,
});
}
// `return await` needed here to ensure `finally` block runs after the
// inner operation completes.
return await func();
} finally {
// Clear secret storage key cache now that work is complete
secretStorageBeingAccessed = false;
if (!isCachingAllowed()) {
secretStorageKeys = {};
}
}
}

View file

@ -17,7 +17,7 @@ limitations under the License.
import { _t } from './languageHandler';
function getDaysArray() {
function getDaysArray(): string[] {
return [
_t('Sun'),
_t('Mon'),
@ -29,7 +29,7 @@ function getDaysArray() {
];
}
function getMonthsArray() {
function getMonthsArray(): string[] {
return [
_t('Jan'),
_t('Feb'),
@ -46,11 +46,11 @@ function getMonthsArray() {
];
}
function pad(n) {
function pad(n: number): string {
return (n < 10 ? '0' : '') + n;
}
function twelveHourTime(date, showSeconds=false) {
function twelveHourTime(date: Date, showSeconds = false): string {
let hours = date.getHours() % 12;
const minutes = pad(date.getMinutes());
const ampm = date.getHours() >= 12 ? _t('PM') : _t('AM');
@ -62,7 +62,7 @@ function twelveHourTime(date, showSeconds=false) {
return `${hours}:${minutes}${ampm}`;
}
export function formatDate(date, showTwelveHour=false) {
export function formatDate(date: Date, showTwelveHour = false): string {
const now = new Date();
const days = getDaysArray();
const months = getMonthsArray();
@ -86,7 +86,7 @@ export function formatDate(date, showTwelveHour=false) {
return formatFullDate(date, showTwelveHour);
}
export function formatFullDateNoTime(date) {
export function formatFullDateNoTime(date: Date): string {
const days = getDaysArray();
const months = getMonthsArray();
return _t('%(weekDayName)s, %(monthName)s %(day)s %(fullYear)s', {
@ -97,7 +97,7 @@ export function formatFullDateNoTime(date) {
});
}
export function formatFullDate(date, showTwelveHour=false) {
export function formatFullDate(date: Date, showTwelveHour = false, showSeconds = true): string {
const days = getDaysArray();
const months = getMonthsArray();
return _t('%(weekDayName)s, %(monthName)s %(day)s %(fullYear)s %(time)s', {
@ -105,18 +105,18 @@ export function formatFullDate(date, showTwelveHour=false) {
monthName: months[date.getMonth()],
day: date.getDate(),
fullYear: date.getFullYear(),
time: formatFullTime(date, showTwelveHour),
time: showSeconds ? formatFullTime(date, showTwelveHour) : formatTime(date, showTwelveHour),
});
}
export function formatFullTime(date, showTwelveHour=false) {
export function formatFullTime(date: Date, showTwelveHour = false): string {
if (showTwelveHour) {
return twelveHourTime(date, true);
}
return pad(date.getHours()) + ':' + pad(date.getMinutes()) + ':' + pad(date.getSeconds());
}
export function formatTime(date, showTwelveHour=false) {
export function formatTime(date: Date, showTwelveHour = false): string {
if (showTwelveHour) {
return twelveHourTime(date);
}
@ -124,7 +124,7 @@ export function formatTime(date, showTwelveHour=false) {
}
const MILLIS_IN_DAY = 86400000;
export function wantsDateSeparator(prevEventDate, nextEventDate) {
export function wantsDateSeparator(prevEventDate: Date, nextEventDate: Date): boolean {
if (!nextEventDate || !prevEventDate) {
return false;
}

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,34 +14,40 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import { MatrixError } from "matrix-js-sdk/src/http-api";
import { MatrixEvent } from "matrix-js-sdk/src/models/event";
export class DecryptionFailure {
constructor(failedEventId, errorCode) {
this.failedEventId = failedEventId;
this.errorCode = errorCode;
public readonly ts: number;
constructor(public readonly failedEventId: string, public readonly errorCode: string) {
this.ts = Date.now();
}
}
type TrackingFn = (count: number, trackedErrCode: string) => void;
type ErrCodeMapFn = (errcode: string) => string;
export class DecryptionFailureTracker {
// Array of items of type DecryptionFailure. Every `CHECK_INTERVAL_MS`, this list
// is checked for failures that happened > `GRACE_PERIOD_MS` ago. Those that did
// are accumulated in `failureCounts`.
failures = [];
public failures: DecryptionFailure[] = [];
// A histogram of the number of failures that will be tracked at the next tracking
// interval, split by failure error code.
failureCounts = {
public failureCounts: Record<string, number> = {
// [errorCode]: 42
};
// Event IDs of failures that were tracked previously
trackedEventHashMap = {
public trackedEventHashMap: Record<string, boolean> = {
// [eventId]: true
};
// Set to an interval ID when `start` is called
checkInterval = null;
trackInterval = null;
public checkInterval: number = null;
public trackInterval: number = null;
// Spread the load on `Analytics` by tracking at a low frequency, `TRACK_INTERVAL_MS`.
static TRACK_INTERVAL_MS = 60000;
@ -67,7 +73,7 @@ export class DecryptionFailureTracker {
* @param {function?} errorCodeMapFn The function used to map error codes to the
* trackedErrorCode. If not provided, the `.code` of errors will be used.
*/
constructor(fn, errorCodeMapFn) {
constructor(private readonly fn: TrackingFn, private readonly errorCodeMapFn?: ErrCodeMapFn) {
if (!fn || typeof fn !== 'function') {
throw new Error('DecryptionFailureTracker requires tracking function');
}
@ -75,9 +81,6 @@ export class DecryptionFailureTracker {
if (errorCodeMapFn && typeof errorCodeMapFn !== 'function') {
throw new Error('DecryptionFailureTracker second constructor argument should be a function');
}
this._trackDecryptionFailure = fn;
this._mapErrorCode = errorCodeMapFn;
}
// loadTrackedEventHashMap() {
@ -88,7 +91,7 @@ export class DecryptionFailureTracker {
// localStorage.setItem('mx-decryption-failure-event-id-hashes', JSON.stringify(this.trackedEventHashMap));
// }
eventDecrypted(e, err) {
public eventDecrypted(e: MatrixEvent, err: MatrixError | Error): void {
if (err) {
this.addDecryptionFailure(new DecryptionFailure(e.getId(), err.code));
} else {
@ -97,18 +100,18 @@ export class DecryptionFailureTracker {
}
}
addDecryptionFailure(failure) {
public addDecryptionFailure(failure: DecryptionFailure): void {
this.failures.push(failure);
}
removeDecryptionFailuresForEvent(e) {
public removeDecryptionFailuresForEvent(e: MatrixEvent): void {
this.failures = this.failures.filter((f) => f.failedEventId !== e.getId());
}
/**
* Start checking for and tracking failures.
*/
start() {
public start(): void {
this.checkInterval = setInterval(
() => this.checkFailures(Date.now()),
DecryptionFailureTracker.CHECK_INTERVAL_MS,
@ -123,7 +126,7 @@ export class DecryptionFailureTracker {
/**
* Clear state and stop checking for and tracking failures.
*/
stop() {
public stop(): void {
clearInterval(this.checkInterval);
clearInterval(this.trackInterval);
@ -132,11 +135,11 @@ export class DecryptionFailureTracker {
}
/**
* Mark failures that occured before nowTs - GRACE_PERIOD_MS as failures that should be
* Mark failures that occurred before nowTs - GRACE_PERIOD_MS as failures that should be
* tracked. Only mark one failure per event ID.
* @param {number} nowTs the timestamp that represents the time now.
*/
checkFailures(nowTs) {
public checkFailures(nowTs: number): void {
const failuresGivenGrace = [];
const failuresNotReady = [];
while (this.failures.length > 0) {
@ -165,7 +168,7 @@ export class DecryptionFailureTracker {
const trackedEventIds = [...dedupedFailuresMap.keys()];
this.trackedEventHashMap = trackedEventIds.reduce(
(result, eventId) => ({...result, [eventId]: true}),
(result, eventId) => ({ ...result, [eventId]: true }),
this.trackedEventHashMap,
);
@ -175,10 +178,10 @@ export class DecryptionFailureTracker {
const dedupedFailures = dedupedFailuresMap.values();
this._aggregateFailures(dedupedFailures);
this.aggregateFailures(dedupedFailures);
}
_aggregateFailures(failures) {
private aggregateFailures(failures: DecryptionFailure[]): void {
for (const failure of failures) {
const errorCode = failure.errorCode;
this.failureCounts[errorCode] = (this.failureCounts[errorCode] || 0) + 1;
@ -189,12 +192,12 @@ export class DecryptionFailureTracker {
* If there are failures that should be tracked, call the given trackDecryptionFailure
* function with the number of failures that should be tracked.
*/
trackFailures() {
public trackFailures(): void {
for (const errorCode of Object.keys(this.failureCounts)) {
if (this.failureCounts[errorCode] > 0) {
const trackedErrorCode = this._mapErrorCode ? this._mapErrorCode(errorCode) : errorCode;
const trackedErrorCode = this.errorCodeMapFn ? this.errorCodeMapFn(errorCode) : errorCode;
this._trackDecryptionFailure(this.failureCounts[errorCode], trackedErrorCode);
this.fn(this.failureCounts[errorCode], trackedErrorCode);
this.failureCounts[errorCode] = 0;
}
}

View file

@ -1,219 +0,0 @@
/*
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 SettingsStore from './settings/SettingsStore';
import * as sdk from './index';
import { _t } from './languageHandler';
import ToastStore from './stores/ToastStore';
function toastKey(deviceId) {
return 'unverified_session_' + deviceId;
}
const KEY_BACKUP_POLL_INTERVAL = 5 * 60 * 1000;
const THIS_DEVICE_TOAST_KEY = 'setupencryption';
export default class DeviceListener {
static sharedInstance() {
if (!global.mx_DeviceListener) global.mx_DeviceListener = new DeviceListener();
return global.mx_DeviceListener;
}
constructor() {
// set of device IDs we're currently showing toasts for
this._activeNagToasts = new Set();
// device IDs for which the user has dismissed the verify toast ('Later')
this._dismissed = new Set();
// has the user dismissed any of the various nag toasts to setup encryption on this device?
this._dismissedThisDeviceToast = false;
// cache of the key backup info
this._keyBackupInfo = null;
this._keyBackupFetchedAt = null;
}
start() {
MatrixClientPeg.get().on('crypto.devicesUpdated', this._onDevicesUpdated);
MatrixClientPeg.get().on('deviceVerificationChanged', this._onDeviceVerificationChanged);
MatrixClientPeg.get().on('userTrustStatusChanged', this._onUserTrustStatusChanged);
MatrixClientPeg.get().on('crossSigning.keysChanged', this._onCrossSingingKeysChanged);
MatrixClientPeg.get().on('accountData', this._onAccountData);
this._recheck();
}
stop() {
if (MatrixClientPeg.get()) {
MatrixClientPeg.get().removeListener('crypto.devicesUpdated', this._onDevicesUpdated);
MatrixClientPeg.get().removeListener('deviceVerificationChanged', this._onDeviceVerificationChanged);
MatrixClientPeg.get().removeListener('userTrustStatusChanged', this._onUserTrustStatusChanged);
MatrixClientPeg.get().removeListener('crossSigning.keysChanged', this._onCrossSingingKeysChanged);
MatrixClientPeg.get().removeListener('accountData', this._onAccountData);
}
this._dismissed.clear();
}
dismissVerification(deviceId) {
this._dismissed.add(deviceId);
this._recheck();
}
dismissEncryptionSetup() {
this._dismissedThisDeviceToast = true;
this._recheck();
}
_onDevicesUpdated = (users) => {
if (!users.includes(MatrixClientPeg.get().getUserId())) return;
this._recheck();
}
_onDeviceVerificationChanged = (userId) => {
if (userId !== MatrixClientPeg.get().getUserId()) return;
this._recheck();
}
_onUserTrustStatusChanged = (userId, trustLevel) => {
if (userId !== MatrixClientPeg.get().getUserId()) return;
this._recheck();
}
_onCrossSingingKeysChanged = () => {
this._recheck();
}
_onAccountData = (ev) => {
// User may have:
// * migrated SSSS to symmetric
// * uploaded keys to secret storage
// * completed secret storage creation
// which result in account data changes affecting checks below.
if (
ev.getType().startsWith('m.secret_storage.') ||
ev.getType().startsWith('m.cross_signing.')
) {
this._recheck();
}
}
// The server doesn't tell us when key backup is set up, so we poll
// & cache the result
async _getKeyBackupInfo() {
const now = (new Date()).getTime();
if (!this._keyBackupInfo || this._keyBackupFetchedAt < now - KEY_BACKUP_POLL_INTERVAL) {
this._keyBackupInfo = await MatrixClientPeg.get().getKeyBackupVersion();
this._keyBackupFetchedAt = now;
}
return this._keyBackupInfo;
}
async _recheck() {
const cli = MatrixClientPeg.get();
if (
!SettingsStore.isFeatureEnabled("feature_cross_signing") ||
!await cli.doesServerSupportUnstableFeature("org.matrix.e2e_cross_signing")
) return;
if (!cli.isCryptoEnabled()) return;
const crossSigningReady = await cli.isCrossSigningReady();
if (this._dismissedThisDeviceToast) {
ToastStore.sharedInstance().dismissToast(THIS_DEVICE_TOAST_KEY);
} else {
if (!crossSigningReady) {
// cross signing isn't enabled - nag to enable it
// There are 3 different toasts for:
if (cli.getStoredCrossSigningForUser(cli.getUserId())) {
// Cross-signing on account but this device doesn't trust the master key (verify this session)
ToastStore.sharedInstance().addOrReplaceToast({
key: THIS_DEVICE_TOAST_KEY,
title: _t("Verify this session"),
icon: "verification_warning",
props: {kind: 'verify_this_session'},
component: sdk.getComponent("toasts.SetupEncryptionToast"),
});
} else {
const backupInfo = await this._getKeyBackupInfo();
if (backupInfo) {
// No cross-signing on account but key backup available (upgrade encryption)
ToastStore.sharedInstance().addOrReplaceToast({
key: THIS_DEVICE_TOAST_KEY,
title: _t("Encryption upgrade available"),
icon: "verification_warning",
props: {kind: 'upgrade_encryption'},
component: sdk.getComponent("toasts.SetupEncryptionToast"),
});
} else {
// No cross-signing or key backup on account (set up encryption)
ToastStore.sharedInstance().addOrReplaceToast({
key: THIS_DEVICE_TOAST_KEY,
title: _t("Set up encryption"),
icon: "verification_warning",
props: {kind: 'set_up_encryption'},
component: sdk.getComponent("toasts.SetupEncryptionToast"),
});
}
}
return;
} else if (await cli.secretStorageKeyNeedsUpgrade()) {
ToastStore.sharedInstance().addOrReplaceToast({
key: THIS_DEVICE_TOAST_KEY,
title: _t("Encryption upgrade available"),
icon: "verification_warning",
props: {kind: 'upgrade_ssss'},
component: sdk.getComponent("toasts.SetupEncryptionToast"),
});
} else {
// cross-signing is ready, and we don't need to upgrade encryption
ToastStore.sharedInstance().dismissToast(THIS_DEVICE_TOAST_KEY);
}
}
// as long as cross-signing isn't ready,
// you can't see or dismiss any device toasts
if (crossSigningReady) {
const newActiveToasts = new Set();
const devices = await cli.getStoredDevicesForUser(cli.getUserId());
for (const device of devices) {
if (device.deviceId == cli.deviceId) continue;
const deviceTrust = await cli.checkDeviceTrust(cli.getUserId(), device.deviceId);
if (deviceTrust.isCrossSigningVerified() || this._dismissed.has(device.deviceId)) {
ToastStore.sharedInstance().dismissToast(toastKey(device.deviceId));
} else {
this._activeNagToasts.add(device.deviceId);
ToastStore.sharedInstance().addOrReplaceToast({
key: toastKey(device.deviceId),
title: _t("Unverified login. Was this you?"),
icon: "verification_warning",
props: { device },
component: sdk.getComponent("toasts.UnverifiedSessionToast"),
});
newActiveToasts.add(device.deviceId);
}
}
// clear any other outstanding toasts (eg. logged out devices)
for (const deviceId of this._activeNagToasts) {
if (!newActiveToasts.has(deviceId)) ToastStore.sharedInstance().dismissToast(toastKey(deviceId));
}
this._activeNagToasts = newActiveToasts;
}
}
}

308
src/DeviceListener.ts Normal file
View file

@ -0,0 +1,308 @@
/*
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 dis from "./dispatcher/dispatcher";
import {
hideToast as hideBulkUnverifiedSessionsToast,
showToast as showBulkUnverifiedSessionsToast,
} from "./toasts/BulkUnverifiedSessionsToast";
import {
hideToast as hideSetupEncryptionToast,
Kind as SetupKind,
showToast as showSetupEncryptionToast,
} from "./toasts/SetupEncryptionToast";
import {
hideToast as hideUnverifiedSessionsToast,
showToast as showUnverifiedSessionsToast,
} from "./toasts/UnverifiedSessionToast";
import { isSecretStorageBeingAccessed, accessSecretStorage } from "./SecurityManager";
import { isSecureBackupRequired } from './utils/WellKnownUtils';
import { isLoggedIn } from './components/structures/MatrixChat';
import { MatrixEvent } from "matrix-js-sdk/src/models/event";
const KEY_BACKUP_POLL_INTERVAL = 5 * 60 * 1000;
export default class DeviceListener {
private dispatcherRef: string;
// device IDs for which the user has dismissed the verify toast ('Later')
private dismissed = new Set<string>();
// has the user dismissed any of the various nag toasts to setup encryption on this device?
private dismissedThisDeviceToast = false;
// cache of the key backup info
private keyBackupInfo: object = null;
private keyBackupFetchedAt: number = null;
// We keep a list of our own device IDs so we can batch ones that were already
// there the last time the app launched into a single toast, but display new
// ones in their own toasts.
private ourDeviceIdsAtStart: Set<string> = null;
// The set of device IDs we're currently displaying toasts for
private displayingToastsForDeviceIds = new Set<string>();
static sharedInstance() {
if (!window.mxDeviceListener) window.mxDeviceListener = new DeviceListener();
return window.mxDeviceListener;
}
start() {
MatrixClientPeg.get().on('crypto.willUpdateDevices', this._onWillUpdateDevices);
MatrixClientPeg.get().on('crypto.devicesUpdated', this._onDevicesUpdated);
MatrixClientPeg.get().on('deviceVerificationChanged', this._onDeviceVerificationChanged);
MatrixClientPeg.get().on('userTrustStatusChanged', this._onUserTrustStatusChanged);
MatrixClientPeg.get().on('crossSigning.keysChanged', this._onCrossSingingKeysChanged);
MatrixClientPeg.get().on('accountData', this._onAccountData);
MatrixClientPeg.get().on('sync', this._onSync);
MatrixClientPeg.get().on('RoomState.events', this._onRoomStateEvents);
this.dispatcherRef = dis.register(this._onAction);
this._recheck();
}
stop() {
if (MatrixClientPeg.get()) {
MatrixClientPeg.get().removeListener('crypto.willUpdateDevices', this._onWillUpdateDevices);
MatrixClientPeg.get().removeListener('crypto.devicesUpdated', this._onDevicesUpdated);
MatrixClientPeg.get().removeListener('deviceVerificationChanged', this._onDeviceVerificationChanged);
MatrixClientPeg.get().removeListener('userTrustStatusChanged', this._onUserTrustStatusChanged);
MatrixClientPeg.get().removeListener('crossSigning.keysChanged', this._onCrossSingingKeysChanged);
MatrixClientPeg.get().removeListener('accountData', this._onAccountData);
MatrixClientPeg.get().removeListener('sync', this._onSync);
MatrixClientPeg.get().removeListener('RoomState.events', this._onRoomStateEvents);
}
if (this.dispatcherRef) {
dis.unregister(this.dispatcherRef);
this.dispatcherRef = null;
}
this.dismissed.clear();
this.dismissedThisDeviceToast = false;
this.keyBackupInfo = null;
this.keyBackupFetchedAt = null;
this.ourDeviceIdsAtStart = null;
this.displayingToastsForDeviceIds = new Set();
}
/**
* Dismiss notifications about our own unverified devices
*
* @param {String[]} deviceIds List of device IDs to dismiss notifications for
*/
async dismissUnverifiedSessions(deviceIds: Iterable<string>) {
for (const d of deviceIds) {
this.dismissed.add(d);
}
this._recheck();
}
dismissEncryptionSetup() {
this.dismissedThisDeviceToast = true;
this._recheck();
}
_ensureDeviceIdsAtStartPopulated() {
if (this.ourDeviceIdsAtStart === null) {
const cli = MatrixClientPeg.get();
this.ourDeviceIdsAtStart = new Set(
cli.getStoredDevicesForUser(cli.getUserId()).map(d => d.deviceId),
);
}
}
_onWillUpdateDevices = async (users: string[], initialFetch?: boolean) => {
// If we didn't know about *any* devices before (ie. it's fresh login),
// then they are all pre-existing devices, so ignore this and set the
// devicesAtStart list to the devices that we see after the fetch.
if (initialFetch) return;
const myUserId = MatrixClientPeg.get().getUserId();
if (users.includes(myUserId)) this._ensureDeviceIdsAtStartPopulated();
// No need to do a recheck here: we just need to get a snapshot of our devices
// before we download any new ones.
};
_onDevicesUpdated = (users: string[]) => {
if (!users.includes(MatrixClientPeg.get().getUserId())) return;
this._recheck();
};
_onDeviceVerificationChanged = (userId: string) => {
if (userId !== MatrixClientPeg.get().getUserId()) return;
this._recheck();
};
_onUserTrustStatusChanged = (userId: string) => {
if (userId !== MatrixClientPeg.get().getUserId()) return;
this._recheck();
};
_onCrossSingingKeysChanged = () => {
this._recheck();
};
_onAccountData = (ev) => {
// User may have:
// * migrated SSSS to symmetric
// * uploaded keys to secret storage
// * completed secret storage creation
// which result in account data changes affecting checks below.
if (
ev.getType().startsWith('m.secret_storage.') ||
ev.getType().startsWith('m.cross_signing.') ||
ev.getType() === 'm.megolm_backup.v1'
) {
this._recheck();
}
};
_onSync = (state, prevState) => {
if (state === 'PREPARED' && prevState === null) this._recheck();
};
_onRoomStateEvents = (ev: MatrixEvent) => {
if (ev.getType() !== "m.room.encryption") {
return;
}
// If a room changes to encrypted, re-check as it may be our first
// encrypted room. This also catches encrypted room creation as well.
this._recheck();
};
_onAction = ({ action }) => {
if (action !== "on_logged_in") return;
this._recheck();
};
// The server doesn't tell us when key backup is set up, so we poll
// & cache the result
async _getKeyBackupInfo() {
const now = (new Date()).getTime();
if (!this.keyBackupInfo || this.keyBackupFetchedAt < now - KEY_BACKUP_POLL_INTERVAL) {
this.keyBackupInfo = await MatrixClientPeg.get().getKeyBackupVersion();
this.keyBackupFetchedAt = now;
}
return this.keyBackupInfo;
}
private shouldShowSetupEncryptionToast() {
// If we're in the middle of a secret storage operation, we're likely
// modifying the state involved here, so don't add new toasts to setup.
if (isSecretStorageBeingAccessed()) return false;
// Show setup toasts once the user is in at least one encrypted room.
const cli = MatrixClientPeg.get();
return cli && cli.getRooms().some(r => cli.isRoomEncrypted(r.roomId));
}
async _recheck() {
const cli = MatrixClientPeg.get();
if (!await cli.doesServerSupportUnstableFeature("org.matrix.e2e_cross_signing")) return;
if (!cli.isCryptoEnabled()) return;
// don't recheck until the initial sync is complete: lots of account data events will fire
// while the initial sync is processing and we don't need to recheck on each one of them
// (we add a listener on sync to do once check after the initial sync is done)
if (!cli.isInitialSyncComplete()) return;
const crossSigningReady = await cli.isCrossSigningReady();
const secretStorageReady = await cli.isSecretStorageReady();
const allSystemsReady = crossSigningReady && secretStorageReady;
if (this.dismissedThisDeviceToast || allSystemsReady) {
hideSetupEncryptionToast();
} else if (this.shouldShowSetupEncryptionToast()) {
// make sure our keys are finished downloading
await cli.downloadKeys([cli.getUserId()]);
// cross signing isn't enabled - nag to enable it
// There are 3 different toasts for:
if (
!cli.getCrossSigningId() &&
cli.getStoredCrossSigningForUser(cli.getUserId())
) {
// Cross-signing on account but this device doesn't trust the master key (verify this session)
showSetupEncryptionToast(SetupKind.VERIFY_THIS_SESSION);
} else {
const backupInfo = await this._getKeyBackupInfo();
if (backupInfo) {
// No cross-signing on account but key backup available (upgrade encryption)
showSetupEncryptionToast(SetupKind.UPGRADE_ENCRYPTION);
} else {
// No cross-signing or key backup on account (set up encryption)
await cli.waitForClientWellKnown();
if (isSecureBackupRequired() && isLoggedIn()) {
// If we're meant to set up, and Secure Backup is required,
// trigger the flow directly without a toast once logged in.
hideSetupEncryptionToast();
accessSecretStorage();
} else {
showSetupEncryptionToast(SetupKind.SET_UP_ENCRYPTION);
}
}
}
}
// This needs to be done after awaiting on downloadKeys() above, so
// we make sure we get the devices after the fetch is done.
this._ensureDeviceIdsAtStartPopulated();
// Unverified devices that were there last time the app ran
// (technically could just be a boolean: we don't actually
// need to remember the device IDs, but for the sake of
// symmetry...).
const oldUnverifiedDeviceIds = new Set<string>();
// Unverified devices that have appeared since then
const newUnverifiedDeviceIds = new Set<string>();
// as long as cross-signing isn't ready,
// you can't see or dismiss any device toasts
if (crossSigningReady) {
const devices = cli.getStoredDevicesForUser(cli.getUserId());
for (const device of devices) {
if (device.deviceId === cli.deviceId) continue;
const deviceTrust = await cli.checkDeviceTrust(cli.getUserId(), device.deviceId);
if (!deviceTrust.isCrossSigningVerified() && !this.dismissed.has(device.deviceId)) {
if (this.ourDeviceIdsAtStart.has(device.deviceId)) {
oldUnverifiedDeviceIds.add(device.deviceId);
} else {
newUnverifiedDeviceIds.add(device.deviceId);
}
}
}
}
// Display or hide the batch toast for old unverified sessions
if (oldUnverifiedDeviceIds.size > 0) {
showBulkUnverifiedSessionsToast(oldUnverifiedDeviceIds);
} else {
hideBulkUnverifiedSessionsToast();
}
// Show toasts for new unverified devices if they aren't already there
for (const deviceId of newUnverifiedDeviceIds) {
showUnverifiedSessionsToast(deviceId);
}
// ...and hide any we don't need any more
for (const deviceId of this.displayingToastsForDeviceIds) {
if (!newUnverifiedDeviceIds.has(deviceId)) {
hideUnverifiedSessionsToast(deviceId);
}
}
this.displayingToastsForDeviceIds = newUnverifiedDeviceIds;
}
}

View file

@ -1,274 +0,0 @@
/*
Copyright 2018 New Vector Ltd
Copyright 2019 Travis Ralston
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.
*/
import URL from 'url';
import dis from './dispatcher';
import WidgetMessagingEndpoint from './WidgetMessagingEndpoint';
import ActiveWidgetStore from './stores/ActiveWidgetStore';
import {MatrixClientPeg} from "./MatrixClientPeg";
import RoomViewStore from "./stores/RoomViewStore";
import {IntegrationManagers} from "./integrations/IntegrationManagers";
import SettingsStore from "./settings/SettingsStore";
import {Capability} from "./widgets/WidgetApi";
const WIDGET_API_VERSION = '0.0.2'; // Current API version
const SUPPORTED_WIDGET_API_VERSIONS = [
'0.0.1',
'0.0.2',
];
const INBOUND_API_NAME = 'fromWidget';
// Listen for and handle incoming requests using the 'fromWidget' postMessage
// API and initiate responses
export default class FromWidgetPostMessageApi {
constructor() {
this.widgetMessagingEndpoints = [];
this.widgetListeners = {}; // {action: func[]}
this.start = this.start.bind(this);
this.stop = this.stop.bind(this);
this.onPostMessage = this.onPostMessage.bind(this);
}
start() {
window.addEventListener('message', this.onPostMessage);
}
stop() {
window.removeEventListener('message', this.onPostMessage);
}
/**
* Adds a listener for a given action
* @param {string} action The action to listen for.
* @param {Function} callbackFn A callback function to be called when the action is
* encountered. Called with two parameters: the interesting request information and
* the raw event received from the postMessage API. The raw event is meant to be used
* for sendResponse and similar functions.
*/
addListener(action, callbackFn) {
if (!this.widgetListeners[action]) this.widgetListeners[action] = [];
this.widgetListeners[action].push(callbackFn);
}
/**
* Removes a listener for a given action.
* @param {string} action The action that was subscribed to.
* @param {Function} callbackFn The original callback function that was used to subscribe
* to updates.
*/
removeListener(action, callbackFn) {
if (!this.widgetListeners[action]) return;
const idx = this.widgetListeners[action].indexOf(callbackFn);
if (idx !== -1) this.widgetListeners[action].splice(idx, 1);
}
/**
* Register a widget endpoint for trusted postMessage communication
* @param {string} widgetId Unique widget identifier
* @param {string} endpointUrl Widget wurl origin (protocol + (optional port) + host)
*/
addEndpoint(widgetId, endpointUrl) {
const u = URL.parse(endpointUrl);
if (!u || !u.protocol || !u.host) {
console.warn('Add FromWidgetPostMessageApi endpoint - Invalid origin:', endpointUrl);
return;
}
const origin = u.protocol + '//' + u.host;
const endpoint = new WidgetMessagingEndpoint(widgetId, origin);
if (this.widgetMessagingEndpoints.some(function(ep) {
return (ep.widgetId === widgetId && ep.endpointUrl === endpointUrl);
})) {
// Message endpoint already registered
console.warn('Add FromWidgetPostMessageApi - Endpoint already registered');
return;
} else {
console.log(`Adding fromWidget messaging endpoint for ${widgetId}`, endpoint);
this.widgetMessagingEndpoints.push(endpoint);
}
}
/**
* De-register a widget endpoint from trusted communication sources
* @param {string} widgetId Unique widget identifier
* @param {string} endpointUrl Widget wurl origin (protocol + (optional port) + host)
* @return {boolean} True if endpoint was successfully removed
*/
removeEndpoint(widgetId, endpointUrl) {
const u = URL.parse(endpointUrl);
if (!u || !u.protocol || !u.host) {
console.warn('Remove widget messaging endpoint - Invalid origin');
return;
}
const origin = u.protocol + '//' + u.host;
if (this.widgetMessagingEndpoints && this.widgetMessagingEndpoints.length > 0) {
const length = this.widgetMessagingEndpoints.length;
this.widgetMessagingEndpoints = this.widgetMessagingEndpoints
.filter((endpoint) => endpoint.widgetId !== widgetId || endpoint.endpointUrl !== origin);
return (length > this.widgetMessagingEndpoints.length);
}
return false;
}
/**
* Handle widget postMessage events
* Messages are only handled where a valid, registered messaging endpoints
* @param {Event} event Event to handle
* @return {undefined}
*/
onPostMessage(event) {
if (!event.origin) { // Handle chrome
event.origin = event.originalEvent.origin;
}
// Event origin is empty string if undefined
if (
event.origin.length === 0 ||
!this.trustedEndpoint(event.origin) ||
event.data.api !== INBOUND_API_NAME ||
!event.data.widgetId
) {
return; // don't log this - debugging APIs like to spam postMessage which floods the log otherwise
}
// Call any listeners we have registered
if (this.widgetListeners[event.data.action]) {
for (const fn of this.widgetListeners[event.data.action]) {
fn(event.data, event);
}
}
// Although the requestId is required, we don't use it. We'll be nice and process the message
// if the property is missing, but with a warning for widget developers.
if (!event.data.requestId) {
console.warn("fromWidget action '" + event.data.action + "' does not have a requestId");
}
const action = event.data.action;
const widgetId = event.data.widgetId;
if (action === 'content_loaded') {
console.log('Widget reported content loaded for', widgetId);
dis.dispatch({
action: 'widget_content_loaded',
widgetId: widgetId,
});
this.sendResponse(event, {success: true});
} else if (action === 'supported_api_versions') {
this.sendResponse(event, {
api: INBOUND_API_NAME,
supported_versions: SUPPORTED_WIDGET_API_VERSIONS,
});
} else if (action === 'api_version') {
this.sendResponse(event, {
api: INBOUND_API_NAME,
version: WIDGET_API_VERSION,
});
} else if (action === 'm.sticker') {
// console.warn('Got sticker message from widget', widgetId);
// NOTE -- The widgetData field is deprecated (in favour of the 'data' field) and will be removed eventually
const data = event.data.data || event.data.widgetData;
dis.dispatch({action: 'm.sticker', data: data, widgetId: event.data.widgetId});
} else if (action === 'integration_manager_open') {
// Close the stickerpicker
dis.dispatch({action: 'stickerpicker_close'});
// Open the integration manager
// NOTE -- The widgetData field is deprecated (in favour of the 'data' field) and will be removed eventually
const data = event.data.data || event.data.widgetData;
const integType = (data && data.integType) ? data.integType : null;
const integId = (data && data.integId) ? data.integId : null;
// TODO: Open the right integration manager for the widget
if (SettingsStore.isFeatureEnabled("feature_many_integration_managers")) {
IntegrationManagers.sharedInstance().openAll(
MatrixClientPeg.get().getRoom(RoomViewStore.getRoomId()),
`type_${integType}`,
integId,
);
} else {
IntegrationManagers.sharedInstance().getPrimaryManager().open(
MatrixClientPeg.get().getRoom(RoomViewStore.getRoomId()),
`type_${integType}`,
integId,
);
}
} else if (action === 'set_always_on_screen') {
// This is a new message: there is no reason to support the deprecated widgetData here
const data = event.data.data;
const val = data.value;
if (ActiveWidgetStore.widgetHasCapability(widgetId, Capability.AlwaysOnScreen)) {
ActiveWidgetStore.setWidgetPersistence(widgetId, val);
}
} else if (action === 'get_openid') {
// Handled by caller
} else {
console.warn('Widget postMessage event unhandled');
this.sendError(event, {message: 'The postMessage was unhandled'});
}
}
/**
* Check if message origin is registered as trusted
* @param {string} origin PostMessage origin to check
* @return {boolean} True if trusted
*/
trustedEndpoint(origin) {
if (!origin) {
return false;
}
return this.widgetMessagingEndpoints.some((endpoint) => {
// TODO / FIXME -- Should this also check the widgetId?
return endpoint.endpointUrl === origin;
});
}
/**
* Send a postmessage response to a postMessage request
* @param {Event} event The original postMessage request event
* @param {Object} res Response data
*/
sendResponse(event, res) {
const data = JSON.parse(JSON.stringify(event.data));
data.response = res;
event.source.postMessage(data, event.origin);
}
/**
* Send an error response to a postMessage request
* @param {Event} event The original postMessage request event
* @param {string} msg Error message
* @param {Error} nestedError Nested error event (optional)
*/
sendError(event, msg, nestedError) {
console.error('Action:' + event.data.action + ' failed with message: ' + msg);
const data = JSON.parse(JSON.stringify(event.data));
data.response = {
error: {
message: msg,
},
};
if (nestedError) {
data.response.error._error = nestedError;
}
event.source.postMessage(data, event.origin);
}
}

View file

@ -19,9 +19,9 @@ import Modal from './Modal';
import * as sdk from './';
import MultiInviter from './utils/MultiInviter';
import { _t } from './languageHandler';
import {MatrixClientPeg} from './MatrixClientPeg';
import { MatrixClientPeg } from './MatrixClientPeg';
import GroupStore from './stores/GroupStore';
import {allSettled} from "./utils/promise";
import StyledCheckbox from './components/views/elements/StyledCheckbox';
export function showGroupInviteDialog(groupId) {
return new Promise((resolve, reject) => {
@ -61,19 +61,19 @@ export function showGroupAddRoomDialog(groupId) {
<div>{ _t("Which rooms would you like to add to this community?") }</div>
</div>;
const checkboxContainer = <label className="mx_GroupAddressPicker_checkboxContainer">
<input type="checkbox" onChange={onCheckboxClicked} />
<div>
{ _t("Show these rooms to non-members on the community page and room list?") }
</div>
</label>;
const checkboxContainer = <StyledCheckbox
className="mx_GroupAddressPicker_checkboxContainer"
onChange={onCheckboxClicked}
>
{ _t("Show these rooms to non-members on the community page and room list?") }
</StyledCheckbox>;
const AddressPickerDialog = sdk.getComponent("dialogs.AddressPickerDialog");
Modal.createTrackedDialog('Add Rooms to Group', '', AddressPickerDialog, {
title: _t("Add rooms to the community"),
description: description,
extraNode: checkboxContainer,
placeholder: _t("Room name or alias"),
placeholder: _t("Room name or address"),
button: _t("Add to community"),
pickerType: 'room',
validAddressTypes: ['mx-room-id'],
@ -103,7 +103,7 @@ function _onGroupInviteFinished(groupId, addrs) {
if (errorList.length > 0) {
const ErrorDialog = sdk.getComponent("dialogs.ErrorDialog");
Modal.createTrackedDialog('Failed to invite the following users to the group', '', ErrorDialog, {
title: _t("Failed to invite the following users to %(groupId)s:", {groupId: groupId}),
title: _t("Failed to invite the following users to %(groupId)s:", { groupId: groupId }),
description: errorList.join(", "),
});
}
@ -111,7 +111,7 @@ function _onGroupInviteFinished(groupId, addrs) {
const ErrorDialog = sdk.getComponent("dialogs.ErrorDialog");
Modal.createTrackedDialog('Failed to invite users to group', '', ErrorDialog, {
title: _t("Failed to invite users to community"),
description: _t("Failed to invite users to %(groupId)s", {groupId: groupId}),
description: _t("Failed to invite users to %(groupId)s", { groupId: groupId }),
});
});
}
@ -119,7 +119,7 @@ function _onGroupInviteFinished(groupId, addrs) {
function _onGroupAddRoomFinished(groupId, addrs, addRoomsPublicly) {
const matrixClient = MatrixClientPeg.get();
const errorList = [];
return allSettled(addrs.map((addr) => {
return Promise.allSettled(addrs.map((addr) => {
return GroupStore
.addRoomToGroup(groupId, addr.address, addRoomsPublicly)
.catch(() => { errorList.push(addr.address); })
@ -137,7 +137,7 @@ function _onGroupAddRoomFinished(groupId, addrs, addRoomsPublicly) {
// Add this group as related
if (!groups.includes(groupId)) {
groups.push(groupId);
return MatrixClientPeg.get().sendStateEvent(roomId, 'm.room.related_groups', {groups}, '');
return MatrixClientPeg.get().sendStateEvent(roomId, 'm.room.related_groups', { groups }, '');
}
});
})).then(() => {
@ -147,13 +147,15 @@ function _onGroupAddRoomFinished(groupId, addrs, addRoomsPublicly) {
const ErrorDialog = sdk.getComponent("dialogs.ErrorDialog");
Modal.createTrackedDialog(
'Failed to add the following room to the group',
'', ErrorDialog,
{
title: _t(
"Failed to add the following rooms to %(groupId)s:",
{groupId},
),
description: errorList.join(", "),
});
'',
ErrorDialog,
{
title: _t(
"Failed to add the following rooms to %(groupId)s:",
{ groupId },
),
description: errorList.join(", "),
},
);
});
}

View file

@ -17,23 +17,26 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
'use strict';
import ReplyThread from "./components/views/elements/ReplyThread";
import React from 'react';
import React, { ReactNode } from 'react';
import sanitizeHtml from 'sanitize-html';
import cheerio from 'cheerio';
import * as linkify from 'linkifyjs';
import linkifyMatrix from './linkify-matrix';
import _linkifyElement from 'linkifyjs/element';
import _linkifyString from 'linkifyjs/string';
import classNames from 'classnames';
import {MatrixClientPeg} from './MatrixClientPeg';
import url from 'url';
import EMOJIBASE_REGEX from 'emojibase-regex';
import {tryTransformPermalinkToLocalHref} from "./utils/permalinks/Permalinks";
import {SHORTCODE_TO_EMOJI, getEmojiFromUnicode} from "./emoji";
import url from 'url';
import katex from 'katex';
import { AllHtmlEntities } from 'html-entities';
import { IContent } from 'matrix-js-sdk/src/models/event';
import { IExtendedSanitizeOptions } from './@types/sanitize-html';
import linkifyMatrix from './linkify-matrix';
import SettingsStore from './settings/SettingsStore';
import { tryTransformPermalinkToLocalHref } from "./utils/permalinks/Permalinks";
import { SHORTCODE_TO_EMOJI, getEmojiFromUnicode } from "./emoji";
import ReplyThread from "./components/views/elements/ReplyThread";
import { mediaFromMxc } from "./customisations/Media";
linkifyMatrix(linkify);
@ -55,7 +58,9 @@ const BIGEMOJI_REGEX = new RegExp(`^(${EMOJIBASE_REGEX.source})+$`, 'i');
const COLOR_REGEX = /^#[0-9a-fA-F]{6}$/;
const PERMITTED_URL_SCHEMES = ['http', 'https', 'ftp', 'mailto', 'magnet'];
export const PERMITTED_URL_SCHEMES = ['http', 'https', 'ftp', 'mailto', 'magnet'];
const MEDIA_API_MXC_REGEX = /\/_matrix\/media\/r0\/(?:download|thumbnail)\/(.+?)\/(.+?)(?:[?/]|$)/;
/*
* Return true if the given string contains emoji
@ -64,7 +69,7 @@ const PERMITTED_URL_SCHEMES = ['http', 'https', 'ftp', 'mailto', 'magnet'];
* need emojification.
* unicodeToImage uses this function.
*/
function mightContainEmoji(str) {
function mightContainEmoji(str: string): boolean {
return SURROGATE_PAIR_PATTERN.test(str) || SYMBOL_PATTERN.test(str);
}
@ -74,7 +79,7 @@ function mightContainEmoji(str) {
* @param {String} char The emoji character
* @return {String} The shortcode (such as :thumbup:)
*/
export function unicodeToShortcode(char) {
export function unicodeToShortcode(char: string): string {
const data = getEmojiFromUnicode(char);
return (data && data.shortcodes ? `:${data.shortcodes[0]}:` : '');
}
@ -85,7 +90,7 @@ export function unicodeToShortcode(char) {
* @param {String} shortcode The shortcode (such as :thumbup:)
* @return {String} The emoji character; null if none exists
*/
export function shortcodeToUnicode(shortcode) {
export function shortcodeToUnicode(shortcode: string): string {
shortcode = shortcode.slice(1, shortcode.length - 1);
const data = SHORTCODE_TO_EMOJI.get(shortcode);
return data ? data.unicode : null;
@ -100,7 +105,7 @@ export function processHtmlForSending(html: string): string {
}
let contentHTML = "";
for (let i=0; i < contentDiv.children.length; i++) {
for (let i = 0; i < contentDiv.children.length; i++) {
const element = contentDiv.children[i];
if (element.tagName.toLowerCase() === 'p') {
contentHTML += element.innerHTML;
@ -122,12 +127,22 @@ export function processHtmlForSending(html: string): string {
* Given an untrusted HTML string, return a React node with an sanitized version
* of that HTML.
*/
export function sanitizedHtmlNode(insaneHtml) {
export function sanitizedHtmlNode(insaneHtml: string): ReactNode {
const saneHtml = sanitizeHtml(insaneHtml, sanitizeHtmlParams);
return <div dangerouslySetInnerHTML={{ __html: saneHtml }} dir="auto" />;
}
export function getHtmlText(insaneHtml: string): string {
return sanitizeHtml(insaneHtml, {
allowedTags: [],
allowedAttributes: {},
selfClosing: [],
allowedSchemes: [],
disallowedTagsMode: 'discard',
});
}
/**
* Tests if a URL from an untrusted source may be safely put into the DOM
* The biggest threat here is javascript: URIs.
@ -136,7 +151,7 @@ export function sanitizedHtmlNode(insaneHtml) {
* other places we need to sanitise URLs.
* @return true if permitted, otherwise false
*/
export function isUrlPermitted(inputUrl) {
export function isUrlPermitted(inputUrl: string): boolean {
try {
const parsed = url.parse(inputUrl);
if (!parsed.protocol) return false;
@ -147,14 +162,14 @@ export function isUrlPermitted(inputUrl) {
}
}
const transformTags = { // custom to matrix
const transformTags: IExtendedSanitizeOptions["transformTags"] = { // custom to matrix
// add blank targets to all hyperlinks except vector URLs
'a': function(tagName, attribs) {
'a': function(tagName: string, attribs: sanitizeHtml.Attributes) {
if (attribs.href) {
attribs.target = '_blank'; // by default
const transformed = tryTransformPermalinkToLocalHref(attribs.href);
if (transformed !== attribs.href || attribs.href.match(linkifyMatrix.VECTOR_URL_PATTERN)) {
if (transformed !== attribs.href || attribs.href.match(linkifyMatrix.ELEMENT_URL_PATTERN)) {
attribs.href = transformed;
delete attribs.target;
}
@ -162,31 +177,45 @@ const transformTags = { // custom to matrix
attribs.rel = 'noreferrer noopener'; // https://mathiasbynens.github.io/rel-noopener/
return { tagName, attribs };
},
'img': function(tagName, attribs) {
'img': function(tagName: string, attribs: sanitizeHtml.Attributes) {
let src = attribs.src;
// Strip out imgs that aren't `mxc` here instead of using allowedSchemesByTag
// because transformTags is used _before_ we filter by allowedSchemesByTag and
// we don't want to allow images with `https?` `src`s.
if (!attribs.src || !attribs.src.startsWith('mxc://')) {
return { tagName, attribs: {}};
// We also drop inline images (as if they were not present at all) when the "show
// images" preference is disabled. Future work might expose some UI to reveal them
// like standalone image events have.
if (!src || !SettingsStore.getValue("showImages")) {
return { tagName, attribs: {} };
}
attribs.src = MatrixClientPeg.get().mxcUrlToHttp(
attribs.src,
attribs.width || 800,
attribs.height || 600,
);
if (!src.startsWith("mxc://")) {
const match = MEDIA_API_MXC_REGEX.exec(src);
if (match) {
src = `mxc://${match[1]}/${match[2]}`;
}
}
if (!src.startsWith("mxc://")) {
return { tagName, attribs: {} };
}
const width = Number(attribs.width) || 800;
const height = Number(attribs.height) || 600;
attribs.src = mediaFromMxc(src).getThumbnailOfSourceHttp(width, height);
return { tagName, attribs };
},
'code': function(tagName, attribs) {
'code': function(tagName: string, attribs: sanitizeHtml.Attributes) {
if (typeof attribs.class !== 'undefined') {
// Filter out all classes other than ones starting with language- for syntax highlighting.
const classes = attribs.class.split(/\s/).filter(function(cl) {
return cl.startsWith('language-');
return cl.startsWith('language-') && !cl.startsWith('language-_');
});
attribs.class = classes.join(' ');
}
return { tagName, attribs };
},
'*': function(tagName, attribs) {
'*': function(tagName: string, attribs: sanitizeHtml.Attributes) {
// Delete any style previously assigned, style is an allowedTag for font and span
// because attributes are stripped after transforming
delete attribs.style;
@ -220,18 +249,20 @@ const transformTags = { // custom to matrix
},
};
const sanitizeHtmlParams = {
const sanitizeHtmlParams: IExtendedSanitizeOptions = {
allowedTags: [
'font', // custom to matrix for IRC-style font coloring
'del', // for markdown
'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'blockquote', 'p', 'a', 'ul', 'ol', 'sup', 'sub',
'nl', 'li', 'b', 'i', 'u', 'strong', 'em', 'strike', 'code', 'hr', 'br', 'div',
'table', 'thead', 'caption', 'tbody', 'tr', 'th', 'td', 'pre', 'span', 'img',
'details', 'summary',
],
allowedAttributes: {
// custom ones first:
font: ['color', 'data-mx-bg-color', 'data-mx-color', 'style'], // custom to matrix
span: ['data-mx-bg-color', 'data-mx-color', 'data-mx-spoiler', 'style'], // custom to matrix
span: ['data-mx-maths', 'data-mx-bg-color', 'data-mx-color', 'data-mx-spoiler', 'style'], // custom to matrix
div: ['data-mx-maths'],
a: ['href', 'name', 'target', 'rel'], // remote target: custom to matrix
img: ['src', 'width', 'height', 'alt', 'title'],
ol: ['start'],
@ -241,22 +272,23 @@ const sanitizeHtmlParams = {
selfClosing: ['img', 'br', 'hr', 'area', 'base', 'basefont', 'input', 'link', 'meta'],
// URL schemes we permit
allowedSchemes: PERMITTED_URL_SCHEMES,
allowProtocolRelative: false,
transformTags,
// 50 levels deep "should be enough for anyone"
nestingLimit: 50,
};
// this is the same as the above except with less rewriting
const composerSanitizeHtmlParams = Object.assign({}, sanitizeHtmlParams);
composerSanitizeHtmlParams.transformTags = {
'code': transformTags['code'],
'*': transformTags['*'],
const composerSanitizeHtmlParams: IExtendedSanitizeOptions = {
...sanitizeHtmlParams,
transformTags: {
'code': transformTags['code'],
'*': transformTags['*'],
},
};
class BaseHighlighter {
constructor(highlightClass, highlightLink) {
this.highlightClass = highlightClass;
this.highlightLink = highlightLink;
abstract class BaseHighlighter<T extends React.ReactNode> {
constructor(public highlightClass: string, public highlightLink: string) {
}
/**
@ -270,47 +302,49 @@ class BaseHighlighter {
* returns a list of results (strings for HtmlHighligher, react nodes for
* TextHighlighter).
*/
applyHighlights(safeSnippet, safeHighlights) {
public applyHighlights(safeSnippet: string, safeHighlights: string[]): T[] {
let lastOffset = 0;
let offset;
let nodes = [];
let nodes: T[] = [];
const safeHighlight = safeHighlights[0];
while ((offset = safeSnippet.toLowerCase().indexOf(safeHighlight.toLowerCase(), lastOffset)) >= 0) {
// handle preamble
if (offset > lastOffset) {
var subSnippet = safeSnippet.substring(lastOffset, offset);
nodes = nodes.concat(this._applySubHighlights(subSnippet, safeHighlights));
const subSnippet = safeSnippet.substring(lastOffset, offset);
nodes = nodes.concat(this.applySubHighlights(subSnippet, safeHighlights));
}
// do highlight. use the original string rather than safeHighlight
// to preserve the original casing.
const endOffset = offset + safeHighlight.length;
nodes.push(this._processSnippet(safeSnippet.substring(offset, endOffset), true));
nodes.push(this.processSnippet(safeSnippet.substring(offset, endOffset), true));
lastOffset = endOffset;
}
// handle postamble
if (lastOffset !== safeSnippet.length) {
subSnippet = safeSnippet.substring(lastOffset, undefined);
nodes = nodes.concat(this._applySubHighlights(subSnippet, safeHighlights));
const subSnippet = safeSnippet.substring(lastOffset, undefined);
nodes = nodes.concat(this.applySubHighlights(subSnippet, safeHighlights));
}
return nodes;
}
_applySubHighlights(safeSnippet, safeHighlights) {
private applySubHighlights(safeSnippet: string, safeHighlights: string[]): T[] {
if (safeHighlights[1]) {
// recurse into this range to check for the next set of highlight matches
return this.applyHighlights(safeSnippet, safeHighlights.slice(1));
} else {
// no more highlights to be found, just return the unhighlighted string
return [this._processSnippet(safeSnippet, false)];
return [this.processSnippet(safeSnippet, false)];
}
}
protected abstract processSnippet(snippet: string, highlight: boolean): T;
}
class HtmlHighlighter extends BaseHighlighter {
class HtmlHighlighter extends BaseHighlighter<string> {
/* highlight the given snippet if required
*
* snippet: content of the span; must have been sanitised
@ -318,52 +352,37 @@ class HtmlHighlighter extends BaseHighlighter {
*
* returns an HTML string
*/
_processSnippet(snippet, highlight) {
protected processSnippet(snippet: string, highlight: boolean): string {
if (!highlight) {
// nothing required here
return snippet;
}
let span = "<span class=\""+this.highlightClass+"\">"
+ snippet + "</span>";
let span = `<span class="${this.highlightClass}">${snippet}</span>`;
if (this.highlightLink) {
span = "<a href=\""+encodeURI(this.highlightLink)+"\">"
+span+"</a>";
span = `<a href="${encodeURI(this.highlightLink)}">${span}</a>`;
}
return span;
}
}
class TextHighlighter extends BaseHighlighter {
constructor(highlightClass, highlightLink) {
super(highlightClass, highlightLink);
this._key = 0;
}
/* create a <span> node to hold the given content
*
* snippet: content of the span
* highlight: true to highlight as a search match
*
* returns a React node
*/
_processSnippet(snippet, highlight) {
const key = this._key++;
let node =
<span key={key} className={highlight ? this.highlightClass : null}>
{ snippet }
</span>;
if (highlight && this.highlightLink) {
node = <a key={key} href={this.highlightLink}>{ node }</a>;
}
return node;
}
interface IOpts {
highlightLink?: string;
disableBigEmoji?: boolean;
stripReplyFallback?: boolean;
returnString?: boolean;
forComposerQuote?: boolean;
ref?: React.Ref<HTMLSpanElement>;
}
export interface IOptsReturnNode extends IOpts {
returnString: false | undefined;
}
export interface IOptsReturnString extends IOpts {
returnString: true;
}
/* turn a matrix event body into html
*
@ -378,7 +397,9 @@ class TextHighlighter extends BaseHighlighter {
* opts.forComposerQuote: optional param to lessen the url rewriting done by sanitization, for quoting into composer
* opts.ref: React ref to attach to any React components returned (not compatible with opts.returnString)
*/
export function bodyToHtml(content, highlights, opts={}) {
export function bodyToHtml(content: IContent, highlights: string[], opts: IOptsReturnString): string;
export function bodyToHtml(content: IContent, highlights: string[], opts: IOptsReturnNode): ReactNode;
export function bodyToHtml(content: IContent, highlights: string[], opts: IOpts = {}) {
const isHtmlMessage = content.format === "org.matrix.custom.html" && content.formatted_body;
let bodyHasEmoji = false;
@ -387,9 +408,9 @@ export function bodyToHtml(content, highlights, opts={}) {
sanitizeParams = composerSanitizeHtmlParams;
}
let strippedBody;
let safeBody;
let isDisplayedWithHtml;
let strippedBody: string;
let safeBody: string;
let isDisplayedWithHtml: boolean;
// XXX: We sanitize the HTML whilst also highlighting its text nodes, to avoid accidentally trying
// to highlight HTML tags themselves. However, this does mean that we don't highlight textnodes which
// are interrupted by HTML tags (not that we did before) - e.g. foo<span/>bar won't get highlighted
@ -397,9 +418,14 @@ export function bodyToHtml(content, highlights, opts={}) {
try {
if (highlights && highlights.length > 0) {
const highlighter = new HtmlHighlighter("mx_EventTile_searchHighlight", opts.highlightLink);
const safeHighlights = highlights.map(function(highlight) {
return sanitizeHtml(highlight, sanitizeParams);
});
const safeHighlights = highlights
// sanitizeHtml can hang if an unclosed HTML tag is thrown at it
// A search for `<foo` will make the browser crash
// an alternative would be to escape HTML special characters
// but that would bring no additional benefit as the highlighter
// does not work with those special chars
.filter((highlight: string): boolean => !highlight.includes("<"))
.map((highlight: string): string => sanitizeHtml(highlight, sanitizeParams));
// XXX: hacky bodge to temporarily apply a textFilter to the sanitizeParams structure.
sanitizeParams.textFilter = function(safeText) {
return highlighter.applyHighlights(safeText, safeHighlights).join('');
@ -407,7 +433,7 @@ export function bodyToHtml(content, highlights, opts={}) {
}
let formattedBody = typeof content.formatted_body === 'string' ? content.formatted_body : null;
const plainBody = typeof content.body === 'string' ? content.body : null;
const plainBody = typeof content.body === 'string' ? content.body : "";
if (opts.stripReplyFallback && formattedBody) formattedBody = ReplyThread.stripHTMLReply(formattedBody);
strippedBody = opts.stripReplyFallback ? ReplyThread.stripPlainReply(plainBody) : plainBody;
@ -418,18 +444,41 @@ export function bodyToHtml(content, highlights, opts={}) {
if (isHtmlMessage) {
isDisplayedWithHtml = true;
safeBody = sanitizeHtml(formattedBody, sanitizeParams);
if (SettingsStore.getValue("feature_latex_maths")) {
const phtml = cheerio.load(safeBody, {
// @ts-ignore: The `_useHtmlParser2` internal option is the
// simplest way to both parse and render using `htmlparser2`.
_useHtmlParser2: true,
decodeEntities: false,
});
// @ts-ignore - The types for `replaceWith` wrongly expect
// Cheerio instance to be returned.
phtml('div, span[data-mx-maths!=""]').replaceWith(function(i, e) {
return katex.renderToString(
AllHtmlEntities.decode(phtml(e).attr('data-mx-maths')),
{
throwOnError: false,
// @ts-ignore - `e` can be an Element, not just a Node
displayMode: e.name == 'div',
output: "htmlAndMathml",
});
});
safeBody = phtml.html();
}
}
} finally {
delete sanitizeParams.textFilter;
}
const contentBody = isDisplayedWithHtml ? safeBody : strippedBody;
if (opts.returnString) {
return isDisplayedWithHtml ? safeBody : strippedBody;
return contentBody;
}
let emojiBody = false;
if (!opts.disableBigEmoji && bodyHasEmoji) {
let contentBodyTrimmed = strippedBody !== undefined ? strippedBody.trim() : '';
let contentBodyTrimmed = contentBody !== undefined ? contentBody.trim() : '';
// Ignore spaces in body text. Emojis with spaces in between should
// still be counted as purely emoji messages.
@ -446,7 +495,8 @@ export function bodyToHtml(content, highlights, opts={}) {
// their username. Permalinks (links in pills) can be any URL
// now, so we just check for an HTTP-looking thing.
(
content.formatted_body == undefined ||
strippedBody === safeBody || // replies have the html fallbacks, account for that here
content.formatted_body === undefined ||
(!content.formatted_body.includes("http:") &&
!content.formatted_body.includes("https:"))
);
@ -459,8 +509,13 @@ export function bodyToHtml(content, highlights, opts={}) {
});
return isDisplayedWithHtml ?
<span key="body" ref={opts.ref} className={className} dangerouslySetInnerHTML={{ __html: safeBody }} dir="auto" /> :
<span key="body" ref={opts.ref} className={className} dir="auto">{ strippedBody }</span>;
<span
key="body"
ref={opts.ref}
className={className}
dangerouslySetInnerHTML={{ __html: safeBody }}
dir="auto"
/> : <span key="body" ref={opts.ref} className={className} dir="auto">{ strippedBody }</span>;
}
/**
@ -470,7 +525,7 @@ export function bodyToHtml(content, highlights, opts={}) {
* @param {object} [options] Options for linkifyString. Default: linkifyMatrix.options
* @returns {string} Linkified string
*/
export function linkifyString(str, options = linkifyMatrix.options) {
export function linkifyString(str: string, options = linkifyMatrix.options): string {
return _linkifyString(str, options);
}
@ -481,7 +536,7 @@ export function linkifyString(str, options = linkifyMatrix.options) {
* @param {object} [options] Options for linkifyElement. Default: linkifyMatrix.options
* @returns {object}
*/
export function linkifyElement(element, options = linkifyMatrix.options) {
export function linkifyElement(element: HTMLElement, options = linkifyMatrix.options): HTMLElement {
return _linkifyElement(element, options);
}
@ -492,7 +547,7 @@ export function linkifyElement(element, options = linkifyMatrix.options) {
* @param {object} [options] Options for linkifyString. Default: linkifyMatrix.options
* @returns {string}
*/
export function linkifyAndSanitizeHtml(dirtyHtml, options = linkifyMatrix.options) {
export function linkifyAndSanitizeHtml(dirtyHtml: string, options = linkifyMatrix.options): string {
return sanitizeHtml(linkifyString(dirtyHtml, options), sanitizeHtmlParams);
}
@ -503,7 +558,7 @@ export function linkifyAndSanitizeHtml(dirtyHtml, options = linkifyMatrix.option
* @param {Node} node
* @returns {bool}
*/
export function checkBlockNode(node) {
export function checkBlockNode(node: Node): boolean {
switch (node.nodeName) {
case "H1":
case "H2":
@ -513,7 +568,6 @@ export function checkBlockNode(node) {
case "H6":
case "PRE":
case "BLOCKQUOTE":
case "DIV":
case "P":
case "UL":
case "OL":
@ -526,6 +580,9 @@ export function checkBlockNode(node) {
case "TH":
case "TD":
return true;
case "DIV":
// don't treat math nodes as block nodes for deserializing
return !(node as HTMLElement).hasAttribute("data-mx-maths");
default:
return false;
}

View file

@ -14,9 +14,10 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import { createClient, SERVICE_TYPES } from 'matrix-js-sdk';
import { SERVICE_TYPES } from 'matrix-js-sdk/src/service-types';
import { createClient } from 'matrix-js-sdk/src/matrix';
import {MatrixClientPeg} from './MatrixClientPeg';
import { MatrixClientPeg } from './MatrixClientPeg';
import Modal from './Modal';
import * as sdk from './index';
import { _t } from './languageHandler';
@ -126,7 +127,7 @@ export default class IdentityAuthClient {
await this._matrixClient.getIdentityAccount(token);
} catch (e) {
if (e.errcode === "M_TERMS_NOT_SIGNED") {
console.log("Identity Server requires new terms to be agreed to");
console.log("Identity server requires new terms to be agreed to");
await startTermsFlow([new Service(
SERVICE_TYPES.IS,
identityServerUrl,
@ -162,9 +163,10 @@ export default class IdentityAuthClient {
</div>
),
button: _t("Trust"),
});
});
const [confirmed] = await finished;
if (confirmed) {
// eslint-disable-next-line react-hooks/rules-of-hooks
useDefaultIdentityServer();
} else {
throw new AbortedIdentityActionError(
@ -177,7 +179,7 @@ export default class IdentityAuthClient {
// appropriately. We already clear storage on sign out, but we'll need
// additional clearing when changing ISes in settings as part of future
// privacy work.
// See also https://github.com/vector-im/riot-web/issues/10455.
// See also https://github.com/vector-im/element-web/issues/10455.
}
async registerForToken(check=true) {

View file

@ -1,5 +1,5 @@
/*
Copyright 2015, 2016 OpenMarket Ltd
Copyright 2015, 2016, 2020 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,8 +14,6 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
'use strict';
/**
* Returns the actual height that an image of dimensions (fullWidth, fullHeight)
* will occupy if resized to fit inside a thumbnail bounding box of size
@ -30,11 +28,11 @@ limitations under the License.
* consume in the timeline, when performing scroll offset calcuations
* (e.g. scroll locking)
*/
export function thumbHeight(fullWidth, fullHeight, thumbWidth, thumbHeight) {
export function thumbHeight(fullWidth: number, fullHeight: number, thumbWidth: number, thumbHeight: number) {
if (!fullWidth || !fullHeight) {
// Cannot calculate thumbnail height for image: missing w/h in metadata. We can't even
// log this because it's spammy
return undefined;
return null;
}
if (fullWidth < thumbWidth && fullHeight < thumbHeight) {
// no scaling needs to be applied

407
src/KeyBindingsDefaults.ts Normal file
View file

@ -0,0 +1,407 @@
/*
Copyright 2021 Clemens Zeidler
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 { AutocompleteAction, IKeyBindingsProvider, KeyBinding, MessageComposerAction, NavigationAction, RoomAction,
RoomListAction } from "./KeyBindingsManager";
import { isMac, Key } from "./Keyboard";
import SettingsStore from "./settings/SettingsStore";
const messageComposerBindings = (): KeyBinding<MessageComposerAction>[] => {
const bindings: KeyBinding<MessageComposerAction>[] = [
{
action: MessageComposerAction.SelectPrevSendHistory,
keyCombo: {
key: Key.ARROW_UP,
altKey: true,
ctrlKey: true,
},
},
{
action: MessageComposerAction.SelectNextSendHistory,
keyCombo: {
key: Key.ARROW_DOWN,
altKey: true,
ctrlKey: true,
},
},
{
action: MessageComposerAction.EditPrevMessage,
keyCombo: {
key: Key.ARROW_UP,
},
},
{
action: MessageComposerAction.EditNextMessage,
keyCombo: {
key: Key.ARROW_DOWN,
},
},
{
action: MessageComposerAction.CancelEditing,
keyCombo: {
key: Key.ESCAPE,
},
},
{
action: MessageComposerAction.FormatBold,
keyCombo: {
key: Key.B,
ctrlOrCmd: true,
},
},
{
action: MessageComposerAction.FormatItalics,
keyCombo: {
key: Key.I,
ctrlOrCmd: true,
},
},
{
action: MessageComposerAction.FormatQuote,
keyCombo: {
key: Key.GREATER_THAN,
ctrlOrCmd: true,
shiftKey: true,
},
},
{
action: MessageComposerAction.EditUndo,
keyCombo: {
key: Key.Z,
ctrlOrCmd: true,
},
},
{
action: MessageComposerAction.MoveCursorToStart,
keyCombo: {
key: Key.HOME,
ctrlOrCmd: true,
},
},
{
action: MessageComposerAction.MoveCursorToEnd,
keyCombo: {
key: Key.END,
ctrlOrCmd: true,
},
},
];
if (isMac) {
bindings.push({
action: MessageComposerAction.EditRedo,
keyCombo: {
key: Key.Z,
ctrlOrCmd: true,
shiftKey: true,
},
});
} else {
bindings.push({
action: MessageComposerAction.EditRedo,
keyCombo: {
key: Key.Y,
ctrlOrCmd: true,
},
});
}
if (SettingsStore.getValue('MessageComposerInput.ctrlEnterToSend')) {
bindings.push({
action: MessageComposerAction.Send,
keyCombo: {
key: Key.ENTER,
ctrlOrCmd: true,
},
});
bindings.push({
action: MessageComposerAction.NewLine,
keyCombo: {
key: Key.ENTER,
},
});
} else {
bindings.push({
action: MessageComposerAction.Send,
keyCombo: {
key: Key.ENTER,
},
});
bindings.push({
action: MessageComposerAction.NewLine,
keyCombo: {
key: Key.ENTER,
shiftKey: true,
},
});
if (isMac) {
bindings.push({
action: MessageComposerAction.NewLine,
keyCombo: {
key: Key.ENTER,
altKey: true,
},
});
}
}
return bindings;
};
const autocompleteBindings = (): KeyBinding<AutocompleteAction>[] => {
return [
{
action: AutocompleteAction.CompleteOrNextSelection,
keyCombo: {
key: Key.TAB,
},
},
{
action: AutocompleteAction.CompleteOrNextSelection,
keyCombo: {
key: Key.TAB,
ctrlKey: true,
},
},
{
action: AutocompleteAction.CompleteOrPrevSelection,
keyCombo: {
key: Key.TAB,
shiftKey: true,
},
},
{
action: AutocompleteAction.CompleteOrPrevSelection,
keyCombo: {
key: Key.TAB,
ctrlKey: true,
shiftKey: true,
},
},
{
action: AutocompleteAction.Cancel,
keyCombo: {
key: Key.ESCAPE,
},
},
{
action: AutocompleteAction.PrevSelection,
keyCombo: {
key: Key.ARROW_UP,
},
},
{
action: AutocompleteAction.NextSelection,
keyCombo: {
key: Key.ARROW_DOWN,
},
},
];
};
const roomListBindings = (): KeyBinding<RoomListAction>[] => {
return [
{
action: RoomListAction.ClearSearch,
keyCombo: {
key: Key.ESCAPE,
},
},
{
action: RoomListAction.PrevRoom,
keyCombo: {
key: Key.ARROW_UP,
},
},
{
action: RoomListAction.NextRoom,
keyCombo: {
key: Key.ARROW_DOWN,
},
},
{
action: RoomListAction.SelectRoom,
keyCombo: {
key: Key.ENTER,
},
},
{
action: RoomListAction.CollapseSection,
keyCombo: {
key: Key.ARROW_LEFT,
},
},
{
action: RoomListAction.ExpandSection,
keyCombo: {
key: Key.ARROW_RIGHT,
},
},
];
};
const roomBindings = (): KeyBinding<RoomAction>[] => {
const bindings: KeyBinding<RoomAction>[] = [
{
action: RoomAction.ScrollUp,
keyCombo: {
key: Key.PAGE_UP,
},
},
{
action: RoomAction.RoomScrollDown,
keyCombo: {
key: Key.PAGE_DOWN,
},
},
{
action: RoomAction.DismissReadMarker,
keyCombo: {
key: Key.ESCAPE,
},
},
{
action: RoomAction.JumpToOldestUnread,
keyCombo: {
key: Key.PAGE_UP,
shiftKey: true,
},
},
{
action: RoomAction.UploadFile,
keyCombo: {
key: Key.U,
ctrlOrCmd: true,
shiftKey: true,
},
},
{
action: RoomAction.JumpToFirstMessage,
keyCombo: {
key: Key.HOME,
ctrlKey: true,
},
},
{
action: RoomAction.JumpToLatestMessage,
keyCombo: {
key: Key.END,
ctrlKey: true,
},
},
];
if (SettingsStore.getValue('ctrlFForSearch')) {
bindings.push({
action: RoomAction.FocusSearch,
keyCombo: {
key: Key.F,
ctrlOrCmd: true,
},
});
}
return bindings;
};
const navigationBindings = (): KeyBinding<NavigationAction>[] => {
return [
{
action: NavigationAction.FocusRoomSearch,
keyCombo: {
key: Key.K,
ctrlOrCmd: true,
},
},
{
action: NavigationAction.ToggleRoomSidePanel,
keyCombo: {
key: Key.PERIOD,
ctrlOrCmd: true,
},
},
{
action: NavigationAction.ToggleUserMenu,
// Ideally this would be CTRL+P for "Profile", but that's
// taken by the print dialog. CTRL+I for "Information"
// was previously chosen but conflicted with italics in
// composer, so CTRL+` it is
keyCombo: {
key: Key.BACKTICK,
ctrlOrCmd: true,
},
},
{
action: NavigationAction.ToggleShortCutDialog,
keyCombo: {
key: Key.SLASH,
ctrlOrCmd: true,
},
},
{
action: NavigationAction.ToggleShortCutDialog,
keyCombo: {
key: Key.SLASH,
ctrlOrCmd: true,
shiftKey: true,
},
},
{
action: NavigationAction.GoToHome,
keyCombo: {
key: Key.H,
ctrlKey: true,
altKey: !isMac,
shiftKey: isMac,
},
},
{
action: NavigationAction.SelectPrevRoom,
keyCombo: {
key: Key.ARROW_UP,
altKey: true,
},
},
{
action: NavigationAction.SelectNextRoom,
keyCombo: {
key: Key.ARROW_DOWN,
altKey: true,
},
},
{
action: NavigationAction.SelectPrevUnreadRoom,
keyCombo: {
key: Key.ARROW_UP,
altKey: true,
shiftKey: true,
},
},
{
action: NavigationAction.SelectNextUnreadRoom,
keyCombo: {
key: Key.ARROW_DOWN,
altKey: true,
shiftKey: true,
},
},
];
};
export const defaultBindingsProvider: IKeyBindingsProvider = {
getMessageComposerBindings: messageComposerBindings,
getAutocompleteBindings: autocompleteBindings,
getRoomListBindings: roomListBindings,
getRoomBindings: roomBindings,
getNavigationBindings: navigationBindings,
};

273
src/KeyBindingsManager.ts Normal file
View file

@ -0,0 +1,273 @@
/*
Copyright 2021 Clemens Zeidler
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 { defaultBindingsProvider } from './KeyBindingsDefaults';
import { isMac } from './Keyboard';
/** Actions for the chat message composer component */
export enum MessageComposerAction {
/** Send a message */
Send = 'Send',
/** Go backwards through the send history and use the message in composer view */
SelectPrevSendHistory = 'SelectPrevSendHistory',
/** Go forwards through the send history */
SelectNextSendHistory = 'SelectNextSendHistory',
/** Start editing the user's last sent message */
EditPrevMessage = 'EditPrevMessage',
/** Start editing the user's next sent message */
EditNextMessage = 'EditNextMessage',
/** Cancel editing a message or cancel replying to a message */
CancelEditing = 'CancelEditing',
/** Set bold format the current selection */
FormatBold = 'FormatBold',
/** Set italics format the current selection */
FormatItalics = 'FormatItalics',
/** Format the current selection as quote */
FormatQuote = 'FormatQuote',
/** Undo the last editing */
EditUndo = 'EditUndo',
/** Redo editing */
EditRedo = 'EditRedo',
/** Insert new line */
NewLine = 'NewLine',
/** Move the cursor to the start of the message */
MoveCursorToStart = 'MoveCursorToStart',
/** Move the cursor to the end of the message */
MoveCursorToEnd = 'MoveCursorToEnd',
}
/** Actions for text editing autocompletion */
export enum AutocompleteAction {
/**
* Select previous selection or, if the autocompletion window is not shown, open the window and select the first
* selection.
*/
CompleteOrPrevSelection = 'ApplySelection',
/** Select next selection or, if the autocompletion window is not shown, open it and select the first selection */
CompleteOrNextSelection = 'CompleteOrNextSelection',
/** Move to the previous autocomplete selection */
PrevSelection = 'PrevSelection',
/** Move to the next autocomplete selection */
NextSelection = 'NextSelection',
/** Close the autocompletion window */
Cancel = 'Cancel',
}
/** Actions for the room list sidebar */
export enum RoomListAction {
/** Clear room list filter field */
ClearSearch = 'ClearSearch',
/** Navigate up/down in the room list */
PrevRoom = 'PrevRoom',
/** Navigate down in the room list */
NextRoom = 'NextRoom',
/** Select room from the room list */
SelectRoom = 'SelectRoom',
/** Collapse room list section */
CollapseSection = 'CollapseSection',
/** Expand room list section, if already expanded, jump to first room in the selection */
ExpandSection = 'ExpandSection',
}
/** Actions for the current room view */
export enum RoomAction {
/** Scroll up in the timeline */
ScrollUp = 'ScrollUp',
/** Scroll down in the timeline */
RoomScrollDown = 'RoomScrollDown',
/** Dismiss read marker and jump to bottom */
DismissReadMarker = 'DismissReadMarker',
/** Jump to oldest unread message */
JumpToOldestUnread = 'JumpToOldestUnread',
/** Upload a file */
UploadFile = 'UploadFile',
/** Focus search message in a room (must be enabled) */
FocusSearch = 'FocusSearch',
/** Jump to the first (downloaded) message in the room */
JumpToFirstMessage = 'JumpToFirstMessage',
/** Jump to the latest message in the room */
JumpToLatestMessage = 'JumpToLatestMessage',
}
/** Actions for navigating do various menus, dialogs or screens */
export enum NavigationAction {
/** Jump to room search (search for a room) */
FocusRoomSearch = 'FocusRoomSearch',
/** Toggle the room side panel */
ToggleRoomSidePanel = 'ToggleRoomSidePanel',
/** Toggle the user menu */
ToggleUserMenu = 'ToggleUserMenu',
/** Toggle the short cut help dialog */
ToggleShortCutDialog = 'ToggleShortCutDialog',
/** Got to the Element home screen */
GoToHome = 'GoToHome',
/** Select prev room */
SelectPrevRoom = 'SelectPrevRoom',
/** Select next room */
SelectNextRoom = 'SelectNextRoom',
/** Select prev room with unread messages */
SelectPrevUnreadRoom = 'SelectPrevUnreadRoom',
/** Select next room with unread messages */
SelectNextUnreadRoom = 'SelectNextUnreadRoom',
}
/**
* Represent a key combination.
*
* The combo is evaluated strictly, i.e. the KeyboardEvent must match exactly what is specified in the KeyCombo.
*/
export type KeyCombo = {
key?: string;
/** On PC: ctrl is pressed; on Mac: meta is pressed */
ctrlOrCmd?: boolean;
altKey?: boolean;
ctrlKey?: boolean;
metaKey?: boolean;
shiftKey?: boolean;
};
export type KeyBinding<T extends string> = {
action: T;
keyCombo: KeyCombo;
};
/**
* Helper method to check if a KeyboardEvent matches a KeyCombo
*
* Note, this method is only exported for testing.
*/
export function isKeyComboMatch(ev: KeyboardEvent | React.KeyboardEvent, combo: KeyCombo, onMac: boolean): boolean {
if (combo.key !== undefined) {
// When shift is pressed, letters are returned as upper case chars. In this case do a lower case comparison.
// This works for letter combos such as shift + U as well for none letter combos such as shift + Escape.
// If shift is not pressed, the toLowerCase conversion can be avoided.
if (ev.shiftKey) {
if (ev.key.toLowerCase() !== combo.key.toLowerCase()) {
return false;
}
} else if (ev.key !== combo.key) {
return false;
}
}
const comboCtrl = combo.ctrlKey ?? false;
const comboAlt = combo.altKey ?? false;
const comboShift = combo.shiftKey ?? false;
const comboMeta = combo.metaKey ?? false;
// Tests mock events may keep the modifiers undefined; convert them to booleans
const evCtrl = ev.ctrlKey ?? false;
const evAlt = ev.altKey ?? false;
const evShift = ev.shiftKey ?? false;
const evMeta = ev.metaKey ?? false;
// When ctrlOrCmd is set, the keys need do evaluated differently on PC and Mac
if (combo.ctrlOrCmd) {
if (onMac) {
if (!evMeta
|| evCtrl !== comboCtrl
|| evAlt !== comboAlt
|| evShift !== comboShift) {
return false;
}
} else {
if (!evCtrl
|| evMeta !== comboMeta
|| evAlt !== comboAlt
|| evShift !== comboShift) {
return false;
}
}
return true;
}
if (evMeta !== comboMeta
|| evCtrl !== comboCtrl
|| evAlt !== comboAlt
|| evShift !== comboShift) {
return false;
}
return true;
}
export type KeyBindingGetter<T extends string> = () => KeyBinding<T>[];
export interface IKeyBindingsProvider {
getMessageComposerBindings: KeyBindingGetter<MessageComposerAction>;
getAutocompleteBindings: KeyBindingGetter<AutocompleteAction>;
getRoomListBindings: KeyBindingGetter<RoomListAction>;
getRoomBindings: KeyBindingGetter<RoomAction>;
getNavigationBindings: KeyBindingGetter<NavigationAction>;
}
export class KeyBindingsManager {
/**
* List of key bindings providers.
*
* Key bindings from the first provider(s) in the list will have precedence over key bindings from later providers.
*
* To overwrite the default key bindings add a new providers before the default provider, e.g. a provider for
* customized key bindings.
*/
bindingsProviders: IKeyBindingsProvider[] = [
defaultBindingsProvider,
];
/**
* Finds a matching KeyAction for a given KeyboardEvent
*/
private getAction<T extends string>(
getters: KeyBindingGetter<T>[],
ev: KeyboardEvent | React.KeyboardEvent,
): T | undefined {
for (const getter of getters) {
const bindings = getter();
const binding = bindings.find(it => isKeyComboMatch(ev, it.keyCombo, isMac));
if (binding) {
return binding.action;
}
}
return undefined;
}
getMessageComposerAction(ev: KeyboardEvent | React.KeyboardEvent): MessageComposerAction | undefined {
return this.getAction(this.bindingsProviders.map(it => it.getMessageComposerBindings), ev);
}
getAutocompleteAction(ev: KeyboardEvent | React.KeyboardEvent): AutocompleteAction | undefined {
return this.getAction(this.bindingsProviders.map(it => it.getAutocompleteBindings), ev);
}
getRoomListAction(ev: KeyboardEvent | React.KeyboardEvent): RoomListAction | undefined {
return this.getAction(this.bindingsProviders.map(it => it.getRoomListBindings), ev);
}
getRoomAction(ev: KeyboardEvent | React.KeyboardEvent): RoomAction | undefined {
return this.getAction(this.bindingsProviders.map(it => it.getRoomBindings), ev);
}
getNavigationAction(ev: KeyboardEvent | React.KeyboardEvent): NavigationAction | undefined {
return this.getAction(this.bindingsProviders.map(it => it.getNavigationBindings), ev);
}
}
const manager = new KeyBindingsManager();
export function getKeyBindingsManager(): KeyBindingsManager {
return manager;
}

View file

@ -1,158 +0,0 @@
/*
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.
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 * as sdk from './index';
import Modal from './Modal';
import SettingsStore from './settings/SettingsStore';
// TODO: We can remove this once cross-signing is the only way.
// https://github.com/vector-im/riot-web/issues/11908
export default class KeyRequestHandler {
constructor(matrixClient) {
this._matrixClient = matrixClient;
// the user/device for which we currently have a dialog open
this._currentUser = null;
this._currentDevice = null;
// userId -> deviceId -> [keyRequest]
this._pendingKeyRequests = Object.create(null);
}
handleKeyRequest(keyRequest) {
// Ignore own device key requests if cross-signing lab enabled
if (SettingsStore.isFeatureEnabled("feature_cross_signing")) {
return;
}
const userId = keyRequest.userId;
const deviceId = keyRequest.deviceId;
const requestId = keyRequest.requestId;
if (!this._pendingKeyRequests[userId]) {
this._pendingKeyRequests[userId] = Object.create(null);
}
if (!this._pendingKeyRequests[userId][deviceId]) {
this._pendingKeyRequests[userId][deviceId] = [];
}
// check if we already have this request
const requests = this._pendingKeyRequests[userId][deviceId];
if (requests.find((r) => r.requestId === requestId)) {
console.log("Already have this key request, ignoring");
return;
}
requests.push(keyRequest);
if (this._currentUser) {
// ignore for now
console.log("Key request, but we already have a dialog open");
return;
}
this._processNextRequest();
}
handleKeyRequestCancellation(cancellation) {
// Ignore own device key requests if cross-signing lab enabled
if (SettingsStore.isFeatureEnabled("feature_cross_signing")) {
return;
}
// see if we can find the request in the queue
const userId = cancellation.userId;
const deviceId = cancellation.deviceId;
const requestId = cancellation.requestId;
if (userId === this._currentUser && deviceId === this._currentDevice) {
console.log(
"room key request cancellation for the user we currently have a"
+ " dialog open for",
);
// TODO: update the dialog. For now, we just ignore the
// cancellation.
return;
}
if (!this._pendingKeyRequests[userId]) {
return;
}
const requests = this._pendingKeyRequests[userId][deviceId];
if (!requests) {
return;
}
const idx = requests.findIndex((r) => r.requestId === requestId);
if (idx < 0) {
return;
}
console.log("Forgetting room key request");
requests.splice(idx, 1);
if (requests.length === 0) {
delete this._pendingKeyRequests[userId][deviceId];
if (Object.keys(this._pendingKeyRequests[userId]).length === 0) {
delete this._pendingKeyRequests[userId];
}
}
}
_processNextRequest() {
const userId = Object.keys(this._pendingKeyRequests)[0];
if (!userId) {
return;
}
const deviceId = Object.keys(this._pendingKeyRequests[userId])[0];
if (!deviceId) {
return;
}
console.log(`Starting KeyShareDialog for ${userId}:${deviceId}`);
const finished = (r) => {
this._currentUser = null;
this._currentDevice = null;
if (!this._pendingKeyRequests[userId] || !this._pendingKeyRequests[userId][deviceId]) {
// request was removed in the time the dialog was displayed
this._processNextRequest();
return;
}
if (r) {
for (const req of this._pendingKeyRequests[userId][deviceId]) {
req.share();
}
}
delete this._pendingKeyRequests[userId][deviceId];
if (Object.keys(this._pendingKeyRequests[userId]).length === 0) {
delete this._pendingKeyRequests[userId];
}
this._processNextRequest();
};
const KeyShareDialog = sdk.getComponent("dialogs.KeyShareDialog");
Modal.appendTrackedDialog('Key Share', 'Process Next Request', KeyShareDialog, {
matrixClient: this._matrixClient,
userId: userId,
deviceId: deviceId,
onFinished: finished,
});
this._currentUser = userId;
this._currentDevice = deviceId;
}
}

View file

@ -43,6 +43,8 @@ export const Key = {
BACKTICK: "`",
SPACE: " ",
SLASH: "/",
SQUARE_BRACKET_LEFT: "[",
SQUARE_BRACKET_RIGHT: "]",
A: "a",
B: "b",
C: "c",

View file

@ -17,19 +17,22 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import Matrix from 'matrix-js-sdk';
import { createClient } from 'matrix-js-sdk/src/matrix';
import { InvalidStoreError } from "matrix-js-sdk/src/errors";
import { MatrixClient } from "matrix-js-sdk/src/client";
import { decryptAES, encryptAES, IEncryptedPayload } from "matrix-js-sdk/src/crypto/aes";
import {MatrixClientPeg} from './MatrixClientPeg';
import { IMatrixClientCreds, MatrixClientPeg } from './MatrixClientPeg';
import SecurityCustomisations from "./customisations/Security";
import EventIndexPeg from './indexing/EventIndexPeg';
import createMatrixClient from './utils/createMatrixClient';
import Analytics from './Analytics';
import Notifier from './Notifier';
import UserActivity from './UserActivity';
import Presence from './Presence';
import dis from './dispatcher';
import dis from './dispatcher/dispatcher';
import DMRoomMap from './utils/DMRoomMap';
import Modal from './Modal';
import * as sdk from './index';
import ActiveWidgetStore from './stores/ActiveWidgetStore';
import PlatformPeg from "./PlatformPeg";
import { sendLoginRequest } from "./Login";
@ -37,49 +40,65 @@ import * as StorageManager from './utils/StorageManager';
import SettingsStore from "./settings/SettingsStore";
import TypingStore from "./stores/TypingStore";
import ToastStore from "./stores/ToastStore";
import {IntegrationManagers} from "./integrations/IntegrationManagers";
import {Mjolnir} from "./mjolnir/Mjolnir";
import { IntegrationManagers } from "./integrations/IntegrationManagers";
import { Mjolnir } from "./mjolnir/Mjolnir";
import DeviceListener from "./DeviceListener";
import {Jitsi} from "./widgets/Jitsi";
import { Jitsi } from "./widgets/Jitsi";
import { SSO_HOMESERVER_URL_KEY, SSO_ID_SERVER_URL_KEY, SSO_IDP_ID_KEY } from "./BasePlatform";
import ThreepidInviteStore from "./stores/ThreepidInviteStore";
import CountlyAnalytics from "./CountlyAnalytics";
import CallHandler from './CallHandler';
import LifecycleCustomisations from "./customisations/Lifecycle";
import ErrorDialog from "./components/views/dialogs/ErrorDialog";
import { _t } from "./languageHandler";
import LazyLoadingResyncDialog from "./components/views/dialogs/LazyLoadingResyncDialog";
import LazyLoadingDisabledDialog from "./components/views/dialogs/LazyLoadingDisabledDialog";
import SessionRestoreErrorDialog from "./components/views/dialogs/SessionRestoreErrorDialog";
import StorageEvictedDialog from "./components/views/dialogs/StorageEvictedDialog";
const HOMESERVER_URL_KEY = "mx_hs_url";
const ID_SERVER_URL_KEY = "mx_is_url";
interface ILoadSessionOpts {
enableGuest?: boolean;
guestHsUrl?: string;
guestIsUrl?: string;
ignoreGuest?: boolean;
defaultDeviceDisplayName?: string;
fragmentQueryParams?: Record<string, string>;
}
/**
* Called at startup, to attempt to build a logged-in Matrix session. It tries
* a number of things:
*
*
* 1. if we have a guest access token in the fragment query params, it uses
* that.
*
* 2. if an access token is stored in local storage (from a previous session),
* it uses that.
*
* 3. it attempts to auto-register as a guest user.
*
* If any of steps 1-4 are successful, it will call {_doSetLoggedIn}, which in
* turn will raise on_logged_in and will_start_client events.
*
* @param {object} opts
*
* @param {object} opts.fragmentQueryParams: string->string map of the
* @param {object} [opts]
* @param {object} [opts.fragmentQueryParams]: string->string map of the
* query-parameters extracted from the #-fragment of the starting URI.
*
* @param {boolean} opts.enableGuest: set to true to enable guest access tokens
* and auto-guest registrations.
*
* @params {string} opts.guestHsUrl: homeserver URL. Only used if enableGuest is
* true; defines the HS to register against.
*
* @params {string} opts.guestIsUrl: homeserver URL. Only used if enableGuest is
* true; defines the IS to use.
*
* @params {bool} opts.ignoreGuest: If the stored session is a guest account, ignore
* it and don't load it.
*
* @param {boolean} [opts.enableGuest]: set to true to enable guest access
* tokens and auto-guest registrations.
* @param {string} [opts.guestHsUrl]: homeserver URL. Only used if enableGuest
* is true; defines the HS to register against.
* @param {string} [opts.guestIsUrl]: homeserver URL. Only used if enableGuest
* is true; defines the IS to use.
* @param {bool} [opts.ignoreGuest]: If the stored session is a guest account,
* ignore it and don't load it.
* @param {string} [opts.defaultDeviceDisplayName]: Default display name to use
* when registering as a guest.
* @returns {Promise} a promise which resolves when the above process completes.
* Resolves to `true` if we ended up starting a session, or `false` if we
* failed.
*/
export async function loadSession(opts) {
export async function loadSession(opts: ILoadSessionOpts = {}): Promise<boolean> {
try {
let enableGuest = opts.enableGuest || false;
const guestHsUrl = opts.guestHsUrl;
@ -92,12 +111,13 @@ export async function loadSession(opts) {
enableGuest = false;
}
if (enableGuest &&
if (
enableGuest &&
fragmentQueryParams.guest_user_id &&
fragmentQueryParams.guest_access_token
) {
) {
console.log("Using guest access credentials");
return _doSetLoggedIn({
return doSetLoggedIn({
userId: fragmentQueryParams.guest_user_id,
accessToken: fragmentQueryParams.guest_access_token,
homeserverUrl: guestHsUrl,
@ -105,7 +125,7 @@ export async function loadSession(opts) {
guest: true,
}, true).then(() => true);
}
const success = await _restoreFromLocalStorage({
const success = await restoreFromLocalStorage({
ignoreGuest: Boolean(opts.ignoreGuest),
});
if (success) {
@ -113,7 +133,7 @@ export async function loadSession(opts) {
}
if (enableGuest) {
return _registerAsGuest(guestHsUrl, guestIsUrl, defaultDeviceDisplayName);
return registerAsGuest(guestHsUrl, guestIsUrl, defaultDeviceDisplayName);
}
// fall back to welcome screen
@ -124,7 +144,7 @@ export async function loadSession(opts) {
// need to show the general failure dialog. Instead, just go back to welcome.
return false;
}
return _handleLoadSessionFailure(e);
return handleLoadSessionFailure(e);
}
}
@ -132,20 +152,13 @@ export async function loadSession(opts) {
* Gets the user ID of the persisted session, if one exists. This does not validate
* that the user's credentials still work, just that they exist and that a user ID
* is associated with them. The session is not loaded.
* @returns {String} The persisted session's owner, if an owner exists. Null otherwise.
* @returns {[String, bool]} The persisted session's owner and whether the stored
* session is for a guest user, if an owner exists. If there is no stored session,
* return [null, null].
*/
export function getStoredSessionOwner() {
const {hsUrl, userId, accessToken} = getLocalStorageSessionVars();
return hsUrl && userId && accessToken ? userId : null;
}
/**
* @returns {bool} True if the stored session is for a guest user or false if it is
* for a real user. If there is no stored session, return null.
*/
export function getStoredSessionIsGuest() {
const sessVars = getLocalStorageSessionVars();
return sessVars.hsUrl && sessVars.userId && sessVars.accessToken ? sessVars.isGuest : null;
export async function getStoredSessionOwner(): Promise<[string, boolean]> {
const { hsUrl, userId, hasAccessToken, isGuest } = await getStoredSessionVars();
return hsUrl && userId && hasAccessToken ? [userId, isGuest] : [null, null];
}
/**
@ -153,48 +166,81 @@ export function getStoredSessionIsGuest() {
* query-parameters extracted from the real query-string of the starting
* URI.
*
* @param {String} defaultDeviceDisplayName
* @param {string} defaultDeviceDisplayName
* @param {string} fragmentAfterLogin path to go to after a successful login, only used for "Try again"
*
* @returns {Promise} promise which resolves to true if we completed the token
* login, else false
*/
export function attemptTokenLogin(queryParams, defaultDeviceDisplayName) {
export function attemptTokenLogin(
queryParams: Record<string, string>,
defaultDeviceDisplayName?: string,
fragmentAfterLogin?: string,
): Promise<boolean> {
if (!queryParams.loginToken) {
return Promise.resolve(false);
}
if (!queryParams.homeserver) {
const homeserver = localStorage.getItem(SSO_HOMESERVER_URL_KEY);
const identityServer = localStorage.getItem(SSO_ID_SERVER_URL_KEY);
if (!homeserver) {
console.warn("Cannot log in with token: can't determine HS URL to use");
Modal.createTrackedDialog("SSO", "Unknown HS", ErrorDialog, {
title: _t("We couldn't log you in"),
description: _t("We asked the browser to remember which homeserver you use to let you sign in, " +
"but unfortunately your browser has forgotten it. Go to the sign in page and try again."),
button: _t("Try again"),
});
return Promise.resolve(false);
}
return sendLoginRequest(
queryParams.homeserver,
queryParams.identityServer,
homeserver,
identityServer,
"m.login.token", {
token: queryParams.loginToken,
initial_device_display_name: defaultDeviceDisplayName,
},
).then(function(creds) {
console.log("Logged in with token");
return _clearStorage().then(() => {
_persistCredentialsToLocalStorage(creds);
return clearStorage().then(async () => {
await persistCredentials(creds);
// remember that we just logged in
sessionStorage.setItem("mx_fresh_login", String(true));
return true;
});
}).catch((err) => {
console.error("Failed to log in with login token: " + err + " " +
err.data);
Modal.createTrackedDialog("SSO", "Token Rejected", ErrorDialog, {
title: _t("We couldn't log you in"),
description: err.name === "ConnectionError"
? _t("Your homeserver was unreachable and was not able to log you in. Please try again. " +
"If this continues, please contact your homeserver administrator.")
: _t("Your homeserver rejected your log in attempt. " +
"This could be due to things just taking too long. Please try again. " +
"If this continues, please contact your homeserver administrator."),
button: _t("Try again"),
onFinished: tryAgain => {
if (tryAgain) {
const cli = createClient({
baseUrl: homeserver,
idBaseUrl: identityServer,
});
const idpId = localStorage.getItem(SSO_IDP_ID_KEY) || undefined;
PlatformPeg.get().startSingleSignOn(cli, "sso", fragmentAfterLogin, idpId);
}
},
});
console.error("Failed to log in with login token:");
console.error(err);
return false;
});
}
export function handleInvalidStoreError(e) {
if (e.reason === Matrix.InvalidStoreError.TOGGLED_LAZY_LOADING) {
export function handleInvalidStoreError(e: InvalidStoreError): Promise<void> {
if (e.reason === InvalidStoreError.TOGGLED_LAZY_LOADING) {
return Promise.resolve().then(() => {
const lazyLoadEnabled = e.value;
if (lazyLoadEnabled) {
const LazyLoadingResyncDialog =
sdk.getComponent("views.dialogs.LazyLoadingResyncDialog");
return new Promise((resolve) => {
Modal.createDialog(LazyLoadingResyncDialog, {
onFinished: resolve,
@ -205,8 +251,6 @@ export function handleInvalidStoreError(e) {
// between LL/non-LL version on same host.
// as disabling LL when previously enabled
// is a strong indicator of this (/develop & /app)
const LazyLoadingDisabledDialog =
sdk.getComponent("views.dialogs.LazyLoadingDisabledDialog");
return new Promise((resolve) => {
Modal.createDialog(LazyLoadingDisabledDialog, {
onFinished: resolve,
@ -222,11 +266,15 @@ export function handleInvalidStoreError(e) {
}
}
function _registerAsGuest(hsUrl, isUrl, defaultDeviceDisplayName) {
function registerAsGuest(
hsUrl: string,
isUrl: string,
defaultDeviceDisplayName: string,
): Promise<boolean> {
console.log(`Doing guest login on ${hsUrl}`);
// create a temporary MatrixClient to do the login
const client = Matrix.createClient({
const client = createClient({
baseUrl: hsUrl,
});
@ -236,7 +284,7 @@ function _registerAsGuest(hsUrl, isUrl, defaultDeviceDisplayName) {
},
}).then((creds) => {
console.log(`Registered as guest: ${creds.user_id}`);
return _doSetLoggedIn({
return doSetLoggedIn({
userId: creds.user_id,
deviceId: creds.device_id,
accessToken: creds.access_token,
@ -250,15 +298,42 @@ function _registerAsGuest(hsUrl, isUrl, defaultDeviceDisplayName) {
});
}
export interface IStoredSession {
hsUrl: string;
isUrl: string;
hasAccessToken: boolean;
accessToken: string | IEncryptedPayload;
userId: string;
deviceId: string;
isGuest: boolean;
}
/**
* Retrieves information about the stored session in localstorage. The session
* Retrieves information about the stored session from the browser's storage. The session
* may not be valid, as it is not tested for consistency here.
* @returns {Object} Information about the session - see implementation for variables.
*/
export function getLocalStorageSessionVars() {
const hsUrl = localStorage.getItem("mx_hs_url");
const isUrl = localStorage.getItem("mx_is_url");
const accessToken = localStorage.getItem("mx_access_token");
export async function getStoredSessionVars(): Promise<IStoredSession> {
const hsUrl = localStorage.getItem(HOMESERVER_URL_KEY);
const isUrl = localStorage.getItem(ID_SERVER_URL_KEY);
let accessToken;
try {
accessToken = await StorageManager.idbLoad("account", "mx_access_token");
} catch (e) {}
if (!accessToken) {
accessToken = localStorage.getItem("mx_access_token");
if (accessToken) {
try {
// try to migrate access token to IndexedDB if we can
await StorageManager.idbSave("account", "mx_access_token", accessToken);
localStorage.removeItem("mx_access_token");
} catch (e) {}
}
}
// if we pre-date storing "mx_has_access_token", but we retrieved an access
// token, then we should say we have an access token
const hasAccessToken =
(localStorage.getItem("mx_has_access_token") === "true") || !!accessToken;
const userId = localStorage.getItem("mx_user_id");
const deviceId = localStorage.getItem("mx_device_id");
@ -270,7 +345,43 @@ export function getLocalStorageSessionVars() {
isGuest = localStorage.getItem("matrix-is-guest") === "true";
}
return {hsUrl, isUrl, accessToken, userId, deviceId, isGuest};
return { hsUrl, isUrl, hasAccessToken, accessToken, userId, deviceId, isGuest };
}
// The pickle key is a string of unspecified length and format. For AES, we
// need a 256-bit Uint8Array. So we HKDF the pickle key to generate the AES
// key. The AES key should be zeroed after it is used.
async function pickleKeyToAesKey(pickleKey: string): Promise<Uint8Array> {
const pickleKeyBuffer = new Uint8Array(pickleKey.length);
for (let i = 0; i < pickleKey.length; i++) {
pickleKeyBuffer[i] = pickleKey.charCodeAt(i);
}
const hkdfKey = await window.crypto.subtle.importKey(
"raw", pickleKeyBuffer, "HKDF", false, ["deriveBits"],
);
pickleKeyBuffer.fill(0);
return new Uint8Array(await window.crypto.subtle.deriveBits(
{
name: "HKDF", hash: "SHA-256",
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore: https://github.com/microsoft/TypeScript-DOM-lib-generator/pull/879
salt: new Uint8Array(32), info: new Uint8Array(0),
},
hkdfKey,
256,
));
}
async function abortLogin() {
const signOut = await showStorageEvictedDialog();
if (signOut) {
await clearStorage();
// This error feels a bit clunky, but we want to make sure we don't go any
// further and instead head back to sign in.
throw new AbortLoginAndRebuildStorage(
"Aborting login in progress because of storage inconsistency",
);
}
}
// returns a promise which resolves to true if a session is found in
@ -283,14 +394,18 @@ export function getLocalStorageSessionVars() {
// The plan is to gradually move the localStorage access done here into
// SessionStore to avoid bugs where the view becomes out-of-sync with
// localStorage (e.g. isGuest etc.)
async function _restoreFromLocalStorage(opts) {
const ignoreGuest = opts.ignoreGuest;
export async function restoreFromLocalStorage(opts?: { ignoreGuest?: boolean }): Promise<boolean> {
const ignoreGuest = opts?.ignoreGuest;
if (!localStorage) {
return false;
}
const {hsUrl, isUrl, accessToken, userId, deviceId, isGuest} = getLocalStorageSessionVars();
const { hsUrl, isUrl, hasAccessToken, accessToken, userId, deviceId, isGuest } = await getStoredSessionVars();
if (hasAccessToken && !accessToken) {
abortLogin();
}
if (accessToken && userId && hsUrl) {
if (ignoreGuest && isGuest) {
@ -298,14 +413,32 @@ async function _restoreFromLocalStorage(opts) {
return false;
}
let decryptedAccessToken = accessToken;
const pickleKey = await PlatformPeg.get().getPickleKey(userId, deviceId);
if (pickleKey) {
console.log("Got pickle key");
if (typeof accessToken !== "string") {
const encrKey = await pickleKeyToAesKey(pickleKey);
decryptedAccessToken = await decryptAES(accessToken, encrKey, "access_token");
encrKey.fill(0);
}
} else {
console.log("No pickle key available");
}
const freshLogin = sessionStorage.getItem("mx_fresh_login") === "true";
sessionStorage.removeItem("mx_fresh_login");
console.log(`Restoring session for ${userId}`);
await _doSetLoggedIn({
await doSetLoggedIn({
userId: userId,
deviceId: deviceId,
accessToken: accessToken,
accessToken: decryptedAccessToken as string,
homeserverUrl: hsUrl,
identityServerUrl: isUrl,
guest: isGuest,
pickleKey: pickleKey,
freshLogin: freshLogin,
}, false);
return true;
} else {
@ -314,12 +447,9 @@ async function _restoreFromLocalStorage(opts) {
}
}
async function _handleLoadSessionFailure(e) {
async function handleLoadSessionFailure(e: Error): Promise<boolean> {
console.error("Unable to load session", e);
const SessionRestoreErrorDialog =
sdk.getComponent('views.dialogs.SessionRestoreErrorDialog');
const modal = Modal.createTrackedDialog('Session Restore Error', '', SessionRestoreErrorDialog, {
error: e.message,
});
@ -327,7 +457,7 @@ async function _handleLoadSessionFailure(e) {
const [success] = await modal.finished;
if (success) {
// user clicked continue.
await _clearStorage();
await clearStorage();
return false;
}
@ -348,9 +478,20 @@ async function _handleLoadSessionFailure(e) {
*
* @returns {Promise} promise which resolves to the new MatrixClient once it has been started
*/
export function setLoggedIn(credentials) {
export async function setLoggedIn(credentials: IMatrixClientCreds): Promise<MatrixClient> {
credentials.freshLogin = true;
stopMatrixClient();
return _doSetLoggedIn(credentials, true);
const pickleKey = credentials.userId && credentials.deviceId
? await PlatformPeg.get().createPickleKey(credentials.userId, credentials.deviceId)
: null;
if (pickleKey) {
console.log("Created pickle key");
} else {
console.log("Pickle key not created");
}
return doSetLoggedIn(Object.assign({}, credentials, { pickleKey }), true);
}
/**
@ -368,7 +509,7 @@ export function setLoggedIn(credentials) {
*
* @returns {Promise} promise which resolves to the new MatrixClient once it has been started
*/
export function hydrateSession(credentials) {
export function hydrateSession(credentials: IMatrixClientCreds): Promise<MatrixClient> {
const oldUserId = MatrixClientPeg.get().getUserId();
const oldDeviceId = MatrixClientPeg.get().getDeviceId();
@ -381,7 +522,7 @@ export function hydrateSession(credentials) {
console.warn("Clearing all data: Old session belongs to a different user/session");
}
return _doSetLoggedIn(credentials, overwrite);
return doSetLoggedIn(credentials, overwrite);
}
/**
@ -393,7 +534,10 @@ export function hydrateSession(credentials) {
*
* @returns {Promise} promise which resolves to the new MatrixClient once it has been started
*/
async function _doSetLoggedIn(credentials, clearStorage) {
async function doSetLoggedIn(
credentials: IMatrixClientCreds,
clearStorageEnabled: boolean,
): Promise<MatrixClient> {
credentials.guest = Boolean(credentials.guest);
const softLogout = isSoftLogout();
@ -404,6 +548,7 @@ async function _doSetLoggedIn(credentials, clearStorage) {
" guest: " + credentials.guest +
" hs: " + credentials.homeserverUrl +
" softLogout: " + softLogout,
" freshLogin: " + credentials.freshLogin,
);
// This is dispatched to indicate that the user is still in the process of logging in
@ -413,10 +558,10 @@ async function _doSetLoggedIn(credentials, clearStorage) {
//
// we fire it *synchronously* to make sure it fires before on_logged_in.
// (dis.dispatch uses `setTimeout`, which does not guarantee ordering.)
dis.dispatch({action: 'on_logging_in'}, true);
dis.dispatch({ action: 'on_logging_in' }, true);
if (clearStorage) {
await _clearStorage();
if (clearStorageEnabled) {
await clearStorage();
}
const results = await StorageManager.checkConsistency();
@ -424,32 +569,31 @@ async function _doSetLoggedIn(credentials, clearStorage) {
// crypto store, we'll be generally confused when handling encrypted data.
// Show a modal recommending a full reset of storage.
if (results.dataInLocalStorage && results.cryptoInited && !results.dataInCryptoStore) {
const signOut = await _showStorageEvictedDialog();
if (signOut) {
await _clearStorage();
// This error feels a bit clunky, but we want to make sure we don't go any
// further and instead head back to sign in.
throw new AbortLoginAndRebuildStorage(
"Aborting login in progress because of storage inconsistency",
);
}
await abortLogin();
}
Analytics.setLoggedIn(credentials.guest, credentials.homeserverUrl);
MatrixClientPeg.replaceUsingCreds(credentials);
const client = MatrixClientPeg.get();
if (credentials.freshLogin && SettingsStore.getValue("feature_dehydration")) {
// If we just logged in, try to rehydrate a device instead of using a
// new device. If it succeeds, we'll get a new device ID, so make sure
// we persist that ID to localStorage
const newDeviceId = await client.rehydrateDevice();
if (newDeviceId) {
credentials.deviceId = newDeviceId;
}
delete credentials.freshLogin;
}
if (localStorage) {
try {
_persistCredentialsToLocalStorage(credentials);
// The user registered as a PWLU (PassWord-Less User), the generated password
// is cached here such that the user can change it at a later time.
if (credentials.password) {
// Update SessionStore
dis.dispatch({
action: 'cached_password',
cachedPassword: credentials.password,
});
}
await persistCredentials(credentials);
// make sure we don't think that it's a fresh login any more
sessionStorage.removeItem("mx_fresh_login");
} catch (e) {
console.warn("Error using local storage: can't persist session!", e);
}
@ -457,16 +601,13 @@ async function _doSetLoggedIn(credentials, clearStorage) {
console.warn("No local storage available: can't persist session!");
}
MatrixClientPeg.replaceUsingCreds(credentials);
dis.dispatch({ action: 'on_logged_in' });
await startMatrixClient(/*startSyncing=*/!softLogout);
return MatrixClientPeg.get();
return client;
}
function _showStorageEvictedDialog() {
const StorageEvictedDialog = sdk.getComponent('views.dialogs.StorageEvictedDialog');
function showStorageEvictedDialog(): Promise<boolean> {
return new Promise(resolve => {
Modal.createTrackedDialog('Storage evicted', '', StorageEvictedDialog, {
onFinished: resolve,
@ -478,15 +619,60 @@ function _showStorageEvictedDialog() {
// `instanceof`. Babel 7 supports this natively in their class handling.
class AbortLoginAndRebuildStorage extends Error { }
function _persistCredentialsToLocalStorage(credentials) {
localStorage.setItem("mx_hs_url", credentials.homeserverUrl);
async function persistCredentials(credentials: IMatrixClientCreds): Promise<void> {
localStorage.setItem(HOMESERVER_URL_KEY, credentials.homeserverUrl);
if (credentials.identityServerUrl) {
localStorage.setItem("mx_is_url", credentials.identityServerUrl);
localStorage.setItem(ID_SERVER_URL_KEY, credentials.identityServerUrl);
}
localStorage.setItem("mx_user_id", credentials.userId);
localStorage.setItem("mx_access_token", credentials.accessToken);
localStorage.setItem("mx_is_guest", JSON.stringify(credentials.guest));
// store whether we expect to find an access token, to detect the case
// where IndexedDB is blown away
if (credentials.accessToken) {
localStorage.setItem("mx_has_access_token", "true");
} else {
localStorage.deleteItem("mx_has_access_token");
}
if (credentials.pickleKey) {
let encryptedAccessToken;
try {
// try to encrypt the access token using the pickle key
const encrKey = await pickleKeyToAesKey(credentials.pickleKey);
encryptedAccessToken = await encryptAES(credentials.accessToken, encrKey, "access_token");
encrKey.fill(0);
} catch (e) {
console.warn("Could not encrypt access token", e);
}
try {
// save either the encrypted access token, or the plain access
// token if we were unable to encrypt (e.g. if the browser doesn't
// have WebCrypto).
await StorageManager.idbSave(
"account", "mx_access_token",
encryptedAccessToken || credentials.accessToken,
);
} catch (e) {
// if we couldn't save to indexedDB, fall back to localStorage. We
// store the access token unencrypted since localStorage only saves
// strings.
localStorage.setItem("mx_access_token", credentials.accessToken);
}
localStorage.setItem("mx_has_pickle_key", String(true));
} else {
try {
await StorageManager.idbSave(
"account", "mx_access_token", credentials.accessToken,
);
} catch (e) {
localStorage.setItem("mx_access_token", credentials.accessToken);
}
if (localStorage.getItem("mx_has_pickle_key")) {
console.error("Expected a pickle key, but none provided. Encryption may not work.");
}
}
// if we didn't get a deviceId from the login, leave mx_device_id unset,
// rather than setting it to "undefined".
//
@ -496,6 +682,8 @@ function _persistCredentialsToLocalStorage(credentials) {
localStorage.setItem("mx_device_id", credentials.deviceId);
}
SecurityCustomisations.persistCredentials?.(credentials);
console.log(`Session persisted for ${credentials.userId}`);
}
@ -504,19 +692,25 @@ let _isLoggingOut = false;
/**
* Logs the current session out and transitions to the logged-out state
*/
export function logout() {
export function logout(): void {
if (!MatrixClientPeg.get()) return;
if (!CountlyAnalytics.instance.disabled) {
// user has logged out, fall back to anonymous
CountlyAnalytics.instance.enable(/* anonymous = */ true);
}
if (MatrixClientPeg.get().isGuest()) {
// logout doesn't work for guest sessions
// Also we sometimes want to re-log in a guest session
// if we abort the login
onLoggedOut();
// Also we sometimes want to re-log in a guest session if we abort the login.
// defer until next tick because it calls a synchronous dispatch and we are likely here from a dispatch.
setImmediate(() => onLoggedOut());
return;
}
_isLoggingOut = true;
MatrixClientPeg.get().logout().then(onLoggedOut,
const client = MatrixClientPeg.get();
PlatformPeg.get().destroyPickleKey(client.getUserId(), client.getDeviceId());
client.logout().then(onLoggedOut,
(err) => {
// Just throwing an error here is going to be very unhelpful
// if you're trying to log out because your server's down and
@ -531,7 +725,7 @@ export function logout() {
);
}
export function softLogout() {
export function softLogout(): void {
if (!MatrixClientPeg.get()) return;
// Track that we've detected and trapped a soft logout. This helps prevent other
@ -546,17 +740,17 @@ export function softLogout() {
// Ensure that we dispatch a view change **before** stopping the client so
// so that React components unmount first. This avoids React soft crashes
// that can occur when components try to use a null client.
dis.dispatch({action: 'on_client_not_viable'}); // generic version of on_logged_out
dis.dispatch({ action: 'on_client_not_viable' }); // generic version of on_logged_out
stopMatrixClient(/*unsetClient=*/false);
// DO NOT CALL LOGOUT. A soft logout preserves data, logout does not.
}
export function isSoftLogout() {
export function isSoftLogout(): boolean {
return localStorage.getItem("mx_soft_logout") === "true";
}
export function isLoggingOut() {
export function isLoggingOut(): boolean {
return _isLoggingOut;
}
@ -566,22 +760,25 @@ export function isLoggingOut() {
* @param {boolean} startSyncing True (default) to actually start
* syncing the client.
*/
async function startMatrixClient(startSyncing=true) {
async function startMatrixClient(startSyncing = true): Promise<void> {
console.log(`Lifecycle: Starting MatrixClient`);
// dispatch this before starting the matrix client: it's used
// to add listeners for the 'sync' event so otherwise we'd have
// a race condition (and we need to dispatch synchronously for this
// to work).
dis.dispatch({action: 'will_start_client'}, true);
dis.dispatch({ action: 'will_start_client' }, true);
// reset things first just in case
TypingStore.sharedInstance().reset();
ToastStore.sharedInstance().reset();
Notifier.start();
UserActivity.sharedInstance().start();
TypingStore.sharedInstance().reset(); // just in case
ToastStore.sharedInstance().reset();
DMRoomMap.makeShared().start();
IntegrationManagers.sharedInstance().startWatching();
ActiveWidgetStore.start();
CallHandler.sharedInstance().start();
// Start Mjolnir even though we haven't checked the feature flag yet. Starting
// the thing just wastes CPU cycles, but should result in no actual functionality
@ -608,11 +805,11 @@ async function startMatrixClient(startSyncing=true) {
}
// Now that we have a MatrixClientPeg, update the Jitsi info
await Jitsi.getInstance().update();
await Jitsi.getInstance().start();
// dispatch that we finished starting up to wire up any other bits
// of the matrix client that cannot be set prior to starting up.
dis.dispatch({action: 'client_started'});
dis.dispatch({ action: 'client_started' });
if (isSoftLogout()) {
softLogout();
@ -623,24 +820,42 @@ async function startMatrixClient(startSyncing=true) {
* Stops a running client and all related services, and clears persistent
* storage. Used after a session has been logged out.
*/
export async function onLoggedOut() {
export async function onLoggedOut(): Promise<void> {
_isLoggingOut = false;
// Ensure that we dispatch a view change **before** stopping the client so
// so that React components unmount first. This avoids React soft crashes
// that can occur when components try to use a null client.
dis.dispatch({action: 'on_logged_out'}, true);
dis.dispatch({ action: 'on_logged_out' }, true);
stopMatrixClient();
await _clearStorage();
await clearStorage({ deleteEverything: true });
LifecycleCustomisations.onLoggedOutAndStorageCleared?.();
}
/**
* @param {object} opts Options for how to clear storage.
* @returns {Promise} promise which resolves once the stores have been cleared
*/
async function _clearStorage() {
async function clearStorage(opts?: { deleteEverything?: boolean }): Promise<void> {
Analytics.disable();
if (window.localStorage) {
// try to save any 3pid invites from being obliterated
const pendingInvites = ThreepidInviteStore.instance.getWireInvites();
window.localStorage.clear();
try {
await StorageManager.idbDelete("account", "mx_access_token");
} catch (e) {}
// now restore those invites
if (!opts?.deleteEverything) {
pendingInvites.forEach(i => {
const roomId = i.roomId;
delete i.roomId; // delete to avoid confusing the store
ThreepidInviteStore.instance.storeInvite(roomId, i);
});
}
}
if (window.sessionStorage) {
@ -662,8 +877,9 @@ async function _clearStorage() {
* @param {boolean} unsetClient True (default) to abandon the client
* on MatrixClientPeg after stopping.
*/
export function stopMatrixClient(unsetClient=true) {
export function stopMatrixClient(unsetClient = true): void {
Notifier.stop();
CallHandler.sharedInstance().stop();
UserActivity.sharedInstance().stop();
TypingStore.sharedInstance().reset();
Presence.stop();

55
src/Livestream.ts Normal file
View file

@ -0,0 +1,55 @@
/*
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 { ClientWidgetApi } from "matrix-widget-api";
import { MatrixClientPeg } from "./MatrixClientPeg";
import SdkConfig from "./SdkConfig";
import { ElementWidgetActions } from "./stores/widgets/ElementWidgetActions";
export function getConfigLivestreamUrl() {
return SdkConfig.get()["audioStreamUrl"];
}
// Dummy rtmp URL used to signal that we want a special audio-only stream
const AUDIOSTREAM_DUMMY_URL = 'rtmp://audiostream.dummy/';
async function createLiveStream(roomId: string) {
const openIdToken = await MatrixClientPeg.get().getOpenIdToken();
const url = getConfigLivestreamUrl() + "/createStream";
const response = await window.fetch(url, {
method: 'POST',
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
room_id: roomId,
openid_token: openIdToken,
}),
});
const respBody = await response.json();
return respBody['stream_id'];
}
export async function startJitsiAudioLivestream(widgetMessaging: ClientWidgetApi, roomId: string) {
const streamId = await createLiveStream(roomId);
await widgetMessaging.transport.send(ElementWidgetActions.StartLiveStream, {
rtmpStreamKey: AUDIOSTREAM_DUMMY_URL + streamId,
});
}

View file

@ -1,187 +0,0 @@
/*
Copyright 2015, 2016 OpenMarket Ltd
Copyright 2017 Vector Creations Ltd
Copyright 2018 New Vector Ltd
Copyright 2019 Michael Telatynski <7t3chguy@gmail.com>
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 Matrix from "matrix-js-sdk";
export default class Login {
constructor(hsUrl, isUrl, fallbackHsUrl, opts) {
this._hsUrl = hsUrl;
this._isUrl = isUrl;
this._fallbackHsUrl = fallbackHsUrl;
this._currentFlowIndex = 0;
this._flows = [];
this._defaultDeviceDisplayName = opts.defaultDeviceDisplayName;
this._tempClient = null; // memoize
}
getHomeserverUrl() {
return this._hsUrl;
}
getIdentityServerUrl() {
return this._isUrl;
}
setHomeserverUrl(hsUrl) {
this._tempClient = null; // clear memoization
this._hsUrl = hsUrl;
}
setIdentityServerUrl(isUrl) {
this._tempClient = null; // clear memoization
this._isUrl = isUrl;
}
/**
* Get a temporary MatrixClient, which can be used for login or register
* requests.
* @returns {MatrixClient}
*/
createTemporaryClient() {
if (this._tempClient) return this._tempClient; // use memoization
return this._tempClient = Matrix.createClient({
baseUrl: this._hsUrl,
idBaseUrl: this._isUrl,
});
}
getFlows() {
const self = this;
const client = this.createTemporaryClient();
return client.loginFlows().then(function(result) {
self._flows = result.flows;
self._currentFlowIndex = 0;
// technically the UI should display options for all flows for the
// user to then choose one, so return all the flows here.
return self._flows;
});
}
chooseFlow(flowIndex) {
this._currentFlowIndex = flowIndex;
}
getCurrentFlowStep() {
// technically the flow can have multiple steps, but no one does this
// for login so we can ignore it.
const flowStep = this._flows[this._currentFlowIndex];
return flowStep ? flowStep.type : null;
}
loginViaPassword(username, phoneCountry, phoneNumber, pass) {
const self = this;
const isEmail = username.indexOf("@") > 0;
let identifier;
if (phoneCountry && phoneNumber) {
identifier = {
type: 'm.id.phone',
country: phoneCountry,
number: phoneNumber,
};
} else if (isEmail) {
identifier = {
type: 'm.id.thirdparty',
medium: 'email',
address: username,
};
} else {
identifier = {
type: 'm.id.user',
user: username,
};
}
const loginParams = {
password: pass,
identifier: identifier,
initial_device_display_name: this._defaultDeviceDisplayName,
};
const tryFallbackHs = (originalError) => {
return sendLoginRequest(
self._fallbackHsUrl, this._isUrl, 'm.login.password', loginParams,
).catch((fallbackError) => {
console.log("fallback HS login failed", fallbackError);
// throw the original error
throw originalError;
});
};
let originalLoginError = null;
return sendLoginRequest(
self._hsUrl, self._isUrl, 'm.login.password', loginParams,
).catch((error) => {
originalLoginError = error;
if (error.httpStatus === 403) {
if (self._fallbackHsUrl) {
return tryFallbackHs(originalLoginError);
}
}
throw originalLoginError;
}).catch((error) => {
console.log("Login failed", error);
throw error;
});
}
}
/**
* Send a login request to the given server, and format the response
* as a MatrixClientCreds
*
* @param {string} hsUrl the base url of the Homeserver used to log in.
* @param {string} isUrl the base url of the default identity server
* @param {string} loginType the type of login to do
* @param {object} loginParams the parameters for the login
*
* @returns {MatrixClientCreds}
*/
export async function sendLoginRequest(hsUrl, isUrl, loginType, loginParams) {
const client = Matrix.createClient({
baseUrl: hsUrl,
idBaseUrl: isUrl,
});
const data = await client.login(loginType, loginParams);
const wellknown = data.well_known;
if (wellknown) {
if (wellknown["m.homeserver"] && wellknown["m.homeserver"]["base_url"]) {
hsUrl = wellknown["m.homeserver"]["base_url"];
console.log(`Overrode homeserver setting with ${hsUrl} from login response`);
}
if (wellknown["m.identity_server"] && wellknown["m.identity_server"]["base_url"]) {
// TODO: should we prompt here?
isUrl = wellknown["m.identity_server"]["base_url"];
console.log(`Overrode IS setting with ${isUrl} from login response`);
}
}
return {
homeserverUrl: hsUrl,
identityServerUrl: isUrl,
userId: data.user_id,
deviceId: data.device_id,
accessToken: data.access_token,
};
}

241
src/Login.ts Normal file
View file

@ -0,0 +1,241 @@
/*
Copyright 2015-2021 The Matrix.org Foundation C.I.C.
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.
*/
// @ts-ignore - XXX: tsc doesn't like this: our js-sdk imports are complex so this isn't surprising
import { createClient } from "matrix-js-sdk/src/matrix";
import { MatrixClient } from "matrix-js-sdk/src/client";
import { IMatrixClientCreds } from "./MatrixClientPeg";
import SecurityCustomisations from "./customisations/Security";
interface ILoginOptions {
defaultDeviceDisplayName?: string;
}
// TODO: Move this to JS SDK
interface IPasswordFlow {
type: "m.login.password";
}
export enum IdentityProviderBrand {
Gitlab = "gitlab",
Github = "github",
Apple = "apple",
Google = "google",
Facebook = "facebook",
Twitter = "twitter",
}
export interface IIdentityProvider {
id: string;
name: string;
icon?: string;
brand?: IdentityProviderBrand | string;
}
export interface ISSOFlow {
type: "m.login.sso" | "m.login.cas";
// eslint-disable-next-line camelcase
identity_providers: IIdentityProvider[];
}
export type LoginFlow = ISSOFlow | IPasswordFlow;
// TODO: Move this to JS SDK
/* eslint-disable camelcase */
interface ILoginParams {
identifier?: object;
password?: string;
token?: string;
device_id?: string;
initial_device_display_name?: string;
}
/* eslint-enable camelcase */
export default class Login {
private hsUrl: string;
private isUrl: string;
private fallbackHsUrl: string;
// TODO: Flows need a type in JS SDK
private flows: Array<LoginFlow>;
private defaultDeviceDisplayName: string;
private tempClient: MatrixClient;
constructor(
hsUrl: string,
isUrl: string,
fallbackHsUrl?: string,
opts?: ILoginOptions,
) {
this.hsUrl = hsUrl;
this.isUrl = isUrl;
this.fallbackHsUrl = fallbackHsUrl;
this.flows = [];
this.defaultDeviceDisplayName = opts.defaultDeviceDisplayName;
this.tempClient = null; // memoize
}
public getHomeserverUrl(): string {
return this.hsUrl;
}
public getIdentityServerUrl(): string {
return this.isUrl;
}
public setHomeserverUrl(hsUrl: string): void {
this.tempClient = null; // clear memoization
this.hsUrl = hsUrl;
}
public setIdentityServerUrl(isUrl: string): void {
this.tempClient = null; // clear memoization
this.isUrl = isUrl;
}
/**
* Get a temporary MatrixClient, which can be used for login or register
* requests.
* @returns {MatrixClient}
*/
public createTemporaryClient(): MatrixClient {
if (this.tempClient) return this.tempClient; // use memoization
return this.tempClient = createClient({
baseUrl: this.hsUrl,
idBaseUrl: this.isUrl,
});
}
public async getFlows(): Promise<Array<LoginFlow>> {
const client = this.createTemporaryClient();
const { flows } = await client.loginFlows();
this.flows = flows;
return this.flows;
}
public loginViaPassword(
username: string,
phoneCountry: string,
phoneNumber: string,
password: string,
): Promise<IMatrixClientCreds> {
const isEmail = username.indexOf("@") > 0;
let identifier;
if (phoneCountry && phoneNumber) {
identifier = {
type: 'm.id.phone',
country: phoneCountry,
phone: phoneNumber,
// XXX: Synapse historically wanted `number` and not `phone`
number: phoneNumber,
};
} else if (isEmail) {
identifier = {
type: 'm.id.thirdparty',
medium: 'email',
address: username,
};
} else {
identifier = {
type: 'm.id.user',
user: username,
};
}
const loginParams = {
password,
identifier,
initial_device_display_name: this.defaultDeviceDisplayName,
};
const tryFallbackHs = (originalError) => {
return sendLoginRequest(
this.fallbackHsUrl, this.isUrl, 'm.login.password', loginParams,
).catch((fallbackError) => {
console.log("fallback HS login failed", fallbackError);
// throw the original error
throw originalError;
});
};
let originalLoginError = null;
return sendLoginRequest(
this.hsUrl, this.isUrl, 'm.login.password', loginParams,
).catch((error) => {
originalLoginError = error;
if (error.httpStatus === 403) {
if (this.fallbackHsUrl) {
return tryFallbackHs(originalLoginError);
}
}
throw originalLoginError;
}).catch((error) => {
console.log("Login failed", error);
throw error;
});
}
}
/**
* Send a login request to the given server, and format the response
* as a MatrixClientCreds
*
* @param {string} hsUrl the base url of the Homeserver used to log in.
* @param {string} isUrl the base url of the default identity server
* @param {string} loginType the type of login to do
* @param {ILoginParams} loginParams the parameters for the login
*
* @returns {MatrixClientCreds}
*/
export async function sendLoginRequest(
hsUrl: string,
isUrl: string,
loginType: string,
loginParams: ILoginParams,
): Promise<IMatrixClientCreds> {
const client = createClient({
baseUrl: hsUrl,
idBaseUrl: isUrl,
});
const data = await client.login(loginType, loginParams);
const wellknown = data.well_known;
if (wellknown) {
if (wellknown["m.homeserver"] && wellknown["m.homeserver"]["base_url"]) {
hsUrl = wellknown["m.homeserver"]["base_url"];
console.log(`Overrode homeserver setting with ${hsUrl} from login response`);
}
if (wellknown["m.identity_server"] && wellknown["m.identity_server"]["base_url"]) {
// TODO: should we prompt here?
isUrl = wellknown["m.identity_server"]["base_url"];
console.log(`Overrode IS setting with ${isUrl} from login response`);
}
}
const creds: IMatrixClientCreds = {
homeserverUrl: hsUrl,
identityServerUrl: isUrl,
userId: data.user_id,
deviceId: data.device_id,
accessToken: data.access_token,
};
SecurityCustomisations.examineLoginResponse?.(data, creds);
return creds;
}

View file

@ -1,5 +1,6 @@
/*
Copyright 2016 OpenMarket Ltd
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.
@ -14,15 +15,28 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import commonmark from 'commonmark';
import escape from 'lodash/escape';
import * as commonmark from 'commonmark';
import { escape } from "lodash";
const ALLOWED_HTML_TAGS = ['sub', 'sup', 'del', 'u'];
// These types of node are definitely text
const TEXT_NODES = ['text', 'softbreak', 'linebreak', 'paragraph', 'document'];
function is_allowed_html_tag(node) {
// As far as @types/commonmark is concerned, these are not public, so add them
interface CommonmarkHtmlRendererInternal extends commonmark.HtmlRenderer {
paragraph: (node: commonmark.Node, entering: boolean) => void;
link: (node: commonmark.Node, entering: boolean) => void;
html_inline: (node: commonmark.Node) => void; // eslint-disable-line camelcase
html_block: (node: commonmark.Node) => void; // eslint-disable-line camelcase
}
function isAllowedHtmlTag(node: commonmark.Node): boolean {
if (node.literal != null &&
node.literal.match('^<((div|span) data-mx-maths="[^"]*"|/(div|span))>$') != null) {
return true;
}
// Regex won't work for tags with attrs, but we only
// allow <del> anyway.
const matches = /^<\/?(.*)>$/.exec(node.literal);
@ -30,16 +44,8 @@ function is_allowed_html_tag(node) {
const tag = matches[1];
return ALLOWED_HTML_TAGS.indexOf(tag) > -1;
}
return false;
}
function html_if_tag_allowed(node) {
if (is_allowed_html_tag(node)) {
this.lit(node.literal);
return;
} else {
this.lit(escape(node.literal));
}
return false;
}
/*
@ -47,7 +53,7 @@ function html_if_tag_allowed(node) {
* comprises multiple block level elements (ie. lines),
* or false if it is only a single line.
*/
function is_multi_line(node) {
function isMultiLine(node: commonmark.Node): boolean {
let par = node;
while (par.parent) {
par = par.parent;
@ -61,6 +67,9 @@ function is_multi_line(node) {
* it's plain text.
*/
export default class Markdown {
private input: string;
private parsed: commonmark.Node;
constructor(input) {
this.input = input;
@ -68,7 +77,7 @@ export default class Markdown {
this.parsed = parser.parse(this.input);
}
isPlainText() {
isPlainText(): boolean {
const walker = this.parsed.walker();
let ev;
@ -81,7 +90,7 @@ export default class Markdown {
// if it's an allowed html tag, we need to render it and therefore
// we will need to use HTML. If it's not allowed, it's not HTML since
// we'll just be treating it as text.
if (is_allowed_html_tag(node)) {
if (isAllowedHtmlTag(node)) {
return false;
}
} else {
@ -91,7 +100,7 @@ export default class Markdown {
return true;
}
toHTML({ externalLinks = false } = {}) {
toHTML({ externalLinks = false } = {}): string {
const renderer = new commonmark.HtmlRenderer({
safe: false,
@ -99,9 +108,9 @@ export default class Markdown {
// puts softbreaks in for multiple lines in a blockquote,
// so if these are just newline characters then the
// block quote ends up all on one line
// (https://github.com/vector-im/riot-web/issues/3154)
// (https://github.com/vector-im/element-web/issues/3154)
softbreak: '<br />',
});
}) as CommonmarkHtmlRendererInternal;
// Trying to strip out the wrapping <p/> causes a lot more complication
// than it's worth, i think. For instance, this code will go and strip
@ -112,16 +121,16 @@ export default class Markdown {
//
// Let's try sending with <p/>s anyway for now, though.
const real_paragraph = renderer.paragraph;
const realParagraph = renderer.paragraph;
renderer.paragraph = function(node, entering) {
renderer.paragraph = function(node: commonmark.Node, entering: boolean) {
// If there is only one top level node, just return the
// bare text: it's a single line of text and so should be
// 'inline', rather than unnecessarily wrapped in its own
// p tag. If, however, we have multiple nodes, each gets
// its own p tag to keep them as separate paragraphs.
if (is_multi_line(node)) {
real_paragraph.call(this, node, entering);
if (isMultiLine(node)) {
realParagraph.call(this, node, entering);
}
};
@ -144,19 +153,26 @@ export default class Markdown {
}
};
renderer.html_inline = html_if_tag_allowed;
renderer.html_inline = function(node: commonmark.Node) {
if (isAllowedHtmlTag(node)) {
this.lit(node.literal);
return;
} else {
this.lit(escape(node.literal));
}
};
renderer.html_block = function(node) {
/*
renderer.html_block = function(node: commonmark.Node) {
/*
// as with `paragraph`, we only insert line breaks
// if there are multiple lines in the markdown.
const isMultiLine = is_multi_line(node);
if (isMultiLine) this.cr();
*/
html_if_tag_allowed.call(this, node);
/*
*/
renderer.html_inline(node);
/*
if (isMultiLine) this.cr();
*/
*/
};
return renderer.render(this.parsed);
@ -166,36 +182,27 @@ export default class Markdown {
* Render the markdown message to plain text. That is, essentially
* just remove any backslashes escaping what would otherwise be
* markdown syntax
* (to fix https://github.com/vector-im/riot-web/issues/2870).
* (to fix https://github.com/vector-im/element-web/issues/2870).
*
* N.B. this does **NOT** render arbitrary MD to plain text - only MD
* which has no formatting. Otherwise it emits HTML(!).
*/
toPlaintext() {
const renderer = new commonmark.HtmlRenderer({safe: false});
const real_paragraph = renderer.paragraph;
toPlaintext(): string {
const renderer = new commonmark.HtmlRenderer({ safe: false }) as CommonmarkHtmlRendererInternal;
// The default `out` function only sends the input through an XML
// escaping function, which causes messages to be entity encoded,
// which we don't want in this case.
renderer.out = function(s) {
// The `lit` function adds a string literal to the output buffer.
this.lit(s);
};
renderer.paragraph = function(node, entering) {
renderer.paragraph = function(node: commonmark.Node, entering: boolean) {
// as with toHTML, only append lines to paragraphs if there are
// multiple paragraphs
if (is_multi_line(node)) {
if (isMultiLine(node)) {
if (!entering && node.next) {
this.lit('\n\n');
}
}
};
renderer.html_block = function(node) {
renderer.html_block = function(node: commonmark.Node) {
this.lit(node.literal);
if (is_multi_line(node) && node.next) this.lit('\n\n');
if (isMultiLine(node) && node.next) this.lit('\n\n');
};
return renderer.render(this.parsed);

View file

@ -17,75 +17,54 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import {MatrixClient, MemoryStore} from 'matrix-js-sdk';
import { ICreateClientOpts, PendingEventOrdering } from 'matrix-js-sdk/src/matrix';
import { IStartClientOpts, MatrixClient } from 'matrix-js-sdk/src/client';
import { MemoryStore } from 'matrix-js-sdk/src/store/memory';
import * as utils from 'matrix-js-sdk/src/utils';
import {EventTimeline} from 'matrix-js-sdk/src/models/event-timeline';
import {EventTimelineSet} from 'matrix-js-sdk/src/models/event-timeline-set';
import { EventTimeline } from 'matrix-js-sdk/src/models/event-timeline';
import { EventTimelineSet } from 'matrix-js-sdk/src/models/event-timeline-set';
import * as sdk from './index';
import createMatrixClient from './utils/createMatrixClient';
import SettingsStore from './settings/SettingsStore';
import MatrixActionCreators from './actions/MatrixActionCreators';
import Modal from './Modal';
import {verificationMethods} from 'matrix-js-sdk/src/crypto';
import { verificationMethods } from 'matrix-js-sdk/src/crypto';
import MatrixClientBackedSettingsHandler from "./settings/handlers/MatrixClientBackedSettingsHandler";
import * as StorageManager from './utils/StorageManager';
import IdentityAuthClient from './IdentityAuthClient';
import { crossSigningCallbacks } from './CrossSigningManager';
import {SHOW_QR_CODE_METHOD} from "matrix-js-sdk/src/crypto/verification/QRCode";
import { crossSigningCallbacks, tryToUnlockSecretStorageWithDehydrationKey } from './SecurityManager';
import { SHOW_QR_CODE_METHOD } from "matrix-js-sdk/src/crypto/verification/QRCode";
import SecurityCustomisations from "./customisations/Security";
interface MatrixClientCreds {
homeserverUrl: string,
identityServerUrl: string,
userId: string,
deviceId: string,
accessToken: string,
guest: boolean,
export interface IMatrixClientCreds {
homeserverUrl: string;
identityServerUrl: string;
userId: string;
deviceId?: string;
accessToken: string;
guest?: boolean;
pickleKey?: string;
freshLogin?: boolean;
}
/**
* Wrapper object for handling the js-sdk Matrix Client object in the react-sdk
* Handles the creation/initialisation of client objects.
* This module provides a singleton instance of this class so the 'current'
* Matrix Client object is available easily.
*/
class _MatrixClientPeg {
constructor() {
this.matrixClient = null;
this._justRegisteredUserId = null;
// These are the default options used when when the
// client is started in 'start'. These can be altered
// at any time up to after the 'will_start_client'
// event is finished processing.
this.opts = {
initialSyncLimit: 20,
};
// the credentials used to init the current client object.
// used if we tear it down & recreate it with a different store
this._currentClientCreds = null;
}
export interface IMatrixClientPeg {
opts: IStartClientOpts;
/**
* Sets the script href passed to the IndexedDB web worker
* If set, a separate web worker will be started to run the IndexedDB
* queries on.
* Return the server name of the user's homeserver
* Throws an error if unable to deduce the homeserver name
* (eg. if the user is not logged in)
*
* @param {string} script href to the script to be passed to the web worker
* @returns {string} The homeserver name, if present.
*/
setIndexedDbWorkerScript(script) {
createMatrixClient.indexedDbWorkerScript = script;
}
getHomeserverName(): string;
get(): MatrixClient {
return this.matrixClient;
}
get(): MatrixClient;
unset(): void;
assign(): Promise<any>;
start(): Promise<any>;
unset() {
this.matrixClient = null;
MatrixActionCreators.stop();
}
getCredentials(): IMatrixClientCreds;
/**
* If we've registered a user ID we set this to the ID of the
@ -95,9 +74,7 @@ class _MatrixClientPeg {
*
* @param {string} uid The user ID of the user we've just registered
*/
setJustRegisteredUserId(uid) {
this._justRegisteredUserId = uid;
}
setJustRegisteredUserId(uid: string): void;
/**
* Returns true if the current user has just been registered by this
@ -105,23 +82,87 @@ class _MatrixClientPeg {
*
* @returns {bool} True if user has just been registered
*/
currentUserIsJustRegistered() {
currentUserIsJustRegistered(): boolean;
/**
* If the current user has been registered by this device then this
* returns a boolean of whether it was within the last N hours given.
*/
userRegisteredWithinLastHours(hours: number): boolean;
/**
* Replace this MatrixClientPeg's client with a client instance that has
* homeserver / identity server URLs and active credentials
*
* @param {IMatrixClientCreds} creds The new credentials to use.
*/
replaceUsingCreds(creds: IMatrixClientCreds): void;
}
/**
* Wrapper object for handling the js-sdk Matrix Client object in the react-sdk
* Handles the creation/initialisation of client objects.
* This module provides a singleton instance of this class so the 'current'
* Matrix Client object is available easily.
*/
class _MatrixClientPeg implements IMatrixClientPeg {
// These are the default options used when when the
// client is started in 'start'. These can be altered
// at any time up to after the 'will_start_client'
// event is finished processing.
public opts: IStartClientOpts = {
initialSyncLimit: 20,
};
private matrixClient: MatrixClient = null;
private justRegisteredUserId: string;
// the credentials used to init the current client object.
// used if we tear it down & recreate it with a different store
private currentClientCreds: IMatrixClientCreds;
constructor() {
}
public get(): MatrixClient {
return this.matrixClient;
}
public unset(): void {
this.matrixClient = null;
MatrixActionCreators.stop();
}
public setJustRegisteredUserId(uid: string): void {
this.justRegisteredUserId = uid;
if (uid) {
window.localStorage.setItem("mx_registration_time", String(new Date().getTime()));
}
}
public currentUserIsJustRegistered(): boolean {
return (
this.matrixClient &&
this.matrixClient.credentials.userId === this._justRegisteredUserId
this.matrixClient.credentials.userId === this.justRegisteredUserId
);
}
/*
* Replace this MatrixClientPeg's client with a client instance that has
* homeserver / identity server URLs and active credentials
*/
replaceUsingCreds(creds: MatrixClientCreds) {
this._currentClientCreds = creds;
this._createClient(creds);
public userRegisteredWithinLastHours(hours: number): boolean {
try {
const date = new Date(window.localStorage.getItem("mx_registration_time"));
return ((new Date().getTime() - date.getTime()) / 36e5) <= hours;
} catch (e) {
return false;
}
}
async assign() {
public replaceUsingCreds(creds: IMatrixClientCreds): void {
this.currentClientCreds = creds;
this.createClient(creds);
}
public async assign(): Promise<any> {
for (const dbType of ['indexeddb', 'memory']) {
try {
const promise = this.matrixClient.store.startup();
@ -132,7 +173,7 @@ class _MatrixClientPeg {
if (dbType === 'indexeddb') {
console.error('Error starting matrixclient store - falling back to memory store', err);
this.matrixClient.store = new MemoryStore({
localStorage: global.localStorage,
localStorage: localStorage,
});
} else {
console.error('Failed to start memory store!', err);
@ -151,16 +192,16 @@ class _MatrixClientPeg {
this.matrixClient.setCryptoTrustCrossSignedDevices(
!SettingsStore.getValue('e2ee.manuallyVerifyAllSessions'),
);
await tryToUnlockSecretStorageWithDehydrationKey(this.matrixClient);
StorageManager.setCryptoInitialised(true);
}
} catch (e) {
if (e && e.name === 'InvalidCryptoStoreError') {
// The js-sdk found a crypto DB too new for it to use
// FIXME: Using an import will result in test failures
const CryptoStoreTooNewDialog =
sdk.getComponent("views.dialogs.CryptoStoreTooNewDialog");
Modal.createDialog(CryptoStoreTooNewDialog, {
host: window.location.host,
});
Modal.createDialog(CryptoStoreTooNewDialog);
}
// this can happen for a number of reasons, the most likely being
// that the olm library was missing. It's not fatal.
@ -169,8 +210,9 @@ class _MatrixClientPeg {
const opts = utils.deepCopy(this.opts);
// the react sdk doesn't work without this, so don't allow
opts.pendingEventOrdering = "detached";
opts.pendingEventOrdering = PendingEventOrdering.Detached;
opts.lazyLoadMembers = true;
opts.clientWellKnownPollPeriod = 2 * 60 * 60; // 2 hours
// Connect the matrix client to the dispatcher and setting handlers
MatrixActionCreators.start(this.matrixClient);
@ -179,7 +221,7 @@ class _MatrixClientPeg {
return opts;
}
async start() {
public async start(): Promise<any> {
const opts = await this.assign();
console.log(`MatrixClientPeg: really starting MatrixClient`);
@ -187,7 +229,7 @@ class _MatrixClientPeg {
console.log(`MatrixClientPeg: MatrixClient started`);
}
getCredentials(): MatrixClientCreds {
public getCredentials(): IMatrixClientCreds {
return {
homeserverUrl: this.matrixClient.baseUrl,
identityServerUrl: this.matrixClient.idBaseUrl,
@ -198,29 +240,29 @@ class _MatrixClientPeg {
};
}
/*
* Return the server name of the user's homeserver
* Throws an error if unable to deduce the homeserver name
* (eg. if the user is not logged in)
*/
getHomeserverName() {
const matches = /^@.+:(.+)$/.exec(this.matrixClient.credentials.userId);
public getHomeserverName(): string {
const matches = /^@[^:]+:(.+)$/.exec(this.matrixClient.credentials.userId);
if (matches === null || matches.length < 1) {
throw new Error("Failed to derive homeserver name from user ID!");
}
return matches[1];
}
_createClient(creds: MatrixClientCreds) {
const opts = {
private createClient(creds: IMatrixClientCreds): void {
const opts: ICreateClientOpts = {
baseUrl: creds.homeserverUrl,
idBaseUrl: creds.identityServerUrl,
accessToken: creds.accessToken,
userId: creds.userId,
deviceId: creds.deviceId,
pickleKey: creds.pickleKey,
timelineSupport: true,
forceTURN: !SettingsStore.getValue('webRtcAllowPeerToPeer', false),
forceTURN: !SettingsStore.getValue('webRtcAllowPeerToPeer'),
fallbackICEServerAllowed: !!SettingsStore.getValue('fallbackICEServerAllowed'),
// Gather up to 20 ICE candidates when a call arrives: this should be more than we'd
// ever normally need, so effectively this should make all the gathering happen when
// the call arrives.
iceCandidatePoolSize: 20,
verificationMethods: [
verificationMethods.SAS,
SHOW_QR_CODE_METHOD,
@ -228,13 +270,17 @@ class _MatrixClientPeg {
],
unstableClientRelationAggregation: true,
identityServer: new IdentityAuthClient(),
cryptoCallbacks: {},
};
opts.cryptoCallbacks = {};
// These are always installed regardless of the labs flag so that
// cross-signing features can toggle on without reloading and also be
// accessed immediately after login.
Object.assign(opts.cryptoCallbacks, crossSigningCallbacks);
if (SecurityCustomisations.getDehydrationKey) {
opts.cryptoCallbacks.getDehydrationKey =
SecurityCustomisations.getDehydrationKey;
}
this.matrixClient = createMatrixClient(opts);
@ -253,8 +299,8 @@ class _MatrixClientPeg {
}
}
if (!global.mxMatrixClientPeg) {
global.mxMatrixClientPeg = new _MatrixClientPeg();
if (!window.mxMatrixClientPeg) {
window.mxMatrixClientPeg = new _MatrixClientPeg();
}
export const MatrixClientPeg = global.mxMatrixClientPeg;
export const MatrixClientPeg = window.mxMatrixClientPeg;

125
src/MediaDeviceHandler.ts Normal file
View file

@ -0,0 +1,125 @@
/*
Copyright 2017 Michael Telatynski <7t3chguy@gmail.com>
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 SettingsStore from "./settings/SettingsStore";
import { SettingLevel } from "./settings/SettingLevel";
import { setMatrixCallAudioInput, setMatrixCallVideoInput } from "matrix-js-sdk/src/matrix";
import EventEmitter from 'events';
// XXX: MediaDeviceKind is a union type, so we make our own enum
export enum MediaDeviceKindEnum {
AudioOutput = "audiooutput",
AudioInput = "audioinput",
VideoInput = "videoinput",
}
export type IMediaDevices = Record<MediaDeviceKindEnum, Array<MediaDeviceInfo>>;
export enum MediaDeviceHandlerEvent {
AudioOutputChanged = "audio_output_changed",
}
export default class MediaDeviceHandler extends EventEmitter {
private static internalInstance;
public static get instance(): MediaDeviceHandler {
if (!MediaDeviceHandler.internalInstance) {
MediaDeviceHandler.internalInstance = new MediaDeviceHandler();
}
return MediaDeviceHandler.internalInstance;
}
public static async hasAnyLabeledDevices(): Promise<boolean> {
const devices = await navigator.mediaDevices.enumerateDevices();
return devices.some(d => Boolean(d.label));
}
public static async getDevices(): Promise<IMediaDevices> {
// Only needed for Electron atm, though should work in modern browsers
// once permission has been granted to the webapp
try {
const devices = await navigator.mediaDevices.enumerateDevices();
const output = {
[MediaDeviceKindEnum.AudioOutput]: [],
[MediaDeviceKindEnum.AudioInput]: [],
[MediaDeviceKindEnum.VideoInput]: [],
};
devices.forEach((device) => output[device.kind].push(device));
return output;
} catch (error) {
console.warn('Unable to refresh WebRTC Devices: ', error);
}
}
/**
* Retrieves devices from the SettingsStore and tells the js-sdk to use them
*/
public static loadDevices(): void {
const audioDeviceId = SettingsStore.getValue("webrtc_audioinput");
const videoDeviceId = SettingsStore.getValue("webrtc_videoinput");
setMatrixCallAudioInput(audioDeviceId);
setMatrixCallVideoInput(videoDeviceId);
}
public setAudioOutput(deviceId: string): void {
SettingsStore.setValue("webrtc_audiooutput", null, SettingLevel.DEVICE, deviceId);
this.emit(MediaDeviceHandlerEvent.AudioOutputChanged, deviceId);
}
/**
* This will not change the device that a potential call uses. The call will
* need to be ended and started again for this change to take effect
* @param {string} deviceId
*/
public setAudioInput(deviceId: string): void {
SettingsStore.setValue("webrtc_audioinput", null, SettingLevel.DEVICE, deviceId);
setMatrixCallAudioInput(deviceId);
}
/**
* This will not change the device that a potential call uses. The call will
* need to be ended and started again for this change to take effect
* @param {string} deviceId
*/
public setVideoInput(deviceId: string): void {
SettingsStore.setValue("webrtc_videoinput", null, SettingLevel.DEVICE, deviceId);
setMatrixCallVideoInput(deviceId);
}
public setDevice(deviceId: string, kind: MediaDeviceKindEnum): void {
switch (kind) {
case MediaDeviceKindEnum.AudioOutput: this.setAudioOutput(deviceId); break;
case MediaDeviceKindEnum.AudioInput: this.setAudioInput(deviceId); break;
case MediaDeviceKindEnum.VideoInput: this.setVideoInput(deviceId); break;
}
}
public static getAudioOutput(): string {
return SettingsStore.getValueAt(SettingLevel.DEVICE, "webrtc_audiooutput");
}
public static getAudioInput(): string {
return SettingsStore.getValueAt(SettingLevel.DEVICE, "webrtc_audioinput");
}
public static getVideoInput(): string {
return SettingsStore.getValueAt(SettingLevel.DEVICE, "webrtc_videoinput");
}
}

View file

@ -1,322 +0,0 @@
/*
Copyright 2015, 2016 OpenMarket 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 React from 'react';
import ReactDOM from 'react-dom';
import Analytics from './Analytics';
import dis from './dispatcher';
import {defer} from './utils/promise';
import AsyncWrapper from './AsyncWrapper';
const DIALOG_CONTAINER_ID = "mx_Dialog_Container";
const STATIC_DIALOG_CONTAINER_ID = "mx_Dialog_StaticContainer";
class ModalManager {
constructor() {
this._counter = 0;
// The modal to prioritise over all others. If this is set, only show
// this modal. Remove all other modals from the stack when this modal
// is closed.
this._priorityModal = null;
// The modal to keep open underneath other modals if possible. Useful
// for cases like Settings where the modal should remain open while the
// user is prompted for more information/errors.
this._staticModal = null;
// A list of the modals we have stacked up, with the most recent at [0]
// Neither the static nor priority modal will be in this list.
this._modals = [
/* {
elem: React component for this dialog
onFinished: caller-supplied onFinished callback
className: CSS class for the dialog wrapper div
} */
];
this.onBackgroundClick = this.onBackgroundClick.bind(this);
}
hasDialogs() {
return this._priorityModal || this._staticModal || this._modals.length > 0;
}
getOrCreateContainer() {
let container = document.getElementById(DIALOG_CONTAINER_ID);
if (!container) {
container = document.createElement("div");
container.id = DIALOG_CONTAINER_ID;
document.body.appendChild(container);
}
return container;
}
getOrCreateStaticContainer() {
let container = document.getElementById(STATIC_DIALOG_CONTAINER_ID);
if (!container) {
container = document.createElement("div");
container.id = STATIC_DIALOG_CONTAINER_ID;
document.body.appendChild(container);
}
return container;
}
createTrackedDialog(analyticsAction, analyticsInfo, ...rest) {
Analytics.trackEvent('Modal', analyticsAction, analyticsInfo);
return this.createDialog(...rest);
}
appendTrackedDialog(analyticsAction, analyticsInfo, ...rest) {
Analytics.trackEvent('Modal', analyticsAction, analyticsInfo);
return this.appendDialog(...rest);
}
createDialog(Element, ...rest) {
return this.createDialogAsync(Promise.resolve(Element), ...rest);
}
appendDialog(Element, ...rest) {
return this.appendDialogAsync(Promise.resolve(Element), ...rest);
}
createTrackedDialogAsync(analyticsAction, analyticsInfo, ...rest) {
Analytics.trackEvent('Modal', analyticsAction, analyticsInfo);
return this.createDialogAsync(...rest);
}
appendTrackedDialogAsync(analyticsAction, analyticsInfo, ...rest) {
Analytics.trackEvent('Modal', analyticsAction, analyticsInfo);
return this.appendDialogAsync(...rest);
}
_buildModal(prom, props, className, options) {
const modal = {};
// never call this from onFinished() otherwise it will loop
const [closeDialog, onFinishedProm] = this._getCloseFn(modal, props);
// don't attempt to reuse the same AsyncWrapper for different dialogs,
// otherwise we'll get confused.
const modalCount = this._counter++;
// FIXME: If a dialog uses getDefaultProps it clobbers the onFinished
// property set here so you can't close the dialog from a button click!
modal.elem = (
<AsyncWrapper key={modalCount} prom={prom} {...props}
onFinished={closeDialog} />
);
modal.onFinished = props ? props.onFinished : null;
modal.className = className;
modal.onBeforeClose = options.onBeforeClose;
modal.beforeClosePromise = null;
modal.close = closeDialog;
modal.closeReason = null;
return {modal, closeDialog, onFinishedProm};
}
_getCloseFn(modal, props) {
const deferred = defer();
return [async (...args) => {
if (modal.beforeClosePromise) {
await modal.beforeClosePromise;
} else if (modal.onBeforeClose) {
modal.beforeClosePromise = modal.onBeforeClose(modal.closeReason);
const shouldClose = await modal.beforeClosePromise;
modal.beforeClosePromise = null;
if (!shouldClose) {
return;
}
}
deferred.resolve(args);
if (props && props.onFinished) props.onFinished.apply(null, args);
const i = this._modals.indexOf(modal);
if (i >= 0) {
this._modals.splice(i, 1);
}
if (this._priorityModal === modal) {
this._priorityModal = null;
// XXX: This is destructive
this._modals = [];
}
if (this._staticModal === modal) {
this._staticModal = null;
// XXX: This is destructive
this._modals = [];
}
this._reRender();
}, deferred.promise];
}
/**
* @callback onBeforeClose
* @param {string?} reason either "backgroundClick" or null
* @return {Promise<bool>} whether the dialog should close
*/
/**
* Open a modal view.
*
* This can be used to display a react component which is loaded as an asynchronous
* webpack component. To do this, set 'loader' as:
*
* (cb) => {
* require(['<module>'], cb);
* }
*
* @param {Promise} prom a promise which resolves with a React component
* which will be displayed as the modal view.
*
* @param {Object} props properties to pass to the displayed
* component. (We will also pass an 'onFinished' property.)
*
* @param {String} className CSS class to apply to the modal wrapper
*
* @param {boolean} isPriorityModal if true, this modal will be displayed regardless
* of other modals that are currently in the stack.
* Also, when closed, all modals will be removed
* from the stack.
* @param {boolean} isStaticModal if true, this modal will be displayed under other
* modals in the stack. When closed, all modals will
* also be removed from the stack. This is not compatible
* with being a priority modal. Only one modal can be
* static at a time.
* @param {Object} options? extra options for the dialog
* @param {onBeforeClose} options.onBeforeClose a callback to decide whether to close the dialog
* @returns {object} Object with 'close' parameter being a function that will close the dialog
*/
createDialogAsync(prom, props, className, isPriorityModal, isStaticModal, options = {}) {
const {modal, closeDialog, onFinishedProm} = this._buildModal(prom, props, className, options);
if (isPriorityModal) {
// XXX: This is destructive
this._priorityModal = modal;
} else if (isStaticModal) {
// This is intentionally destructive
this._staticModal = modal;
} else {
this._modals.unshift(modal);
}
this._reRender();
return {
close: closeDialog,
finished: onFinishedProm,
};
}
appendDialogAsync(prom, props, className) {
const {modal, closeDialog, onFinishedProm} = this._buildModal(prom, props, className, {});
this._modals.push(modal);
this._reRender();
return {
close: closeDialog,
finished: onFinishedProm,
};
}
onBackgroundClick() {
const modal = this._getCurrentModal();
if (!modal) {
return;
}
// we want to pass a reason to the onBeforeClose
// callback, but close is currently defined to
// pass all number of arguments to the onFinished callback
// so, pass the reason to close through a member variable
modal.closeReason = "backgroundClick";
modal.close();
modal.closeReason = null;
}
_getCurrentModal() {
return this._priorityModal ? this._priorityModal : (this._modals[0] || this._staticModal);
}
_reRender() {
if (this._modals.length === 0 && !this._priorityModal && !this._staticModal) {
// If there is no modal to render, make all of Riot available
// to screen reader users again
dis.dispatch({
action: 'aria_unhide_main_app',
});
ReactDOM.unmountComponentAtNode(this.getOrCreateContainer());
ReactDOM.unmountComponentAtNode(this.getOrCreateStaticContainer());
return;
}
// Hide the content outside the modal to screen reader users
// so they won't be able to navigate into it and act on it using
// screen reader specific features
dis.dispatch({
action: 'aria_hide_main_app',
});
if (this._staticModal) {
const classes = "mx_Dialog_wrapper mx_Dialog_staticWrapper "
+ (this._staticModal.className ? this._staticModal.className : '');
const staticDialog = (
<div className={classes}>
<div className="mx_Dialog">
{ this._staticModal.elem }
</div>
<div className="mx_Dialog_background mx_Dialog_staticBackground" onClick={this.onBackgroundClick}></div>
</div>
);
ReactDOM.render(staticDialog, this.getOrCreateStaticContainer());
} else {
// This is safe to call repeatedly if we happen to do that
ReactDOM.unmountComponentAtNode(this.getOrCreateStaticContainer());
}
const modal = this._getCurrentModal();
if (modal !== this._staticModal) {
const classes = "mx_Dialog_wrapper "
+ (this._staticModal ? "mx_Dialog_wrapperWithStaticUnder " : '')
+ (modal.className ? modal.className : '');
const dialog = (
<div className={classes}>
<div className="mx_Dialog">
{modal.elem}
</div>
<div className="mx_Dialog_background" onClick={this.onBackgroundClick}></div>
</div>
);
ReactDOM.render(dialog, this.getOrCreateContainer());
} else {
// This is safe to call repeatedly if we happen to do that
ReactDOM.unmountComponentAtNode(this.getOrCreateContainer());
}
}
}
if (!global.singletonModalManager) {
global.singletonModalManager = new ModalManager();
}
export default global.singletonModalManager;

398
src/Modal.tsx Normal file
View file

@ -0,0 +1,398 @@
/*
Copyright 2015, 2016 OpenMarket 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.
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 ReactDOM from 'react-dom';
import classNames from 'classnames';
import { defer } from "matrix-js-sdk/src/utils";
import Analytics from './Analytics';
import dis from './dispatcher/dispatcher';
import AsyncWrapper from './AsyncWrapper';
const DIALOG_CONTAINER_ID = "mx_Dialog_Container";
const STATIC_DIALOG_CONTAINER_ID = "mx_Dialog_StaticContainer";
export interface IModal<T extends any[]> {
elem: React.ReactNode;
className?: string;
beforeClosePromise?: Promise<boolean>;
closeReason?: string;
onBeforeClose?(reason?: string): Promise<boolean>;
onFinished(...args: T): void;
close(...args: T): void;
hidden?: boolean;
}
export interface IHandle<T extends any[]> {
finished: Promise<T>;
close(...args: T): void;
}
interface IProps<T extends any[]> {
onFinished?(...args: T): void;
// TODO improve typing here once all Modals are TS and we can exhaustively check the props
[key: string]: any;
}
interface IOptions<T extends any[]> {
onBeforeClose?: IModal<T>["onBeforeClose"];
}
type ParametersWithoutFirst<T extends (...args: any) => any> = T extends (a: any, ...args: infer P) => any ? P : never;
export class ModalManager {
private counter = 0;
// The modal to prioritise over all others. If this is set, only show
// this modal. Remove all other modals from the stack when this modal
// is closed.
private priorityModal: IModal<any> = null;
// The modal to keep open underneath other modals if possible. Useful
// for cases like Settings where the modal should remain open while the
// user is prompted for more information/errors.
private staticModal: IModal<any> = null;
// A list of the modals we have stacked up, with the most recent at [0]
// Neither the static nor priority modal will be in this list.
private modals: IModal<any>[] = [];
private static getOrCreateContainer() {
let container = document.getElementById(DIALOG_CONTAINER_ID);
if (!container) {
container = document.createElement("div");
container.id = DIALOG_CONTAINER_ID;
document.body.appendChild(container);
}
return container;
}
private static getOrCreateStaticContainer() {
let container = document.getElementById(STATIC_DIALOG_CONTAINER_ID);
if (!container) {
container = document.createElement("div");
container.id = STATIC_DIALOG_CONTAINER_ID;
document.body.appendChild(container);
}
return container;
}
public toggleCurrentDialogVisibility() {
const modal = this.getCurrentModal();
if (!modal) return;
modal.hidden = !modal.hidden;
}
public hasDialogs() {
return this.priorityModal || this.staticModal || this.modals.length > 0;
}
public createTrackedDialog<T extends any[]>(
analyticsAction: string,
analyticsInfo: string,
...rest: Parameters<ModalManager["createDialog"]>
) {
Analytics.trackEvent('Modal', analyticsAction, analyticsInfo);
return this.createDialog<T>(...rest);
}
public appendTrackedDialog<T extends any[]>(
analyticsAction: string,
analyticsInfo: string,
...rest: Parameters<ModalManager["appendDialog"]>
) {
Analytics.trackEvent('Modal', analyticsAction, analyticsInfo);
return this.appendDialog<T>(...rest);
}
public createDialog<T extends any[]>(
Element: React.ComponentType,
...rest: ParametersWithoutFirst<ModalManager["createDialogAsync"]>
) {
return this.createDialogAsync<T>(Promise.resolve(Element), ...rest);
}
public appendDialog<T extends any[]>(
Element: React.ComponentType,
...rest: ParametersWithoutFirst<ModalManager["appendDialogAsync"]>
) {
return this.appendDialogAsync<T>(Promise.resolve(Element), ...rest);
}
public createTrackedDialogAsync<T extends any[]>(
analyticsAction: string,
analyticsInfo: string,
...rest: Parameters<ModalManager["createDialogAsync"]>
) {
Analytics.trackEvent('Modal', analyticsAction, analyticsInfo);
return this.createDialogAsync<T>(...rest);
}
public appendTrackedDialogAsync<T extends any[]>(
analyticsAction: string,
analyticsInfo: string,
...rest: Parameters<ModalManager["appendDialogAsync"]>
) {
Analytics.trackEvent('Modal', analyticsAction, analyticsInfo);
return this.appendDialogAsync<T>(...rest);
}
public closeCurrentModal(reason: string) {
const modal = this.getCurrentModal();
if (!modal) {
return;
}
modal.closeReason = reason;
modal.close();
}
private buildModal<T extends any[]>(
prom: Promise<React.ComponentType>,
props?: IProps<T>,
className?: string,
options?: IOptions<T>,
) {
const modal: IModal<T> = {
onFinished: props ? props.onFinished : null,
onBeforeClose: options.onBeforeClose,
beforeClosePromise: null,
closeReason: null,
className,
// these will be set below but we need an object reference to pass to getCloseFn before we can do that
elem: null,
close: null,
};
// never call this from onFinished() otherwise it will loop
const [closeDialog, onFinishedProm] = this.getCloseFn<T>(modal, props);
// don't attempt to reuse the same AsyncWrapper for different dialogs,
// otherwise we'll get confused.
const modalCount = this.counter++;
// FIXME: If a dialog uses getDefaultProps it clobbers the onFinished
// property set here so you can't close the dialog from a button click!
modal.elem = <AsyncWrapper key={modalCount} prom={prom} {...props} onFinished={closeDialog} />;
modal.close = closeDialog;
return { modal, closeDialog, onFinishedProm };
}
private getCloseFn<T extends any[]>(
modal: IModal<T>,
props: IProps<T>,
): [IHandle<T>["close"], IHandle<T>["finished"]] {
const deferred = defer<T>();
return [async (...args: T) => {
if (modal.beforeClosePromise) {
await modal.beforeClosePromise;
} else if (modal.onBeforeClose) {
modal.beforeClosePromise = modal.onBeforeClose(modal.closeReason);
const shouldClose = await modal.beforeClosePromise;
modal.beforeClosePromise = null;
if (!shouldClose) {
return;
}
}
deferred.resolve(args);
if (props && props.onFinished) props.onFinished.apply(null, args);
const i = this.modals.indexOf(modal);
if (i >= 0) {
this.modals.splice(i, 1);
}
if (this.priorityModal === modal) {
this.priorityModal = null;
// XXX: This is destructive
this.modals = [];
}
if (this.staticModal === modal) {
this.staticModal = null;
// XXX: This is destructive
this.modals = [];
}
this.reRender();
}, deferred.promise];
}
/**
* @callback onBeforeClose
* @param {string?} reason either "backgroundClick" or null
* @return {Promise<bool>} whether the dialog should close
*/
/**
* Open a modal view.
*
* This can be used to display a react component which is loaded as an asynchronous
* webpack component. To do this, set 'loader' as:
*
* (cb) => {
* require(['<module>'], cb);
* }
*
* @param {Promise} prom a promise which resolves with a React component
* which will be displayed as the modal view.
*
* @param {Object} props properties to pass to the displayed
* component. (We will also pass an 'onFinished' property.)
*
* @param {String} className CSS class to apply to the modal wrapper
*
* @param {boolean} isPriorityModal if true, this modal will be displayed regardless
* of other modals that are currently in the stack.
* Also, when closed, all modals will be removed
* from the stack.
* @param {boolean} isStaticModal if true, this modal will be displayed under other
* modals in the stack. When closed, all modals will
* also be removed from the stack. This is not compatible
* with being a priority modal. Only one modal can be
* static at a time.
* @param {Object} options? extra options for the dialog
* @param {onBeforeClose} options.onBeforeClose a callback to decide whether to close the dialog
* @returns {object} Object with 'close' parameter being a function that will close the dialog
*/
private createDialogAsync<T extends any[]>(
prom: Promise<React.ComponentType>,
props?: IProps<T>,
className?: string,
isPriorityModal = false,
isStaticModal = false,
options: IOptions<T> = {},
): IHandle<T> {
const { modal, closeDialog, onFinishedProm } = this.buildModal<T>(prom, props, className, options);
if (isPriorityModal) {
// XXX: This is destructive
this.priorityModal = modal;
} else if (isStaticModal) {
// This is intentionally destructive
this.staticModal = modal;
} else {
this.modals.unshift(modal);
}
this.reRender();
return {
close: closeDialog,
finished: onFinishedProm,
};
}
private appendDialogAsync<T extends any[]>(
prom: Promise<React.ComponentType>,
props?: IProps<T>,
className?: string,
): IHandle<T> {
const { modal, closeDialog, onFinishedProm } = this.buildModal<T>(prom, props, className, {});
this.modals.push(modal);
this.reRender();
return {
close: closeDialog,
finished: onFinishedProm,
};
}
private onBackgroundClick = () => {
const modal = this.getCurrentModal();
if (!modal) {
return;
}
// we want to pass a reason to the onBeforeClose
// callback, but close is currently defined to
// pass all number of arguments to the onFinished callback
// so, pass the reason to close through a member variable
modal.closeReason = "backgroundClick";
modal.close();
modal.closeReason = null;
};
private getCurrentModal(): IModal<any> {
return this.priorityModal ? this.priorityModal : (this.modals[0] || this.staticModal);
}
private reRender() {
if (this.modals.length === 0 && !this.priorityModal && !this.staticModal) {
// If there is no modal to render, make all of Element available
// to screen reader users again
dis.dispatch({
action: 'aria_unhide_main_app',
});
ReactDOM.unmountComponentAtNode(ModalManager.getOrCreateContainer());
ReactDOM.unmountComponentAtNode(ModalManager.getOrCreateStaticContainer());
return;
}
// Hide the content outside the modal to screen reader users
// so they won't be able to navigate into it and act on it using
// screen reader specific features
dis.dispatch({
action: 'aria_hide_main_app',
});
if (this.staticModal) {
const classes = classNames("mx_Dialog_wrapper mx_Dialog_staticWrapper", this.staticModal.className);
const staticDialog = (
<div className={classes}>
<div className="mx_Dialog">
{ this.staticModal.elem }
</div>
<div className="mx_Dialog_background mx_Dialog_staticBackground" onClick={this.onBackgroundClick} />
</div>
);
ReactDOM.render(staticDialog, ModalManager.getOrCreateStaticContainer());
} else {
// This is safe to call repeatedly if we happen to do that
ReactDOM.unmountComponentAtNode(ModalManager.getOrCreateStaticContainer());
}
const modal = this.getCurrentModal();
if (modal !== this.staticModal && !modal.hidden) {
const classes = classNames("mx_Dialog_wrapper", modal.className, {
mx_Dialog_wrapperWithStaticUnder: this.staticModal,
});
const dialog = (
<div className={classes}>
<div className="mx_Dialog">
{modal.elem}
</div>
<div className="mx_Dialog_background" onClick={this.onBackgroundClick} />
</div>
);
setImmediate(() => ReactDOM.render(dialog, ModalManager.getOrCreateContainer()));
} else {
// This is safe to call repeatedly if we happen to do that
ReactDOM.unmountComponentAtNode(ModalManager.getOrCreateContainer());
}
}
}
if (!window.singletonModalManager) {
window.singletonModalManager = new ModalManager();
}
export default window.singletonModalManager;

View file

@ -1,16 +1,15 @@
import React from "react";
import ReactDom from "react-dom";
import Velocity from "velocity-animate";
import PropTypes from 'prop-types';
/**
* The Velociraptor contains components and animates transitions with velocity.
* The NodeAnimator contains components and animates transitions.
* It will only pick up direct changes to properties ('left', currently), and so
* will not work for animating positional changes where the position is implicit
* from DOM order. This makes it a lot simpler and lighter: if you need fully
* automatic positional animation, look at react-shuffle or similar libraries.
*/
export default class Velociraptor extends React.Component {
export default class NodeAnimator extends React.Component {
static propTypes = {
// either a list of child nodes, or a single child.
children: PropTypes.any,
@ -20,14 +19,10 @@ export default class Velociraptor extends React.Component {
// a list of state objects to apply to each child node in turn
startStyles: PropTypes.array,
// a list of transition options from the corresponding startStyle
enterTransitionOpts: PropTypes.array,
};
static defaultProps = {
startStyles: [],
enterTransitionOpts: [],
};
constructor(props) {
@ -41,6 +36,18 @@ export default class Velociraptor extends React.Component {
this._updateChildren(this.props.children);
}
/**
*
* @param {HTMLElement} node element to apply styles to
* @param {object} styles a key/value pair of CSS properties
* @returns {void}
*/
_applyStyles(node, styles) {
Object.entries(styles).forEach(([property, value]) => {
node.style[property] = value;
});
}
_updateChildren(newChildren) {
const oldChildren = this.children || {};
this.children = {};
@ -50,17 +57,8 @@ export default class Velociraptor extends React.Component {
const oldNode = ReactDom.findDOMNode(this.nodes[old.key]);
if (oldNode && oldNode.style.left !== c.props.style.left) {
Velocity(oldNode, { left: c.props.style.left }, this.props.transition).then(() => {
// special case visibility because it's nonsensical to animate an invisible element
// so we always hidden->visible pre-transition and visible->hidden after
if (oldNode.style.visibility === 'visible' && c.props.style.visibility === 'hidden') {
oldNode.style.visibility = c.props.style.visibility;
}
});
//console.log("translation: "+oldNode.style.left+" -> "+c.props.style.left);
}
if (oldNode && oldNode.style.visibility === 'hidden' && c.props.style.visibility === 'visible') {
oldNode.style.visibility = c.props.style.visibility;
this._applyStyles(oldNode, { left: c.props.style.left });
// console.log("translation: "+oldNode.style.left+" -> "+c.props.style.left);
}
// clone the old element with the props (and children) of the new element
// so prop updates are still received by the children.
@ -94,58 +92,30 @@ export default class Velociraptor extends React.Component {
this.props.startStyles.length > 0
) {
const startStyles = this.props.startStyles;
const transitionOpts = this.props.enterTransitionOpts;
const domNode = ReactDom.findDOMNode(node);
// start from startStyle 1: 0 is the one we gave it
// to start with, so now we animate 1 etc.
for (var i = 1; i < startStyles.length; ++i) {
Velocity(domNode, startStyles[i], transitionOpts[i-1]);
/*
console.log("start:",
JSON.stringify(transitionOpts[i-1]),
"->",
JSON.stringify(startStyles[i]),
);
*/
for (let i = 1; i < startStyles.length; ++i) {
this._applyStyles(domNode, startStyles[i]);
// console.log("start:"
// JSON.stringify(startStyles[i]),
// );
}
// and then we animate to the resting state
Velocity(domNode, restingStyle,
transitionOpts[i-1])
.then(() => {
// once we've reached the resting state, hide the element if
// appropriate
domNode.style.visibility = restingStyle.visibility;
});
setTimeout(() => {
this._applyStyles(domNode, restingStyle);
}, 0);
/*
console.log("enter:",
JSON.stringify(transitionOpts[i-1]),
"->",
JSON.stringify(restingStyle));
*/
} else if (node === null) {
// Velocity stores data on elements using the jQuery .data()
// method, and assumes you'll be using jQuery's .remove() to
// remove the element, but we don't use jQuery, so we need to
// blow away the element's data explicitly otherwise it will leak.
// This uses Velocity's internal jQuery compatible wrapper.
// See the bug at
// https://github.com/julianshapiro/velocity/issues/300
// and the FAQ entry, "Preventing memory leaks when
// creating/destroying large numbers of elements"
// (https://github.com/julianshapiro/velocity/issues/47)
const domNode = ReactDom.findDOMNode(this.nodes[k]);
if (domNode) Velocity.Utilities.removeData(domNode);
// console.log("enter:",
// JSON.stringify(restingStyle));
}
this.nodes[k] = node;
}
render() {
return (
<span>
{ Object.values(this.children) }
</span>
<>{ Object.values(this.children) }</>
);
}
}

View file

@ -2,6 +2,7 @@
Copyright 2015, 2016 OpenMarket Ltd
Copyright 2017 Vector Creations Ltd
Copyright 2017 New Vector 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.
@ -16,16 +17,26 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import {MatrixClientPeg} from './MatrixClientPeg';
import { MatrixEvent } from "matrix-js-sdk/src/models/event";
import { Room } from "matrix-js-sdk/src/models/room";
import { MatrixClientPeg } from './MatrixClientPeg';
import SdkConfig from './SdkConfig';
import PlatformPeg from './PlatformPeg';
import * as TextForEvent from './TextForEvent';
import Analytics from './Analytics';
import * as Avatar from './Avatar';
import dis from './dispatcher';
import * as sdk from './index';
import dis from './dispatcher/dispatcher';
import { _t } from './languageHandler';
import Modal from './Modal';
import SettingsStore, {SettingLevel} from "./settings/SettingsStore";
import SettingsStore from "./settings/SettingsStore";
import { hideToast as hideNotificationsToast } from "./toasts/DesktopNotificationsToast";
import { SettingLevel } from "./settings/SettingLevel";
import { isPushNotifyDisabled } from "./settings/controllers/NotificationControllers";
import RoomViewStore from "./stores/RoomViewStore";
import UserActivity from "./UserActivity";
import { mediaFromMxc } from "./customisations/Media";
import ErrorDialog from "./components/views/dialogs/ErrorDialog";
/*
* Dispatches:
@ -49,7 +60,7 @@ const typehandlers = {
},
};
const Notifier = {
export const Notifier = {
notifsByRoom: {},
// A list of event IDs that we've received but need to wait until
@ -57,14 +68,14 @@ const Notifier = {
// or not
pendingEncryptedEventIds: [],
notificationMessageForEvent: function(ev) {
notificationMessageForEvent: function(ev: MatrixEvent): string {
if (typehandlers.hasOwnProperty(ev.getContent().msgtype)) {
return typehandlers[ev.getContent().msgtype](ev);
}
return TextForEvent.textForEvent(ev);
},
_displayPopupNotification: function(ev, room) {
_displayPopupNotification: function(ev: MatrixEvent, room: Room) {
const plaf = PlatformPeg.get();
if (!plaf) {
return;
@ -119,7 +130,7 @@ const Notifier = {
}
},
getSoundForRoom: async function(roomId) {
getSoundForRoom: function(roomId: string) {
// We do no caching here because the SDK caches setting
// and the browser will cache the sound.
const content = SettingsStore.getValue("notificationSound", roomId);
@ -140,19 +151,20 @@ const Notifier = {
// Ideally in here we could use MSC1310 to detect the type of file, and reject it.
return {
url: MatrixClientPeg.get().mxcUrlToHttp(content.url),
url: mediaFromMxc(content.url).srcHttp,
name: content.name,
type: content.type,
size: content.size,
};
},
_playAudioNotification: async function(ev, room) {
const sound = await this.getSoundForRoom(room.roomId);
_playAudioNotification: async function(ev: MatrixEvent, room: Room) {
const sound = this.getSoundForRoom(room.roomId);
console.log(`Got sound ${sound && sound.name || "default"} for ${room.roomId}`);
try {
const selector = document.querySelector(sound ? `audio[src='${sound.url}']` : "#messageAudio");
const selector =
document.querySelector<HTMLAudioElement>(sound ? `audio[src='${sound.url}']` : "#messageAudio");
let audioElement = selector;
if (!selector) {
if (!sound) {
@ -201,7 +213,7 @@ const Notifier = {
return plaf && plaf.supportsNotifications();
},
setEnabled: function(enable, callback) {
setEnabled: function(enable: boolean, callback?: () => void) {
const plaf = PlatformPeg.get();
if (!plaf) return;
@ -209,7 +221,7 @@ const Notifier = {
// calculated value. It is determined based upon whether or not the master rule is enabled
// and other flags. Setting it here would cause a circular reference.
Analytics.trackEvent('Notifier', 'Set Enabled', enable);
Analytics.trackEvent('Notifier', 'Set Enabled', String(enable));
// make sure that we persist the current setting audio_enabled setting
// before changing anything
@ -223,11 +235,11 @@ const Notifier = {
if (result !== 'granted') {
// The permission request was dismissed or denied
// TODO: Support alternative branding in messaging
const brand = SdkConfig.get().brand;
const description = result === 'denied'
? _t('Riot does not have permission to send you notifications - ' +
'please check your browser settings')
: _t('Riot was not given permission to send notifications - please try again');
const ErrorDialog = sdk.getComponent('dialogs.ErrorDialog');
? _t('%(brand)s does not have permission to send you notifications - ' +
'please check your browser settings', { brand })
: _t('%(brand)s was not given permission to send notifications - please try again', { brand });
Modal.createTrackedDialog('Unable to enable Notifications', result, ErrorDialog, {
title: _t('Unable to enable Notifications'),
description,
@ -249,7 +261,7 @@ const Notifier = {
}
// set the notifications_hidden flag, as the user has knowingly interacted
// with the setting we shouldn't nag them any further
this.setToolbarHidden(true);
this.setPromptHidden(true);
},
isEnabled: function() {
@ -270,38 +282,34 @@ const Notifier = {
},
isAudioEnabled: function() {
return this.isEnabled() && SettingsStore.getValue("audioNotificationsEnabled");
// We don't route Audio via the HTML Notifications API so it is possible regardless of other things
return SettingsStore.getValue("audioNotificationsEnabled");
},
setToolbarHidden: function(hidden, persistent = true) {
setPromptHidden: function(hidden: boolean, persistent = true) {
this.toolbarHidden = hidden;
Analytics.trackEvent('Notifier', 'Set Toolbar Hidden', hidden);
Analytics.trackEvent('Notifier', 'Set Toolbar Hidden', String(hidden));
// XXX: why are we dispatching this here?
// this is nothing to do with notifier_enabled
dis.dispatch({
action: "notifier_enabled",
value: this.isEnabled(),
});
hideNotificationsToast();
// update the info to localStorage for persistent settings
if (persistent && global.localStorage) {
global.localStorage.setItem("notifications_hidden", hidden);
global.localStorage.setItem("notifications_hidden", String(hidden));
}
},
shouldShowToolbar: function() {
shouldShowPrompt: function() {
const client = MatrixClientPeg.get();
if (!client) {
return false;
}
const isGuest = client.isGuest();
return !isGuest && this.supportsDesktopNotifications() &&
!this.isEnabled() && !this._isToolbarHidden();
return !isGuest && this.supportsDesktopNotifications() && !isPushNotifyDisabled() &&
!this.isEnabled() && !this._isPromptHidden();
},
_isToolbarHidden: function() {
_isPromptHidden: function() {
// Check localStorage for any such meta data
if (global.localStorage) {
return global.localStorage.getItem("notifications_hidden") === "true";
@ -310,7 +318,7 @@ const Notifier = {
return this.toolbarHidden;
},
onSyncStateChange: function(state) {
onSyncStateChange: function(state: string) {
if (state === "SYNCING") {
this.isSyncing = true;
} else if (state === "STOPPED" || state === "ERROR") {
@ -318,9 +326,11 @@ const Notifier = {
}
},
onEvent: function(ev) {
onEvent: function(ev: MatrixEvent) {
if (!this.isSyncing) return; // don't alert for any messages initially
if (ev.sender && ev.sender.userId === MatrixClientPeg.get().credentials.userId) return;
if (ev.getSender() === MatrixClientPeg.get().credentials.userId) return;
MatrixClientPeg.get().decryptEventIfNeeded(ev);
// If it's an encrypted event and the type is still 'm.room.encrypted',
// it hasn't yet been decrypted, so wait until it is.
@ -336,7 +346,7 @@ const Notifier = {
this._evaluateEvent(ev);
},
onEventDecrypted: function(ev) {
onEventDecrypted: function(ev: MatrixEvent) {
// 'decrypted' means the decryption process has finished: it may have failed,
// in which case it might decrypt soon if the keys arrive
if (ev.isDecryptionFailure()) return;
@ -348,7 +358,7 @@ const Notifier = {
this._evaluateEvent(ev);
},
onRoomReceipt: function(ev, room) {
onRoomReceipt: function(ev: MatrixEvent, room: Room) {
if (room.getUnreadNotificationCount() === 0) {
// ideally we would clear each notification when it was read,
// but we have no way, given a read receipt, to know whether
@ -370,6 +380,15 @@ const Notifier = {
const room = MatrixClientPeg.get().getRoom(ev.getRoomId());
const actions = MatrixClientPeg.get().getPushActionsForEvent(ev);
if (actions && actions.notify) {
if (RoomViewStore.getRoomId() === room.roomId && UserActivity.sharedInstance().userActiveRecently()) {
// don't bother notifying as user was recently active in this room
return;
}
if (SettingsStore.getValue("doNotDisturb")) {
// Don't bother the user if they didn't ask to be bothered
return;
}
if (this.isEnabled()) {
this._displayPopupNotification(ev, room);
}
@ -381,8 +400,8 @@ const Notifier = {
},
};
if (!global.mxNotifier) {
global.mxNotifier = Notifier;
if (!window.mxNotifier) {
window.mxNotifier = Notifier;
}
export default global.mxNotifier;
export default window.mxNotifier;

View file

@ -1,113 +0,0 @@
/*
Copyright 2016 OpenMarket Ltd
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.
*/
/**
* For two objects of the form { key: [val1, val2, val3] }, work out the added/removed
* values. Entirely new keys will result in the entire value array being added.
* @param {Object} before
* @param {Object} after
* @return {Object[]} An array of objects with the form:
* { key: $KEY, val: $VALUE, place: "add|del" }
*/
export function getKeyValueArrayDiffs(before, after) {
const results = [];
const delta = {};
Object.keys(before).forEach(function(beforeKey) {
delta[beforeKey] = delta[beforeKey] || 0; // init to 0 initially
delta[beforeKey]--; // keys present in the past have -ve values
});
Object.keys(after).forEach(function(afterKey) {
delta[afterKey] = delta[afterKey] || 0; // init to 0 initially
delta[afterKey]++; // keys present in the future have +ve values
});
Object.keys(delta).forEach(function(muxedKey) {
switch (delta[muxedKey]) {
case 1: // A new key in after
after[muxedKey].forEach(function(afterVal) {
results.push({ place: "add", key: muxedKey, val: afterVal });
});
break;
case -1: // A before key was removed
before[muxedKey].forEach(function(beforeVal) {
results.push({ place: "del", key: muxedKey, val: beforeVal });
});
break;
case 0: {// A mix of added/removed keys
// compare old & new vals
const itemDelta = {};
before[muxedKey].forEach(function(beforeVal) {
itemDelta[beforeVal] = itemDelta[beforeVal] || 0;
itemDelta[beforeVal]--;
});
after[muxedKey].forEach(function(afterVal) {
itemDelta[afterVal] = itemDelta[afterVal] || 0;
itemDelta[afterVal]++;
});
Object.keys(itemDelta).forEach(function(item) {
if (itemDelta[item] === 1) {
results.push({ place: "add", key: muxedKey, val: item });
} else if (itemDelta[item] === -1) {
results.push({ place: "del", key: muxedKey, val: item });
} else {
// itemDelta of 0 means it was unchanged between before/after
}
});
break;
}
default:
console.error("Calculated key delta of " + delta[muxedKey] + " - this should never happen!");
break;
}
});
return results;
}
/**
* Shallow-compare two objects for equality: each key and value must be identical
* @param {Object} objA First object to compare against the second
* @param {Object} objB Second object to compare against the first
* @return {boolean} whether the two objects have same key=values
*/
export function shallowEqual(objA, objB) {
if (objA === objB) {
return true;
}
if (typeof objA !== 'object' || objA === null ||
typeof objB !== 'object' || objB === null) {
return false;
}
const keysA = Object.keys(objA);
const keysB = Object.keys(objB);
if (keysA.length !== keysB.length) {
return false;
}
for (let i = 0; i < keysA.length; i++) {
const key = keysA[i];
if (!objB.hasOwnProperty(key) || objA[key] !== objB[key]) {
return false;
}
}
return true;
}

View file

@ -15,7 +15,7 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import * as Matrix from 'matrix-js-sdk';
import { createClient } from 'matrix-js-sdk/src/matrix';
import { _t } from './languageHandler';
/**
@ -32,7 +32,7 @@ export default class PasswordReset {
* @param {string} identityUrl The URL to the IS which has linked the email -> mxid mapping.
*/
constructor(homeserverUrl, identityUrl) {
this.client = Matrix.createClient({
this.client = createClient({
baseUrl: homeserverUrl,
idBaseUrl: identityUrl,
});
@ -40,10 +40,6 @@ export default class PasswordReset {
this.identityServerDomain = identityUrl ? identityUrl.split("://")[1] : null;
}
doesServerRequireIdServerParam() {
return this.client.doesServerRequireIdServerParam();
}
/**
* Attempt to reset the user's password. This will trigger a side-effect of
* sending an email to the provided email address.
@ -58,7 +54,7 @@ export default class PasswordReset {
return res;
}, function(err) {
if (err.errcode === 'M_THREEPID_NOT_FOUND') {
err.message = _t('This email address was not found');
err.message = _t('This email address was not found');
} else if (err.httpStatus) {
err.message = err.message + ` (Status ${err.httpStatus})`;
}
@ -78,14 +74,17 @@ export default class PasswordReset {
sid: this.sessionId,
client_secret: this.clientSecret,
};
if (await this.doesServerRequireIdServerParam()) {
creds.id_server = this.identityServerDomain;
}
try {
await this.client.setPassword({
// Note: Though this sounds like a login type for identity servers only, it
// has a dual purpose of being used for homeservers too.
type: "m.login.email.identity",
// TODO: Remove `threepid_creds` once servers support proper UIA
// See https://github.com/matrix-org/synapse/issues/5665
// See https://github.com/matrix-org/matrix-doc/issues/2220
threepid_creds: creds,
threepidCreds: creds,
}, this.password);
} catch (err) {
if (err.httpStatus === 401) {

View file

@ -1,51 +0,0 @@
/*
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.
*/
import SdkConfig from './SdkConfig';
import {hashCode} from './utils/FormattingUtils';
export function phasedRollOutExpiredForUser(username, feature, now, rollOutConfig = SdkConfig.get().phasedRollOut) {
if (!rollOutConfig) {
console.log(`no phased rollout configuration, so enabling ${feature}`);
return true;
}
const featureConfig = rollOutConfig[feature];
if (!featureConfig) {
console.log(`${feature} doesn't have phased rollout configured, so enabling`);
return true;
}
if (!Number.isFinite(featureConfig.offset) || !Number.isFinite(featureConfig.period)) {
console.error(`phased rollout of ${feature} is misconfigured, ` +
`offset and/or period are not numbers, so disabling`, featureConfig);
return false;
}
const hash = hashCode(username);
//ms -> min, enable users at minute granularity
const bucketRatio = 1000 * 60;
const bucketCount = featureConfig.period / bucketRatio;
const userBucket = hash % bucketCount;
const userMs = userBucket * bucketRatio;
const enableAt = featureConfig.offset + userMs;
const result = now >= enableAt;
const bucketStr = `(bucket ${userBucket}/${bucketCount})`;
if (result) {
console.log(`${feature} enabled for ${username} ${bucketStr}`);
} else {
console.log(`${feature} will be enabled for ${username} in ${Math.ceil((enableAt - now)/1000)}s ${bucketStr}`);
}
return result;
}

View file

@ -1,5 +1,6 @@
/*
Copyright 2016 OpenMarket 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,6 +15,8 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import BasePlatform from "./BasePlatform";
/*
* Holds the current Platform object used by the code to do anything
* specific to the platform we're running on (eg. web, electron)
@ -21,10 +24,8 @@ limitations under the License.
* This allows the app layer to set a Platform without necessarily
* having to have a MatrixChat object
*/
class PlatformPeg {
constructor() {
this.platform = null;
}
export class PlatformPeg {
platform: BasePlatform = null;
/**
* Returns the current Platform object for the application.
@ -39,12 +40,12 @@ class PlatformPeg {
* application.
* This should be an instance of a class extending BasePlatform.
*/
set(plaf) {
set(plaf: BasePlatform) {
this.platform = plaf;
}
}
if (!global.mxPlatformPeg) {
global.mxPlatformPeg = new PlatformPeg();
if (!window.mxPlatformPeg) {
window.mxPlatformPeg = new PlatformPeg();
}
export default global.mxPlatformPeg;
export default window.mxPlatformPeg;

View file

@ -16,33 +16,37 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import {MatrixClientPeg} from "./MatrixClientPeg";
import dis from "./dispatcher";
import { MatrixClientPeg } from "./MatrixClientPeg";
import dis from "./dispatcher/dispatcher";
import Timer from './utils/Timer';
import { ActionPayload } from "./dispatcher/payloads";
// Time in ms after that a user is considered as unavailable/away
// Time in ms after that a user is considered as unavailable/away
const UNAVAILABLE_TIME_MS = 3 * 60 * 1000; // 3 mins
const PRESENCE_STATES = ["online", "offline", "unavailable"];
enum State {
Online = "online",
Offline = "offline",
Unavailable = "unavailable",
}
class Presence {
constructor() {
this._activitySignal = null;
this._unavailableTimer = null;
this._onAction = this._onAction.bind(this);
this._dispatcherRef = null;
}
private unavailableTimer: Timer = null;
private dispatcherRef: string = null;
private state: State = null;
/**
* Start listening the user activity to evaluate his presence state.
* Any state change will be sent to the homeserver.
*/
async start() {
this._unavailableTimer = new Timer(UNAVAILABLE_TIME_MS);
public async start() {
this.unavailableTimer = new Timer(UNAVAILABLE_TIME_MS);
// the user_activity_start action starts the timer
this._dispatcherRef = dis.register(this._onAction);
while (this._unavailableTimer) {
this.dispatcherRef = dis.register(this.onAction);
while (this.unavailableTimer) {
try {
await this._unavailableTimer.finished();
this.setState("unavailable");
await this.unavailableTimer.finished();
this.setState(State.Unavailable);
} catch (e) { /* aborted, stop got called */ }
}
}
@ -50,14 +54,14 @@ class Presence {
/**
* Stop tracking user activity
*/
stop() {
if (this._dispatcherRef) {
dis.unregister(this._dispatcherRef);
this._dispatcherRef = null;
public stop() {
if (this.dispatcherRef) {
dis.unregister(this.dispatcherRef);
this.dispatcherRef = null;
}
if (this._unavailableTimer) {
this._unavailableTimer.abort();
this._unavailableTimer = null;
if (this.unavailableTimer) {
this.unavailableTimer.abort();
this.unavailableTimer = null;
}
}
@ -65,29 +69,27 @@ class Presence {
* Get the current presence state.
* @returns {string} the presence state (see PRESENCE enum)
*/
getState() {
public getState() {
return this.state;
}
_onAction(payload) {
private onAction = (payload: ActionPayload) => {
if (payload.action === 'user_activity') {
this.setState("online");
this._unavailableTimer.restart();
this.setState(State.Online);
this.unavailableTimer.restart();
}
}
};
/**
* Set the presence state.
* If the state has changed, the homeserver will be notified.
* @param {string} newState the new presence state (see PRESENCE enum)
*/
async setState(newState) {
private async setState(newState: State) {
if (newState === this.state) {
return;
}
if (PRESENCE_STATES.indexOf(newState) === -1) {
throw new Error("Bad presence state: " + newState);
}
const oldState = this.state;
this.state = newState;
@ -96,10 +98,10 @@ class Presence {
}
try {
await MatrixClientPeg.get().setPresence(this.state);
console.info("Presence: %s", newState);
await MatrixClientPeg.get().setPresence({ presence: this.state });
console.info("Presence:", newState);
} catch (err) {
console.error("Failed to set presence: %s", err);
console.error("Failed to set presence:", err);
this.state = oldState;
}
}

View file

@ -20,11 +20,10 @@ limitations under the License.
* registration code.
*/
import dis from './dispatcher';
import dis from './dispatcher/dispatcher';
import * as sdk from './index';
import Modal from './Modal';
import { _t } from './languageHandler';
// import {MatrixClientPeg} from './MatrixClientPeg';
// Regex for what a "safe" or "Matrix-looking" localpart would be.
// TODO: Update as needed for https://github.com/matrix-org/matrix-doc/issues/1514
@ -44,70 +43,27 @@ export const SAFE_LOCALPART_REGEX = /^[a-z0-9=_\-./]+$/;
*/
export async function startAnyRegistrationFlow(options) {
if (options === undefined) options = {};
// look for an ILAG compatible flow. We define this as one
// which has only dummy or recaptcha flows. In practice it
// would support any stage InteractiveAuth supports, just not
// ones like email & msisdn which require the user to supply
// the relevant details in advance. We err on the side of
// caution though.
// XXX: ILAG is disabled for now,
// see https://github.com/vector-im/riot-web/issues/8222
// const flows = await _getRegistrationFlows();
// const hasIlagFlow = flows.some((flow) => {
// return flow.stages.every((stage) => {
// return ['m.login.dummy', 'm.login.recaptcha', 'm.login.terms'].includes(stage);
// });
// });
// if (hasIlagFlow) {
// dis.dispatch({
// action: 'view_set_mxid',
// go_home_on_cancel: options.go_home_on_cancel,
// });
//} else {
const QuestionDialog = sdk.getComponent("dialogs.QuestionDialog");
const modal = Modal.createTrackedDialog('Registration required', '', QuestionDialog, {
hasCancelButton: true,
quitOnly: true,
title: _t("Sign In or Create Account"),
description: _t("Use your account or create a new one to continue."),
button: _t("Create Account"),
extraButtons: [
<button key="start_login" onClick={() => {
modal.close();
dis.dispatch({action: 'start_login', screenAfterLogin: options.screen_after});
}}>{ _t('Sign In') }</button>,
],
onFinished: (proceed) => {
if (proceed) {
dis.dispatch({action: 'start_registration', screenAfterLogin: options.screen_after});
} else if (options.go_home_on_cancel) {
dis.dispatch({action: 'view_home_page'});
} else if (options.go_welcome_on_cancel) {
dis.dispatch({action: 'view_welcome_page'});
}
},
});
//}
const QuestionDialog = sdk.getComponent("dialogs.QuestionDialog");
const modal = Modal.createTrackedDialog('Registration required', '', QuestionDialog, {
hasCancelButton: true,
quitOnly: true,
title: _t("Sign In or Create Account"),
description: _t("Use your account or create a new one to continue."),
button: _t("Create Account"),
extraButtons: [
<button key="start_login" onClick={() => {
modal.close();
dis.dispatch({ action: 'start_login', screenAfterLogin: options.screen_after });
}}>{ _t('Sign In') }</button>,
],
onFinished: (proceed) => {
if (proceed) {
dis.dispatch({ action: 'start_registration', screenAfterLogin: options.screen_after });
} else if (options.go_home_on_cancel) {
dis.dispatch({ action: 'view_home_page' });
} else if (options.go_welcome_on_cancel) {
dis.dispatch({ action: 'view_welcome_page' });
}
},
});
}
// async function _getRegistrationFlows() {
// try {
// await MatrixClientPeg.get().register(
// null,
// null,
// undefined,
// {},
// {},
// );
// console.log("Register request succeeded when it should have returned 401!");
// } catch (e) {
// if (e.httpStatus === 401) {
// return e.data.flows;
// }
// throw e;
// }
// throw new Error("Register request succeeded when it should have returned 401!");
// }

View file

@ -1,6 +1,5 @@
/*
Copyright 2015, 2016 OpenMarket Ltd
Copyright 2019 The Matrix.org Foundation C.I.C.
Copyright 2015-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,37 +14,39 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import {MatrixClientPeg} from './MatrixClientPeg';
import dis from './dispatcher';
import { EventStatus } from 'matrix-js-sdk';
import { MatrixEvent, EventStatus } from 'matrix-js-sdk/src/models/event';
import { Room } from 'matrix-js-sdk/src/models/room';
import { MatrixClientPeg } from './MatrixClientPeg';
import dis from './dispatcher/dispatcher';
export default class Resend {
static resendUnsentEvents(room) {
room.getPendingEvents().filter(function(ev) {
static resendUnsentEvents(room: Room): Promise<void[]> {
return Promise.all(room.getPendingEvents().filter(function(ev: MatrixEvent) {
return ev.status === EventStatus.NOT_SENT;
}).forEach(function(event) {
Resend.resend(event);
});
}).map(function(event: MatrixEvent) {
return Resend.resend(event);
}));
}
static cancelUnsentEvents(room) {
room.getPendingEvents().filter(function(ev) {
static cancelUnsentEvents(room: Room): void {
room.getPendingEvents().filter(function(ev: MatrixEvent) {
return ev.status === EventStatus.NOT_SENT;
}).forEach(function(event) {
}).forEach(function(event: MatrixEvent) {
Resend.removeFromQueue(event);
});
}
static resend(event) {
static resend(event: MatrixEvent): Promise<void> {
const room = MatrixClientPeg.get().getRoom(event.getRoomId());
MatrixClientPeg.get().resendEvent(event, room).then(function(res) {
return MatrixClientPeg.get().resendEvent(event, room).then(function(res) {
dis.dispatch({
action: 'message_sent',
event: event,
});
}, function(err) {
}, function(err: Error) {
// XXX: temporary logging to try to diagnose
// https://github.com/vector-im/riot-web/issues/3148
// https://github.com/vector-im/element-web/issues/3148
console.log('Resend got send failure: ' + err.name + '(' + err + ')');
dis.dispatch({
@ -55,7 +56,7 @@ export default class Resend {
});
}
static removeFromQueue(event) {
static removeFromQueue(event: MatrixEvent): void {
MatrixClientPeg.get().cancelPendingEvent(event);
}
}

View file

@ -13,9 +13,10 @@ 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 { _t } from './languageHandler';
export function levelRoleMap(usersDefault) {
export function levelRoleMap(usersDefault: number) {
return {
undefined: _t('Default'),
0: _t('Restricted'),
@ -25,11 +26,11 @@ export function levelRoleMap(usersDefault) {
};
}
export function textualPowerLevel(level, usersDefault) {
export function textualPowerLevel(level: number, usersDefault: number): string {
const LEVEL_ROLE_MAP = levelRoleMap(usersDefault);
if (LEVEL_ROLE_MAP[level]) {
return LEVEL_ROLE_MAP[level];
} else {
return _t("Custom (%(level)s)", {level});
return _t("Custom (%(level)s)", { level });
}
}

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.
@ -24,12 +24,12 @@ limitations under the License.
* A similar thing could also be achieved via `pushState` with a state object,
* but keeping it separate like this seems easier in case we do want to extend.
*/
const aliasToIDMap = new Map();
const aliasToIDMap = new Map<string, string>();
export function storeRoomAliasInCache(alias, id) {
export function storeRoomAliasInCache(alias: string, id: string): void {
aliasToIDMap.set(alias, id);
}
export function getCachedRoomIDForAlias(alias) {
export function getCachedRoomIDForAlias(alias: string): string {
return aliasToIDMap.get(alias);
}

View file

@ -1,125 +0,0 @@
/*
Copyright 2016 OpenMarket Ltd
Copyright 2017, 2018 New Vector 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.
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 {MatrixClientPeg} from './MatrixClientPeg';
import MultiInviter from './utils/MultiInviter';
import Modal from './Modal';
import * as sdk from './';
import { _t } from './languageHandler';
import {KIND_DM, KIND_INVITE} from "./components/views/dialogs/InviteDialog";
/**
* Invites multiple addresses to a room
* Simpler interface to utils/MultiInviter but with
* no option to cancel.
*
* @param {string} roomId The ID of the room to invite to
* @param {string[]} addrs Array of strings of addresses to invite. May be matrix IDs or 3pids.
* @returns {Promise} Promise
*/
export function inviteMultipleToRoom(roomId, addrs) {
const inviter = new MultiInviter(roomId);
return inviter.invite(addrs).then(states => Promise.resolve({states, inviter}));
}
export function showStartChatInviteDialog() {
// This dialog handles the room creation internally - we don't need to worry about it.
const InviteDialog = sdk.getComponent("dialogs.InviteDialog");
Modal.createTrackedDialog(
'Start DM', '', InviteDialog, {kind: KIND_DM},
/*className=*/null, /*isPriority=*/false, /*isStatic=*/true,
);
}
export function showRoomInviteDialog(roomId) {
// This dialog handles the room creation internally - we don't need to worry about it.
const InviteDialog = sdk.getComponent("dialogs.InviteDialog");
Modal.createTrackedDialog(
'Invite Users', '', InviteDialog, {kind: KIND_INVITE, roomId},
/*className=*/null, /*isPriority=*/false, /*isStatic=*/true,
);
}
/**
* Checks if the given MatrixEvent is a valid 3rd party user invite.
* @param {MatrixEvent} event The event to check
* @returns {boolean} True if valid, false otherwise
*/
export function isValid3pidInvite(event) {
if (!event || event.getType() !== "m.room.third_party_invite") return false;
// any events without these keys are not valid 3pid invites, so we ignore them
const requiredKeys = ['key_validity_url', 'public_key', 'display_name'];
for (let i = 0; i < requiredKeys.length; ++i) {
if (!event.getContent()[requiredKeys[i]]) return false;
}
// Valid enough by our standards
return true;
}
export function inviteUsersToRoom(roomId, userIds) {
return inviteMultipleToRoom(roomId, userIds).then((result) => {
const room = MatrixClientPeg.get().getRoom(roomId);
return _showAnyInviteErrors(result.states, room, result.inviter);
}).catch((err) => {
console.error(err.stack);
const ErrorDialog = sdk.getComponent("dialogs.ErrorDialog");
Modal.createTrackedDialog('Failed to invite', '', ErrorDialog, {
title: _t("Failed to invite"),
description: ((err && err.message) ? err.message : _t("Operation failed")),
});
});
}
function _showAnyInviteErrors(addrs, room, inviter) {
// Show user any errors
const failedUsers = Object.keys(addrs).filter(a => addrs[a] === 'error');
if (failedUsers.length === 1 && inviter.fatal) {
// Just get the first message because there was a fatal problem on the first
// user. This usually means that no other users were attempted, making it
// pointless for us to list who failed exactly.
const ErrorDialog = sdk.getComponent("dialogs.ErrorDialog");
Modal.createTrackedDialog('Failed to invite users to the room', '', ErrorDialog, {
title: _t("Failed to invite users to the room:", {roomName: room.name}),
description: inviter.getErrorText(failedUsers[0]),
});
} else {
const errorList = [];
for (const addr of failedUsers) {
if (addrs[addr] === "error") {
const reason = inviter.getErrorText(addr);
errorList.push(addr + ": " + reason);
}
}
if (errorList.length > 0) {
// React 16 doesn't let us use `errorList.join(<br />)` anymore, so this is our solution
const description = <div>{errorList.map(e => <div key={e}>{e}</div>)}</div>;
const ErrorDialog = sdk.getComponent("dialogs.ErrorDialog");
Modal.createTrackedDialog('Failed to invite the following users to the room', '', ErrorDialog, {
title: _t("Failed to invite the following users to the %(roomName)s room:", {roomName: room.name}),
description,
});
}
}
return addrs;
}

187
src/RoomInvite.tsx Normal file
View file

@ -0,0 +1,187 @@
/*
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 React from "react";
import { Room } from "matrix-js-sdk/src/models/room";
import { MatrixEvent } from "matrix-js-sdk/src/models/event";
import { User } from "matrix-js-sdk/src/models/user";
import { MatrixClientPeg } from './MatrixClientPeg';
import MultiInviter, { CompletionStates } from './utils/MultiInviter';
import Modal from './Modal';
import { _t } from './languageHandler';
import InviteDialog, { KIND_DM, KIND_INVITE, Member } from "./components/views/dialogs/InviteDialog";
import CommunityPrototypeInviteDialog from "./components/views/dialogs/CommunityPrototypeInviteDialog";
import { CommunityPrototypeStore } from "./stores/CommunityPrototypeStore";
import BaseAvatar from "./components/views/avatars/BaseAvatar";
import { mediaFromMxc } from "./customisations/Media";
import ErrorDialog from "./components/views/dialogs/ErrorDialog";
export interface IInviteResult {
states: CompletionStates;
inviter: MultiInviter;
}
/**
* Invites multiple addresses to a room
* Simpler interface to utils/MultiInviter but with
* no option to cancel.
*
* @param {string} roomId The ID of the room to invite to
* @param {string[]} addresses Array of strings of addresses to invite. May be matrix IDs or 3pids.
* @returns {Promise} Promise
*/
export function inviteMultipleToRoom(roomId: string, addresses: string[]): Promise<IInviteResult> {
const inviter = new MultiInviter(roomId);
return inviter.invite(addresses).then(states => Promise.resolve({ states, inviter }));
}
export function showStartChatInviteDialog(initialText = ""): void {
// This dialog handles the room creation internally - we don't need to worry about it.
Modal.createTrackedDialog(
'Start DM', '', InviteDialog, { kind: KIND_DM, initialText },
/*className=*/null, /*isPriority=*/false, /*isStatic=*/true,
);
}
export function showRoomInviteDialog(roomId: string, initialText = ""): void {
// This dialog handles the room creation internally - we don't need to worry about it.
Modal.createTrackedDialog(
"Invite Users", "", InviteDialog, {
kind: KIND_INVITE,
initialText,
roomId,
},
/*className=*/null, /*isPriority=*/false, /*isStatic=*/true,
);
}
export function showCommunityRoomInviteDialog(roomId: string, communityName: string): void {
Modal.createTrackedDialog(
'Invite Users to Community', '', CommunityPrototypeInviteDialog, { communityName, roomId },
/*className=*/null, /*isPriority=*/false, /*isStatic=*/true,
);
}
export function showCommunityInviteDialog(communityId: string): void {
const chat = CommunityPrototypeStore.instance.getGeneralChat(communityId);
if (chat) {
const name = CommunityPrototypeStore.instance.getCommunityName(communityId);
showCommunityRoomInviteDialog(chat.roomId, name);
} else {
throw new Error("Failed to locate appropriate room to start an invite in");
}
}
/**
* Checks if the given MatrixEvent is a valid 3rd party user invite.
* @param {MatrixEvent} event The event to check
* @returns {boolean} True if valid, false otherwise
*/
export function isValid3pidInvite(event: MatrixEvent): boolean {
if (!event || event.getType() !== "m.room.third_party_invite") return false;
// any events without these keys are not valid 3pid invites, so we ignore them
const requiredKeys = ['key_validity_url', 'public_key', 'display_name'];
for (let i = 0; i < requiredKeys.length; ++i) {
if (!event.getContent()[requiredKeys[i]]) return false;
}
// Valid enough by our standards
return true;
}
export function inviteUsersToRoom(roomId: string, userIds: string[]): Promise<void> {
return inviteMultipleToRoom(roomId, userIds).then((result) => {
const room = MatrixClientPeg.get().getRoom(roomId);
showAnyInviteErrors(result.states, room, result.inviter);
}).catch((err) => {
console.error(err.stack);
Modal.createTrackedDialog('Failed to invite', '', ErrorDialog, {
title: _t("Failed to invite"),
description: ((err && err.message) ? err.message : _t("Operation failed")),
});
});
}
export function showAnyInviteErrors(
states: CompletionStates,
room: Room,
inviter: MultiInviter,
userMap?: Map<string, Member>,
): boolean {
// Show user any errors
const failedUsers = Object.keys(states).filter(a => states[a] === 'error');
if (failedUsers.length === 1 && inviter.fatal) {
// Just get the first message because there was a fatal problem on the first
// user. This usually means that no other users were attempted, making it
// pointless for us to list who failed exactly.
Modal.createTrackedDialog('Failed to invite users to the room', '', ErrorDialog, {
title: _t("Failed to invite users to the room:", { roomName: room.name }),
description: inviter.getErrorText(failedUsers[0]),
});
return false;
} else {
const errorList = [];
for (const addr of failedUsers) {
if (states[addr] === "error") {
const reason = inviter.getErrorText(addr);
errorList.push(addr + ": " + reason);
}
}
const cli = MatrixClientPeg.get();
if (errorList.length > 0) {
// React 16 doesn't let us use `errorList.join(<br />)` anymore, so this is our solution
const description = <div className="mx_InviteDialog_multiInviterError">
<h4>{ _t("We sent the others, but the below people couldn't be invited to <RoomName/>", {}, {
RoomName: () => <b>{ room.name }</b>,
}) }</h4>
<div>
{ failedUsers.map(addr => {
const user = userMap?.get(addr) || cli.getUser(addr);
const name = (user as Member).name || (user as User).rawDisplayName;
const avatarUrl = (user as Member).getMxcAvatarUrl?.() || (user as User).avatarUrl;
return <div key={addr} className="mx_InviteDialog_multiInviterError_entry">
<div className="mx_InviteDialog_multiInviterError_entry_userProfile">
<BaseAvatar
url={avatarUrl ? mediaFromMxc(avatarUrl).getSquareThumbnailHttp(24) : null}
name={name}
idName={user.userId}
width={24}
height={24}
/>
<span className="mx_InviteDialog_multiInviterError_entry_name">{ name }</span>
<span className="mx_InviteDialog_multiInviterError_entry_userId">{ user.userId }</span>
</div>
<div className="mx_InviteDialog_multiInviterError_entry_error">
{ inviter.getErrorText(addr) }
</div>
</div>;
}) }
</div>
</div>;
Modal.createTrackedDialog("Some invites could not be sent", "", ErrorDialog, {
title: _t("Some invites couldn't be sent"),
description,
});
return false;
}
}
return true;
}

View file

@ -15,8 +15,8 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import {MatrixClientPeg} from './MatrixClientPeg';
import {PushProcessor} from 'matrix-js-sdk/src/pushprocessor';
import { MatrixClientPeg } from './MatrixClientPeg';
import { PushProcessor } from 'matrix-js-sdk/src/pushprocessor';
export const ALL_MESSAGES_LOUD = 'all_messages_loud';
export const ALL_MESSAGES = 'all_messages';
@ -34,32 +34,12 @@ export function shouldShowMentionBadge(roomNotifState) {
return MENTION_BADGE_STATES.includes(roomNotifState);
}
export function countRoomsWithNotif(rooms) {
return rooms.reduce((result, room, index) => {
const roomNotifState = getRoomNotifsState(room.roomId);
const highlight = room.getUnreadNotificationCount('highlight') > 0;
const notificationCount = room.getUnreadNotificationCount();
const notifBadges = notificationCount > 0 && shouldShowNotifBadge(roomNotifState);
const mentionBadges = highlight && shouldShowMentionBadge(roomNotifState);
const isInvite = room.hasMembershipState(MatrixClientPeg.get().credentials.userId, 'invite');
const badges = notifBadges || mentionBadges || isInvite;
if (badges) {
result.count++;
if (highlight) {
result.highlight = true;
}
}
return result;
}, {count: 0, highlight: false});
}
export function aggregateNotificationCount(rooms) {
return rooms.reduce((result, room, index) => {
return rooms.reduce((result, room) => {
const roomNotifState = getRoomNotifsState(room.roomId);
const highlight = room.getUnreadNotificationCount('highlight') > 0;
const notificationCount = room.getUnreadNotificationCount();
// use helper method to include highlights in the previous version of the room
const notificationCount = getUnreadNotificationCount(room);
const notifBadges = notificationCount > 0 && shouldShowNotifBadge(roomNotifState);
const mentionBadges = highlight && shouldShowMentionBadge(roomNotifState);
@ -72,7 +52,7 @@ export function aggregateNotificationCount(rooms) {
}
}
return result;
}, {count: 0, highlight: false});
}, { count: 0, highlight: false });
}
export function getRoomHasBadge(room) {
@ -222,12 +202,13 @@ function setRoomNotifsStateUnmuted(roomId, newState) {
}
function findOverrideMuteRule(roomId) {
if (!MatrixClientPeg.get().pushRules ||
!MatrixClientPeg.get().pushRules['global'] ||
!MatrixClientPeg.get().pushRules['global'].override) {
const cli = MatrixClientPeg.get();
if (!cli.pushRules ||
!cli.pushRules['global'] ||
!cli.pushRules['global'].override) {
return null;
}
for (const rule of MatrixClientPeg.get().pushRules['global'].override) {
for (const rule of cli.pushRules['global'].override) {
if (isRuleForRoom(roomId, rule)) {
if (isMuteRule(rule) && rule.enabled) {
return rule;

24
src/RoomNotifsTypes.ts Normal file
View file

@ -0,0 +1,24 @@
/*
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 {
ALL_MESSAGES,
ALL_MESSAGES_LOUD,
MENTIONS_ONLY,
MUTE,
} from "./RoomNotifs";
export type Volume = ALL_MESSAGES_LOUD | ALL_MESSAGES | MENTIONS_ONLY | MUTE;

View file

@ -1,5 +1,5 @@
/*
Copyright 2015, 2016 OpenMarket Ltd
Copyright 2015 - 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,71 +14,36 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import {MatrixClientPeg} from './MatrixClientPeg';
import { Room } from "matrix-js-sdk/src/models/room";
import { MatrixClientPeg } from './MatrixClientPeg';
import AliasCustomisations from './customisations/Alias';
/**
* Given a room object, return the alias we should use for it,
* if any. This could be the canonical alias if one exists, otherwise
* an alias selected arbitrarily but deterministically from the list
* of aliases. Otherwise return null;
*
* @param {Object} room The room object
* @returns {string} A display alias for the given room
*/
export function getDisplayAliasForRoom(room) {
return room.getCanonicalAlias() || room.getAltAliases()[0];
export function getDisplayAliasForRoom(room: Room): string {
return getDisplayAliasForAliasSet(
room.getCanonicalAlias(), room.getAltAliases(),
);
}
/**
* If the room contains only two members including the logged-in user,
* return the other one. Otherwise, return null.
*/
export function getOnlyOtherMember(room, myUserId) {
if (room.currentState.getJoinedMemberCount() === 2) {
return room.getJoinedMembers().filter(function(m) {
return m.userId !== myUserId;
})[0];
// The various display alias getters should all feed through this one path so
// there's a single place to change the logic.
export function getDisplayAliasForAliasSet(canonicalAlias: string, altAliases: string[]): string {
if (AliasCustomisations.getDisplayAliasForAliasSet) {
return AliasCustomisations.getDisplayAliasForAliasSet(canonicalAlias, altAliases);
}
return null;
return canonicalAlias || altAliases?.[0];
}
function _isConfCallRoom(room, myUserId, conferenceHandler) {
if (!conferenceHandler) return false;
const myMembership = room.getMyMembership();
if (myMembership != "join") {
return false;
}
const otherMember = getOnlyOtherMember(room, myUserId);
if (!otherMember) {
return false;
}
if (conferenceHandler.isConferenceUser(otherMember.userId)) {
return true;
}
return false;
}
// Cache whether a room is a conference call. Assumes that rooms will always
// either will or will not be a conference call room.
const isConfCallRoomCache = {
// $roomId: bool
};
export function isConfCallRoom(room, myUserId, conferenceHandler) {
if (isConfCallRoomCache[room.roomId] !== undefined) {
return isConfCallRoomCache[room.roomId];
}
const result = _isConfCallRoom(room, myUserId, conferenceHandler);
isConfCallRoomCache[room.roomId] = result;
return result;
}
export function looksLikeDirectMessageRoom(room, myUserId) {
export function looksLikeDirectMessageRoom(room: Room, myUserId: string): boolean {
const myMembership = room.getMyMembership();
const me = room.getMember(myUserId);
@ -97,7 +62,7 @@ export function looksLikeDirectMessageRoom(room, myUserId) {
return false;
}
export function guessAndSetDMRoom(room, isDirect) {
export function guessAndSetDMRoom(room: Room, isDirect: boolean): Promise<void> {
let newTarget;
if (isDirect) {
const guessedUserId = guessDMRoomTargetId(
@ -119,10 +84,8 @@ export function guessAndSetDMRoom(room, isDirect) {
this room as a DM room
* @returns {object} A promise
*/
export function setDMRoom(roomId, userId) {
if (MatrixClientPeg.get().isGuest()) {
return Promise.resolve();
}
export async function setDMRoom(roomId: string, userId: string): Promise<void> {
if (MatrixClientPeg.get().isGuest()) return;
const mDirectEvent = MatrixClientPeg.get().getAccountData('m.direct');
let dmRoomMap = {};
@ -151,8 +114,7 @@ export function setDMRoom(roomId, userId) {
dmRoomMap[userId] = roomList;
}
return MatrixClientPeg.get().setAccountData('m.direct', dmRoomMap);
await MatrixClientPeg.get().setAccountData('m.direct', dmRoomMap);
}
/**
@ -163,7 +125,7 @@ export function setDMRoom(roomId, userId) {
* @param {string} myUserId User ID of the current user
* @returns {string} User ID of the user that the room is probably a DM with
*/
function guessDMRoomTargetId(room, myUserId) {
function guessDMRoomTargetId(room: Room, myUserId: string): string {
let oldestTs;
let oldestUser;

View file

@ -1,6 +1,5 @@
/*
Copyright 2016 OpenMarket Ltd
Copyright 2019 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.
@ -17,20 +16,26 @@ limitations under the License.
import url from 'url';
import SettingsStore from "./settings/SettingsStore";
import { Service, startTermsFlow, TermsNotSignedError } from './Terms';
import {MatrixClientPeg} from "./MatrixClientPeg";
import { Service, startTermsFlow, TermsInteractionCallback, TermsNotSignedError } from './Terms';
import { MatrixClientPeg } from "./MatrixClientPeg";
import request from "browser-request";
import * as Matrix from 'matrix-js-sdk';
import SdkConfig from "./SdkConfig";
import { WidgetType } from "./widgets/WidgetType";
import { SERVICE_TYPES } from "matrix-js-sdk/src/service-types";
import { Room } from "matrix-js-sdk/src/models/room";
// The version of the integration manager API we're intending to work with
const imApiVersion = "1.1";
// TODO: Generify the name of this class and all components within - it's not just for Scalar.
export default class ScalarAuthClient {
constructor(apiUrl, uiUrl) {
this.apiUrl = apiUrl;
this.uiUrl = uiUrl;
private scalarToken: string;
private termsInteractionCallback: TermsInteractionCallback;
private isDefaultManager: boolean;
constructor(private apiUrl: string, private uiUrl: string) {
this.scalarToken = null;
// `undefined` to allow `startTermsFlow` to fallback to a default
// callback if this is unset.
@ -43,7 +48,7 @@ export default class ScalarAuthClient {
this.isDefaultManager = apiUrl === configApiUrl && configUiUrl === uiUrl;
}
_writeTokenToStore() {
private writeTokenToStore() {
window.localStorage.setItem("mx_scalar_token_at_" + this.apiUrl, this.scalarToken);
if (this.isDefaultManager) {
// We remove the old token from storage to migrate upwards. This is safe
@ -53,7 +58,7 @@ export default class ScalarAuthClient {
}
}
_readTokenFromStore() {
private readTokenFromStore(): string {
let token = window.localStorage.getItem("mx_scalar_token_at_" + this.apiUrl);
if (!token && this.isDefaultManager) {
token = window.localStorage.getItem("mx_scalar_token");
@ -61,33 +66,33 @@ export default class ScalarAuthClient {
return token;
}
_readToken() {
private readToken(): string {
if (this.scalarToken) return this.scalarToken;
return this._readTokenFromStore();
return this.readTokenFromStore();
}
setTermsInteractionCallback(callback) {
this.termsInteractionCallback = callback;
}
connect() {
connect(): Promise<void> {
return this.getScalarToken().then((tok) => {
this.scalarToken = tok;
});
}
hasCredentials() {
hasCredentials(): boolean {
return this.scalarToken != null; // undef or null
}
// Returns a promise that resolves to a scalar_token string
getScalarToken() {
const token = this._readToken();
getScalarToken(): Promise<string> {
const token = this.readToken();
if (!token) {
return this.registerForToken();
} else {
return this._checkToken(token).catch((e) => {
return this.checkToken(token).catch((e) => {
if (e instanceof TermsNotSignedError) {
// retrying won't help this
throw e;
@ -97,14 +102,14 @@ export default class ScalarAuthClient {
}
}
_getAccountName(token) {
private getAccountName(token: string): Promise<string> {
const url = this.apiUrl + "/account";
return new Promise(function(resolve, reject) {
request({
method: "GET",
uri: url,
qs: {scalar_token: token, v: imApiVersion},
qs: { scalar_token: token, v: imApiVersion },
json: true,
}, (err, response, body) => {
if (err) {
@ -122,8 +127,8 @@ export default class ScalarAuthClient {
});
}
_checkToken(token) {
return this._getAccountName(token).then(userId => {
private checkToken(token: string): Promise<string> {
return this.getAccountName(token).then(userId => {
const me = MatrixClientPeg.get().getUserId();
if (userId !== me) {
throw new Error("Scalar token is owned by someone else: " + me);
@ -150,8 +155,8 @@ export default class ScalarAuthClient {
parsedImRestUrl.path = '';
parsedImRestUrl.pathname = '';
return startTermsFlow([new Service(
Matrix.SERVICE_TYPES.IM,
parsedImRestUrl.format(),
SERVICE_TYPES.IM,
url.format(parsedImRestUrl),
token,
)], this.termsInteractionCallback).then(() => {
return token;
@ -162,36 +167,36 @@ export default class ScalarAuthClient {
});
}
registerForToken() {
registerForToken(): Promise<string> {
// Get openid bearer token from the HS as the first part of our dance
return MatrixClientPeg.get().getOpenIdToken().then((tokenObject) => {
// Now we can send that to scalar and exchange it for a scalar token
return this.exchangeForScalarToken(tokenObject);
}).then((token) => {
// Validate it (this mostly checks to see if the IM needs us to agree to some terms)
return this._checkToken(token);
return this.checkToken(token);
}).then((token) => {
this.scalarToken = token;
this._writeTokenToStore();
this.writeTokenToStore();
return token;
});
}
exchangeForScalarToken(openidTokenObject) {
exchangeForScalarToken(openidTokenObject: any): Promise<string> {
const scalarRestUrl = this.apiUrl;
return new Promise(function(resolve, reject) {
request({
method: 'POST',
uri: scalarRestUrl + '/register',
qs: {v: imApiVersion},
qs: { v: imApiVersion },
body: openidTokenObject,
json: true,
}, (err, response, body) => {
if (err) {
reject(err);
} else if (response.statusCode / 100 !== 2) {
reject({statusCode: response.statusCode});
reject(new Error(`Scalar request failed: ${response.statusCode}`));
} else if (!body || !body.scalar_token) {
reject(new Error("Missing scalar_token in response"));
} else {
@ -201,7 +206,7 @@ export default class ScalarAuthClient {
});
}
getScalarPageTitle(url) {
getScalarPageTitle(url: string): Promise<string> {
let scalarPageLookupUrl = this.apiUrl + '/widgets/title_lookup';
scalarPageLookupUrl = this.getStarterLink(scalarPageLookupUrl);
scalarPageLookupUrl += '&curl=' + encodeURIComponent(url);
@ -215,7 +220,7 @@ export default class ScalarAuthClient {
if (err) {
reject(err);
} else if (response.statusCode / 100 !== 2) {
reject({statusCode: response.statusCode});
reject(new Error(`Scalar request failed: ${response.statusCode}`));
} else if (!body) {
reject(new Error("Missing page title in response"));
} else {
@ -233,20 +238,20 @@ export default class ScalarAuthClient {
* Mark all assets associated with the specified widget as "disabled" in the
* integration manager database.
* This can be useful to temporarily prevent purchased assets from being displayed.
* @param {string} widgetType [description]
* @param {string} widgetId [description]
* @param {WidgetType} widgetType The Widget Type to disable assets for
* @param {string} widgetId The widget ID to disable assets for
* @return {Promise} Resolves on completion
*/
disableWidgetAssets(widgetType, widgetId) {
disableWidgetAssets(widgetType: WidgetType, widgetId: string): Promise<void> {
let url = this.apiUrl + '/widgets/set_assets_state';
url = this.getStarterLink(url);
return new Promise((resolve, reject) => {
return new Promise<void>((resolve, reject) => {
request({
method: 'GET',
method: 'GET', // XXX: Actions shouldn't be GET requests
uri: url,
json: true,
qs: {
'widget_type': widgetType,
'widget_type': widgetType.preferred,
'widget_id': widgetId,
'state': 'disable',
},
@ -254,7 +259,7 @@ export default class ScalarAuthClient {
if (err) {
reject(err);
} else if (response.statusCode / 100 !== 2) {
reject({statusCode: response.statusCode});
reject(new Error(`Scalar request failed: ${response.statusCode}`));
} else if (!body) {
reject(new Error("Failed to set widget assets state"));
} else {
@ -264,7 +269,7 @@ export default class ScalarAuthClient {
});
}
getScalarInterfaceUrlForRoom(room, screen, id) {
getScalarInterfaceUrlForRoom(room: Room, screen: string, id: string): string {
const roomId = room.roomId;
const roomName = room.name;
let url = this.uiUrl;
@ -281,7 +286,7 @@ export default class ScalarAuthClient {
return url;
}
getStarterLink(starterLinkUrl) {
getStarterLink(starterLinkUrl: string): string {
return starterLinkUrl + "?scalar_token=" + encodeURIComponent(this.scalarToken);
}
}

View file

@ -16,6 +16,8 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
// TODO: Generify the name of this and all components within - it's not just for scalar.
/*
Listens for incoming postMessage requests from the integrations UI URL. The following API is exposed:
{
@ -172,6 +174,7 @@ Request:
Response:
[
{
// TODO: Enable support for m.widget event type (https://github.com/vector-im/element-web/issues/13111)
type: "im.vector.modular.widgets",
state_key: "wid1",
content: {
@ -190,6 +193,7 @@ Example:
room_id: "!foo:bar",
response: [
{
// TODO: Enable support for m.widget event type (https://github.com/vector-im/element-web/issues/13111)
type: "im.vector.modular.widgets",
state_key: "wid1",
content: {
@ -204,7 +208,6 @@ Example:
]
}
membership_state AND bot_options
--------------------------------
Get the content of the "m.room.member" or "m.room.bot.options" state event respectively.
@ -232,23 +235,25 @@ Example:
}
*/
import {MatrixClientPeg} from './MatrixClientPeg';
import { MatrixEvent } from 'matrix-js-sdk';
import dis from './dispatcher';
import { MatrixClientPeg } from './MatrixClientPeg';
import { MatrixEvent } from 'matrix-js-sdk/src/models/event';
import dis from './dispatcher/dispatcher';
import WidgetUtils from './utils/WidgetUtils';
import RoomViewStore from './stores/RoomViewStore';
import { _t } from './languageHandler';
import {IntegrationManagers} from "./integrations/IntegrationManagers";
import { IntegrationManagers } from "./integrations/IntegrationManagers";
import { WidgetType } from "./widgets/WidgetType";
import { objectClone } from "./utils/objects";
function sendResponse(event, res) {
const data = JSON.parse(JSON.stringify(event.data));
const data = objectClone(event.data);
data.response = res;
event.source.postMessage(data, event.origin);
}
function sendError(event, msg, nestedError) {
console.error("Action:" + event.data.action + " failed with message: " + msg);
const data = JSON.parse(JSON.stringify(event.data));
const data = objectClone(event.data);
data.response = {
error: {
message: msg,
@ -290,7 +295,7 @@ function inviteUser(event, roomId, userId) {
function setWidget(event, roomId) {
const widgetId = event.data.widget_id;
const widgetType = event.data.type;
let widgetType = event.data.type;
const widgetUrl = event.data.url;
const widgetName = event.data.name; // optional
const widgetData = event.data.data; // optional
@ -322,6 +327,9 @@ function setWidget(event, roomId) {
}
}
// convert the widget type to a known widget type
widgetType = WidgetType.fromString(widgetType);
if (userWidget) {
WidgetUtils.setUserWidget(widgetId, widgetType, widgetUrl, widgetName, widgetData).then(() => {
sendResponse(event, {
@ -599,7 +607,7 @@ const onMessage = function(event) {
}
if (roomId !== RoomViewStore.getRoomId()) {
sendError(event, _t('Room %(roomId)s not visible', {roomId: roomId}));
sendError(event, _t('Room %(roomId)s not visible', { roomId: roomId }));
return;
}

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.
@ -20,6 +20,8 @@ export interface ConfigOptions {
}
export const DEFAULTS: ConfigOptions = {
// Brand name of the app
brand: "Element",
// URL to a page we show in an iframe to configure integrations
integrations_ui_url: "https://scalar.vector.im/",
// Base URL to the REST interface of the integrations server
@ -31,6 +33,11 @@ export const DEFAULTS: ConfigOptions = {
// Default conference domain
preferredDomain: "jitsi.riot.im",
},
desktopBuilds: {
available: true,
logo: require("../res/img/element-desktop-logo.svg"),
url: "https://element.io/get-started",
},
};
export default class SdkConfig {

View file

@ -1,140 +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.
*/
import EventIndexPeg from "./indexing/EventIndexPeg";
import {MatrixClientPeg} from "./MatrixClientPeg";
function serverSideSearch(term, roomId = undefined) {
let filter;
if (roomId !== undefined) {
// XXX: it's unintuitive that the filter for searching doesn't have
// the same shape as the v2 filter API :(
filter = {
rooms: [roomId],
};
}
const searchPromise = MatrixClientPeg.get().searchRoomEvents({
filter,
term,
});
return searchPromise;
}
async function combinedSearch(searchTerm) {
// Create two promises, one for the local search, one for the
// server-side search.
const serverSidePromise = serverSideSearch(searchTerm);
const localPromise = localSearch(searchTerm);
// Wait for both promises to resolve.
await Promise.all([serverSidePromise, localPromise]);
// Get both search results.
const localResult = await localPromise;
const serverSideResult = await serverSidePromise;
// Combine the search results into one result.
const result = {};
// Our localResult and serverSideResult are both ordered by
// recency separately, when we combine them the order might not
// be the right one so we need to sort them.
const compare = (a, b) => {
const aEvent = a.context.getEvent().event;
const bEvent = b.context.getEvent().event;
if (aEvent.origin_server_ts >
bEvent.origin_server_ts) return -1;
if (aEvent.origin_server_ts <
bEvent.origin_server_ts) return 1;
return 0;
};
result.count = localResult.count + serverSideResult.count;
result.results = localResult.results.concat(
serverSideResult.results).sort(compare);
result.highlights = localResult.highlights.concat(
serverSideResult.highlights);
return result;
}
async function localSearch(searchTerm, roomId = undefined) {
const searchArgs = {
search_term: searchTerm,
before_limit: 1,
after_limit: 1,
order_by_recency: true,
room_id: undefined,
};
if (roomId !== undefined) {
searchArgs.room_id = roomId;
}
const emptyResult = {
results: [],
highlights: [],
};
if (searchTerm === "") return emptyResult;
const eventIndex = EventIndexPeg.get();
const localResult = await eventIndex.search(searchArgs);
const response = {
search_categories: {
room_events: localResult,
},
};
const result = MatrixClientPeg.get()._processRoomEventsSearch(
emptyResult, response);
return result;
}
function eventIndexSearch(term, roomId = undefined) {
let searchPromise;
if (roomId !== undefined) {
if (MatrixClientPeg.get().isRoomEncrypted(roomId)) {
// The search is for a single encrypted room, use our local
// search method.
searchPromise = localSearch(term, roomId);
} else {
// The search is for a single non-encrypted room, use the
// server-side search.
searchPromise = serverSideSearch(term, roomId);
}
} else {
// Search across all rooms, combine a server side search and a
// local search.
searchPromise = combinedSearch(term);
}
return searchPromise;
}
export default function eventSearch(term, roomId = undefined) {
const eventIndex = EventIndexPeg.get();
if (eventIndex === null) return serverSideSearch(term, roomId);
else return eventIndexSearch(term, roomId);
}

642
src/Searching.ts Normal file
View file

@ -0,0 +1,642 @@
/*
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 {
IResultRoomEvents,
ISearchRequestBody,
ISearchResponse,
ISearchResult,
ISearchResults,
SearchOrderBy,
} from "matrix-js-sdk/src/@types/search";
import { IRoomEventFilter } from "matrix-js-sdk/src/filter";
import { EventType } from "matrix-js-sdk/src/@types/event";
import { ISearchArgs } from "./indexing/BaseEventIndexManager";
import EventIndexPeg from "./indexing/EventIndexPeg";
import { MatrixClientPeg } from "./MatrixClientPeg";
import { SearchResult } from "matrix-js-sdk/src/models/search-result";
const SEARCH_LIMIT = 10;
async function serverSideSearch(
term: string,
roomId: string = undefined,
): Promise<{ response: ISearchResponse, query: ISearchRequestBody }> {
const client = MatrixClientPeg.get();
const filter: IRoomEventFilter = {
limit: SEARCH_LIMIT,
};
if (roomId !== undefined) filter.rooms = [roomId];
const body: ISearchRequestBody = {
search_categories: {
room_events: {
search_term: term,
filter: filter,
order_by: SearchOrderBy.Recent,
event_context: {
before_limit: 1,
after_limit: 1,
include_profile: true,
},
},
},
};
const response = await client.search({ body: body });
return { response, query: body };
}
async function serverSideSearchProcess(term: string, roomId: string = undefined): Promise<ISearchResults> {
const client = MatrixClientPeg.get();
const result = await serverSideSearch(term, roomId);
// The js-sdk method backPaginateRoomEventsSearch() uses _query internally
// so we're reusing the concept here since we want to delegate the
// pagination back to backPaginateRoomEventsSearch() in some cases.
const searchResults: ISearchResults = {
_query: result.query,
results: [],
highlights: [],
};
return client.processRoomEventsSearch(searchResults, result.response);
}
function compareEvents(a: ISearchResult, b: ISearchResult): number {
const aEvent = a.result;
const bEvent = b.result;
if (aEvent.origin_server_ts > bEvent.origin_server_ts) return -1;
if (aEvent.origin_server_ts < bEvent.origin_server_ts) return 1;
return 0;
}
async function combinedSearch(searchTerm: string): Promise<ISearchResults> {
const client = MatrixClientPeg.get();
// Create two promises, one for the local search, one for the
// server-side search.
const serverSidePromise = serverSideSearch(searchTerm);
const localPromise = localSearch(searchTerm);
// Wait for both promises to resolve.
await Promise.all([serverSidePromise, localPromise]);
// Get both search results.
const localResult = await localPromise;
const serverSideResult = await serverSidePromise;
const serverQuery = serverSideResult.query;
const serverResponse = serverSideResult.response;
const localQuery = localResult.query;
const localResponse = localResult.response;
// Store our queries for later on so we can support pagination.
//
// We're reusing _query here again to not introduce separate code paths and
// concepts for our different pagination methods. We're storing the
// server-side next batch separately since the query is the json body of
// the request and next_batch needs to be a query parameter.
//
// We can't put it in the final result that _processRoomEventsSearch()
// returns since that one can be either a server-side one, a local one or a
// fake one to fetch the remaining cached events. See the docs for
// combineEvents() for an explanation why we need to cache events.
const emptyResult: ISeshatSearchResults = {
seshatQuery: localQuery,
_query: serverQuery,
serverSideNextBatch: serverResponse.search_categories.room_events.next_batch,
cachedEvents: [],
oldestEventFrom: "server",
results: [],
highlights: [],
};
// Combine our results.
const combinedResult = combineResponses(emptyResult, localResponse, serverResponse.search_categories.room_events);
// Let the client process the combined result.
const response: ISearchResponse = {
search_categories: {
room_events: combinedResult,
},
};
const result = client.processRoomEventsSearch(emptyResult, response);
// Restore our encryption info so we can properly re-verify the events.
restoreEncryptionInfo(result.results);
return result;
}
async function localSearch(
searchTerm: string,
roomId: string = undefined,
processResult = true,
): Promise<{ response: IResultRoomEvents, query: ISearchArgs }> {
const eventIndex = EventIndexPeg.get();
const searchArgs: ISearchArgs = {
search_term: searchTerm,
before_limit: 1,
after_limit: 1,
limit: SEARCH_LIMIT,
order_by_recency: true,
room_id: undefined,
};
if (roomId !== undefined) {
searchArgs.room_id = roomId;
}
const localResult = await eventIndex.search(searchArgs);
searchArgs.next_batch = localResult.next_batch;
const result = {
response: localResult,
query: searchArgs,
};
return result;
}
export interface ISeshatSearchResults extends ISearchResults {
seshatQuery?: ISearchArgs;
cachedEvents?: ISearchResult[];
oldestEventFrom?: "local" | "server";
serverSideNextBatch?: string;
}
async function localSearchProcess(searchTerm: string, roomId: string = undefined): Promise<ISeshatSearchResults> {
const emptyResult = {
results: [],
highlights: [],
} as ISeshatSearchResults;
if (searchTerm === "") return emptyResult;
const result = await localSearch(searchTerm, roomId);
emptyResult.seshatQuery = result.query;
const response: ISearchResponse = {
search_categories: {
room_events: result.response,
},
};
const processedResult = MatrixClientPeg.get().processRoomEventsSearch(emptyResult, response);
// Restore our encryption info so we can properly re-verify the events.
restoreEncryptionInfo(processedResult.results);
return processedResult;
}
async function localPagination(searchResult: ISeshatSearchResults): Promise<ISeshatSearchResults> {
const eventIndex = EventIndexPeg.get();
const searchArgs = searchResult.seshatQuery;
const localResult = await eventIndex.search(searchArgs);
searchResult.seshatQuery.next_batch = localResult.next_batch;
// We only need to restore the encryption state for the new results, so
// remember how many of them we got.
const newResultCount = localResult.results.length;
const response = {
search_categories: {
room_events: localResult,
},
};
const result = MatrixClientPeg.get().processRoomEventsSearch(searchResult, response);
// Restore our encryption info so we can properly re-verify the events.
const newSlice = result.results.slice(Math.max(result.results.length - newResultCount, 0));
restoreEncryptionInfo(newSlice);
searchResult.pendingRequest = null;
return result;
}
function compareOldestEvents(firstResults: ISearchResult[], secondResults: ISearchResult[]): number {
try {
const oldestFirstEvent = firstResults[firstResults.length - 1].result;
const oldestSecondEvent = secondResults[secondResults.length - 1].result;
if (oldestFirstEvent.origin_server_ts <= oldestSecondEvent.origin_server_ts) {
return -1;
} else {
return 1;
}
} catch {
return 0;
}
}
function combineEventSources(
previousSearchResult: ISeshatSearchResults,
response: IResultRoomEvents,
a: ISearchResult[],
b: ISearchResult[],
): void {
// Merge event sources and sort the events.
const combinedEvents = a.concat(b).sort(compareEvents);
// Put half of the events in the response, and cache the other half.
response.results = combinedEvents.slice(0, SEARCH_LIMIT);
previousSearchResult.cachedEvents = combinedEvents.slice(SEARCH_LIMIT);
}
/**
* Combine the events from our event sources into a sorted result
*
* This method will first be called from the combinedSearch() method. In this
* case we will fetch SEARCH_LIMIT events from the server and the local index.
*
* The method will put the SEARCH_LIMIT newest events from the server and the
* local index in the results part of the response, the rest will be put in the
* cachedEvents field of the previousSearchResult (in this case an empty search
* result).
*
* Every subsequent call will be made from the combinedPagination() method, in
* this case we will combine the cachedEvents and the next SEARCH_LIMIT events
* from either the server or the local index.
*
* Since we have two event sources and we need to sort the results by date we
* need keep on looking for the oldest event. We are implementing a variation of
* a sliding window.
*
* The event sources are here represented as two sorted lists where the smallest
* number represents the newest event. The two lists need to be merged in a way
* that preserves the sorted property so they can be shown as one search result.
* We first fetch SEARCH_LIMIT events from both sources.
*
* If we set SEARCH_LIMIT to 3:
*
* Server events [01, 02, 04, 06, 07, 08, 11, 13]
* |01, 02, 04|
* Local events [03, 05, 09, 10, 12, 14, 15, 16]
* |03, 05, 09|
*
* We note that the oldest event is from the local index, and we combine the
* results:
*
* Server window [01, 02, 04]
* Local window [03, 05, 09]
*
* Combined events [01, 02, 03, 04, 05, 09]
*
* We split the combined result in the part that we want to present and a part
* that will be cached.
*
* Presented events [01, 02, 03]
* Cached events [04, 05, 09]
*
* We slide the window for the server since the oldest event is from the local
* index.
*
* Server events [01, 02, 04, 06, 07, 08, 11, 13]
* |06, 07, 08|
* Local events [03, 05, 09, 10, 12, 14, 15, 16]
* |XX, XX, XX|
* Cached events [04, 05, 09]
*
* We note that the oldest event is from the server and we combine the new
* server events with the cached ones.
*
* Cached events [04, 05, 09]
* Server events [06, 07, 08]
*
* Combined events [04, 05, 06, 07, 08, 09]
*
* We split again.
*
* Presented events [04, 05, 06]
* Cached events [07, 08, 09]
*
* We slide the local window, the oldest event is on the server.
*
* Server events [01, 02, 04, 06, 07, 08, 11, 13]
* |XX, XX, XX|
* Local events [03, 05, 09, 10, 12, 14, 15, 16]
* |10, 12, 14|
*
* Cached events [07, 08, 09]
* Local events [10, 12, 14]
* Combined events [07, 08, 09, 10, 12, 14]
*
* Presented events [07, 08, 09]
* Cached events [10, 12, 14]
*
* Next up we slide the server window again.
*
* Server events [01, 02, 04, 06, 07, 08, 11, 13]
* |11, 13|
* Local events [03, 05, 09, 10, 12, 14, 15, 16]
* |XX, XX, XX|
*
* Cached events [10, 12, 14]
* Server events [11, 13]
* Combined events [10, 11, 12, 13, 14]
*
* Presented events [10, 11, 12]
* Cached events [13, 14]
*
* We have one source exhausted, we fetch the rest of our events from the other
* source and combine it with our cached events.
*
*
* @param {object} previousSearchResult A search result from a previous search
* call.
* @param {object} localEvents An unprocessed search result from the event
* index.
* @param {object} serverEvents An unprocessed search result from the server.
*
* @return {object} A response object that combines the events from the
* different event sources.
*
*/
function combineEvents(
previousSearchResult: ISeshatSearchResults,
localEvents: IResultRoomEvents = undefined,
serverEvents: IResultRoomEvents = undefined,
): IResultRoomEvents {
const response = {} as IResultRoomEvents;
const cachedEvents = previousSearchResult.cachedEvents;
let oldestEventFrom = previousSearchResult.oldestEventFrom;
response.highlights = previousSearchResult.highlights;
if (localEvents && serverEvents && serverEvents.results) {
// This is a first search call, combine the events from the server and
// the local index. Note where our oldest event came from, we shall
// fetch the next batch of events from the other source.
if (compareOldestEvents(localEvents.results, serverEvents.results) < 0) {
oldestEventFrom = "local";
}
combineEventSources(previousSearchResult, response, localEvents.results, serverEvents.results);
response.highlights = localEvents.highlights.concat(serverEvents.highlights);
} else if (localEvents) {
// This is a pagination call fetching more events from the local index,
// meaning that our oldest event was on the server.
// Change the source of the oldest event if our local event is older
// than the cached one.
if (compareOldestEvents(localEvents.results, cachedEvents) < 0) {
oldestEventFrom = "local";
}
combineEventSources(previousSearchResult, response, localEvents.results, cachedEvents);
} else if (serverEvents && serverEvents.results) {
// This is a pagination call fetching more events from the server,
// meaning that our oldest event was in the local index.
// Change the source of the oldest event if our server event is older
// than the cached one.
if (compareOldestEvents(serverEvents.results, cachedEvents) < 0) {
oldestEventFrom = "server";
}
combineEventSources(previousSearchResult, response, serverEvents.results, cachedEvents);
} else {
// This is a pagination call where we exhausted both of our event
// sources, let's push the remaining cached events.
response.results = cachedEvents;
previousSearchResult.cachedEvents = [];
}
previousSearchResult.oldestEventFrom = oldestEventFrom;
return response;
}
/**
* Combine the local and server search responses
*
* @param {object} previousSearchResult A search result from a previous search
* call.
* @param {object} localEvents An unprocessed search result from the event
* index.
* @param {object} serverEvents An unprocessed search result from the server.
*
* @return {object} A response object that combines the events from the
* different event sources.
*/
function combineResponses(
previousSearchResult: ISeshatSearchResults,
localEvents: IResultRoomEvents = undefined,
serverEvents: IResultRoomEvents = undefined,
): IResultRoomEvents {
// Combine our events first.
const response = combineEvents(previousSearchResult, localEvents, serverEvents);
// Our first search will contain counts from both sources, subsequent
// pagination requests will fetch responses only from one of the sources, so
// reuse the first count when we're paginating.
if (previousSearchResult.count) {
response.count = previousSearchResult.count;
} else {
response.count = localEvents.count + serverEvents.count;
}
// Update our next batch tokens for the given search sources.
if (localEvents) {
previousSearchResult.seshatQuery.next_batch = localEvents.next_batch;
}
if (serverEvents) {
previousSearchResult.serverSideNextBatch = serverEvents.next_batch;
}
// Set the response next batch token to one of the tokens from the sources,
// this makes sure that if we exhaust one of the sources we continue with
// the other one.
if (previousSearchResult.seshatQuery.next_batch) {
response.next_batch = previousSearchResult.seshatQuery.next_batch;
} else if (previousSearchResult.serverSideNextBatch) {
response.next_batch = previousSearchResult.serverSideNextBatch;
}
// We collected all search results from the server as well as from Seshat,
// we still have some events cached that we'll want to display on the next
// pagination request.
//
// Provide a fake next batch token for that case.
if (!response.next_batch && previousSearchResult.cachedEvents.length > 0) {
response.next_batch = "cached";
}
return response;
}
interface IEncryptedSeshatEvent {
curve25519Key: string;
ed25519Key: string;
algorithm: string;
forwardingCurve25519KeyChain: string[];
}
function restoreEncryptionInfo(searchResultSlice: SearchResult[] = []): void {
for (let i = 0; i < searchResultSlice.length; i++) {
const timeline = searchResultSlice[i].context.getTimeline();
for (let j = 0; j < timeline.length; j++) {
const mxEv = timeline[j];
const ev = mxEv.event as IEncryptedSeshatEvent;
if (ev.curve25519Key) {
mxEv.makeEncrypted(
EventType.RoomMessageEncrypted,
{ algorithm: ev.algorithm },
ev.curve25519Key,
ev.ed25519Key,
);
// @ts-ignore
mxEv.forwardingCurve25519KeyChain = ev.forwardingCurve25519KeyChain;
delete ev.curve25519Key;
delete ev.ed25519Key;
delete ev.algorithm;
delete ev.forwardingCurve25519KeyChain;
}
}
}
}
async function combinedPagination(searchResult: ISeshatSearchResults): Promise<ISeshatSearchResults> {
const eventIndex = EventIndexPeg.get();
const client = MatrixClientPeg.get();
const searchArgs = searchResult.seshatQuery;
const oldestEventFrom = searchResult.oldestEventFrom;
let localResult: IResultRoomEvents;
let serverSideResult: ISearchResponse;
// Fetch events from the local index if we have a token for it and if it's
// the local indexes turn or the server has exhausted its results.
if (searchArgs.next_batch && (!searchResult.serverSideNextBatch || oldestEventFrom === "server")) {
localResult = await eventIndex.search(searchArgs);
}
// Fetch events from the server if we have a token for it and if it's the
// local indexes turn or the local index has exhausted its results.
if (searchResult.serverSideNextBatch && (oldestEventFrom === "local" || !searchArgs.next_batch)) {
const body = { body: searchResult._query, next_batch: searchResult.serverSideNextBatch };
serverSideResult = await client.search(body);
}
let serverEvents: IResultRoomEvents;
if (serverSideResult) {
serverEvents = serverSideResult.search_categories.room_events;
}
// Combine our events.
const combinedResult = combineResponses(searchResult, localResult, serverEvents);
const response = {
search_categories: {
room_events: combinedResult,
},
};
const oldResultCount = searchResult.results ? searchResult.results.length : 0;
// Let the client process the combined result.
const result = client.processRoomEventsSearch(searchResult, response);
// Restore our encryption info so we can properly re-verify the events.
const newResultCount = result.results.length - oldResultCount;
const newSlice = result.results.slice(Math.max(result.results.length - newResultCount, 0));
restoreEncryptionInfo(newSlice);
searchResult.pendingRequest = null;
return result;
}
function eventIndexSearch(term: string, roomId: string = undefined): Promise<ISearchResults> {
let searchPromise: Promise<ISearchResults>;
if (roomId !== undefined) {
if (MatrixClientPeg.get().isRoomEncrypted(roomId)) {
// The search is for a single encrypted room, use our local
// search method.
searchPromise = localSearchProcess(term, roomId);
} else {
// The search is for a single non-encrypted room, use the
// server-side search.
searchPromise = serverSideSearchProcess(term, roomId);
}
} else {
// Search across all rooms, combine a server side search and a
// local search.
searchPromise = combinedSearch(term);
}
return searchPromise;
}
function eventIndexSearchPagination(searchResult: ISeshatSearchResults): Promise<ISeshatSearchResults> {
const client = MatrixClientPeg.get();
const seshatQuery = searchResult.seshatQuery;
const serverQuery = searchResult._query;
if (!seshatQuery) {
// This is a search in a non-encrypted room. Do the normal server-side
// pagination.
return client.backPaginateRoomEventsSearch(searchResult);
} else if (!serverQuery) {
// This is a search in a encrypted room. Do a local pagination.
const promise = localPagination(searchResult);
searchResult.pendingRequest = promise;
return promise;
} else {
// We have both queries around, this is a search across all rooms so a
// combined pagination needs to be done.
const promise = combinedPagination(searchResult);
searchResult.pendingRequest = promise;
return promise;
}
}
export function searchPagination(searchResult: ISearchResults): Promise<ISearchResults> {
const eventIndex = EventIndexPeg.get();
const client = MatrixClientPeg.get();
if (searchResult.pendingRequest) return searchResult.pendingRequest;
if (eventIndex === null) return client.backPaginateRoomEventsSearch(searchResult);
else return eventIndexSearchPagination(searchResult);
}
export default function eventSearch(term: string, roomId: string = undefined): Promise<ISearchResults> {
const eventIndex = EventIndexPeg.get();
if (eventIndex === null) return serverSideSearchProcess(term, roomId);
else return eventIndexSearch(term, roomId);
}

463
src/SecurityManager.ts Normal file
View file

@ -0,0 +1,463 @@
/*
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.
*/
import { ICryptoCallbacks } from 'matrix-js-sdk/src/matrix';
import { ISecretStorageKeyInfo } from 'matrix-js-sdk/src/crypto/api';
import { MatrixClient } from 'matrix-js-sdk/src/client';
import Modal from './Modal';
import * as sdk from './index';
import { MatrixClientPeg } from './MatrixClientPeg';
import { deriveKey } from 'matrix-js-sdk/src/crypto/key_passphrase';
import { decodeRecoveryKey } from 'matrix-js-sdk/src/crypto/recoverykey';
import { _t } from './languageHandler';
import { encodeBase64 } from "matrix-js-sdk/src/crypto/olmlib";
import { isSecureBackupRequired } from './utils/WellKnownUtils';
import AccessSecretStorageDialog from './components/views/dialogs/security/AccessSecretStorageDialog';
import RestoreKeyBackupDialog from './components/views/dialogs/security/RestoreKeyBackupDialog';
import SettingsStore from "./settings/SettingsStore";
import SecurityCustomisations from "./customisations/Security";
import { DeviceTrustLevel } from 'matrix-js-sdk/src/crypto/CrossSigning';
// This stores the secret storage private keys in memory for the JS SDK. This is
// only meant to act as a cache to avoid prompting the user multiple times
// during the same single operation. Use `accessSecretStorage` below to scope a
// single secret storage operation, as it will clear the cached keys once the
// operation ends.
let secretStorageKeys: Record<string, Uint8Array> = {};
let secretStorageKeyInfo: Record<string, ISecretStorageKeyInfo> = {};
let secretStorageBeingAccessed = false;
let nonInteractive = false;
let dehydrationCache: {
key?: Uint8Array;
keyInfo?: ISecretStorageKeyInfo;
} = {};
function isCachingAllowed(): boolean {
return secretStorageBeingAccessed;
}
/**
* This can be used by other components to check if secret storage access is in
* progress, so that we can e.g. avoid intermittently showing toasts during
* secret storage setup.
*
* @returns {bool}
*/
export function isSecretStorageBeingAccessed(): boolean {
return secretStorageBeingAccessed;
}
export class AccessCancelledError extends Error {
constructor() {
super("Secret storage access canceled");
}
}
async function confirmToDismiss(): Promise<boolean> {
const QuestionDialog = sdk.getComponent("dialogs.QuestionDialog");
const [sure] = await Modal.createDialog(QuestionDialog, {
title: _t("Cancel entering passphrase?"),
description: _t("Are you sure you want to cancel entering passphrase?"),
danger: false,
button: _t("Go Back"),
cancelButton: _t("Cancel"),
}).finished;
return !sure;
}
function makeInputToKey(
keyInfo: ISecretStorageKeyInfo,
): (keyParams: { passphrase: string, recoveryKey: string }) => Promise<Uint8Array> {
return async ({ passphrase, recoveryKey }) => {
if (passphrase) {
return deriveKey(
passphrase,
keyInfo.passphrase.salt,
keyInfo.passphrase.iterations,
);
} else {
return decodeRecoveryKey(recoveryKey);
}
};
}
async function getSecretStorageKey(
{ keys: keyInfos }: { keys: Record<string, ISecretStorageKeyInfo> },
ssssItemName,
): Promise<[string, Uint8Array]> {
const cli = MatrixClientPeg.get();
let keyId = await cli.getDefaultSecretStorageKeyId();
let keyInfo;
if (keyId) {
// use the default SSSS key if set
keyInfo = keyInfos[keyId];
if (!keyInfo) {
// if the default key is not available, pretend the default key
// isn't set
keyId = undefined;
}
}
if (!keyId) {
// if no default SSSS key is set, fall back to a heuristic of using the
// only available key, if only one key is set
const keyInfoEntries = Object.entries(keyInfos);
if (keyInfoEntries.length > 1) {
throw new Error("Multiple storage key requests not implemented");
}
[keyId, keyInfo] = keyInfoEntries[0];
}
// Check the in-memory cache
if (isCachingAllowed() && secretStorageKeys[keyId]) {
return [keyId, secretStorageKeys[keyId]];
}
if (dehydrationCache.key) {
if (await MatrixClientPeg.get().checkSecretStorageKey(dehydrationCache.key, keyInfo)) {
cacheSecretStorageKey(keyId, keyInfo, dehydrationCache.key);
return [keyId, dehydrationCache.key];
}
}
const keyFromCustomisations = SecurityCustomisations.getSecretStorageKey?.();
if (keyFromCustomisations) {
console.log("Using key from security customisations (secret storage)");
cacheSecretStorageKey(keyId, keyInfo, keyFromCustomisations);
return [keyId, keyFromCustomisations];
}
if (nonInteractive) {
throw new Error("Could not unlock non-interactively");
}
const inputToKey = makeInputToKey(keyInfo);
const { finished } = Modal.createTrackedDialog("Access Secret Storage dialog", "",
AccessSecretStorageDialog,
/* props= */
{
keyInfo,
checkPrivateKey: async (input) => {
const key = await inputToKey(input);
return await MatrixClientPeg.get().checkSecretStorageKey(key, keyInfo);
},
},
/* className= */ null,
/* isPriorityModal= */ false,
/* isStaticModal= */ false,
/* options= */ {
onBeforeClose: async (reason) => {
if (reason === "backgroundClick") {
return confirmToDismiss();
}
return true;
},
},
);
const [input] = await finished;
if (!input) {
throw new AccessCancelledError();
}
const key = await inputToKey(input);
// Save to cache to avoid future prompts in the current session
cacheSecretStorageKey(keyId, keyInfo, key);
return [keyId, key];
}
export async function getDehydrationKey(
keyInfo: ISecretStorageKeyInfo,
checkFunc: (Uint8Array) => void,
): Promise<Uint8Array> {
const keyFromCustomisations = SecurityCustomisations.getSecretStorageKey?.();
if (keyFromCustomisations) {
console.log("Using key from security customisations (dehydration)");
return keyFromCustomisations;
}
const inputToKey = makeInputToKey(keyInfo);
const { finished } = Modal.createTrackedDialog("Access Secret Storage dialog", "",
AccessSecretStorageDialog,
/* props= */
{
keyInfo,
checkPrivateKey: async (input) => {
const key = await inputToKey(input);
try {
checkFunc(key);
return true;
} catch (e) {
return false;
}
},
},
/* className= */ null,
/* isPriorityModal= */ false,
/* isStaticModal= */ false,
/* options= */ {
onBeforeClose: async (reason) => {
if (reason === "backgroundClick") {
return confirmToDismiss();
}
return true;
},
},
);
const [input] = await finished;
if (!input) {
throw new AccessCancelledError();
}
const key = await inputToKey(input);
// need to copy the key because rehydration (unpickling) will clobber it
dehydrationCache = { key: new Uint8Array(key), keyInfo };
return key;
}
function cacheSecretStorageKey(
keyId: string,
keyInfo: ISecretStorageKeyInfo,
key: Uint8Array,
): void {
if (isCachingAllowed()) {
secretStorageKeys[keyId] = key;
secretStorageKeyInfo[keyId] = keyInfo;
}
}
async function onSecretRequested(
userId: string,
deviceId: string,
requestId: string,
name: string,
deviceTrust: DeviceTrustLevel,
): Promise<string> {
console.log("onSecretRequested", userId, deviceId, requestId, name, deviceTrust);
const client = MatrixClientPeg.get();
if (userId !== client.getUserId()) {
return;
}
if (!deviceTrust || !deviceTrust.isVerified()) {
console.log(`Ignoring secret request from untrusted device ${deviceId}`);
return;
}
if (
name === "m.cross_signing.master" ||
name === "m.cross_signing.self_signing" ||
name === "m.cross_signing.user_signing"
) {
const callbacks = client.getCrossSigningCacheCallbacks();
if (!callbacks.getCrossSigningKeyCache) return;
const keyId = name.replace("m.cross_signing.", "");
const key = await callbacks.getCrossSigningKeyCache(keyId);
if (!key) {
console.log(
`${keyId} requested by ${deviceId}, but not found in cache`,
);
}
return key && encodeBase64(key);
} else if (name === "m.megolm_backup.v1") {
const key = await client.crypto.getSessionBackupPrivateKey();
if (!key) {
console.log(
`session backup key requested by ${deviceId}, but not found in cache`,
);
}
return key && encodeBase64(key);
}
console.warn("onSecretRequested didn't recognise the secret named ", name);
}
export const crossSigningCallbacks: ICryptoCallbacks = {
getSecretStorageKey,
cacheSecretStorageKey,
onSecretRequested,
getDehydrationKey,
};
export async function promptForBackupPassphrase(): Promise<Uint8Array> {
let key;
const { finished } = Modal.createTrackedDialog('Restore Backup', '', RestoreKeyBackupDialog, {
showSummary: false, keyCallback: k => key = k,
}, null, /* priority = */ false, /* static = */ true);
const success = await finished;
if (!success) throw new Error("Key backup prompt cancelled");
return key;
}
/**
* This helper should be used whenever you need to access secret storage. It
* ensures that secret storage (and also cross-signing since they each depend on
* each other in a cycle of sorts) have been bootstrapped before running the
* provided function.
*
* Bootstrapping secret storage may take one of these paths:
* 1. Create secret storage from a passphrase and store cross-signing keys
* in secret storage.
* 2. Access existing secret storage by requesting passphrase and accessing
* cross-signing keys as needed.
* 3. All keys are loaded and there's nothing to do.
*
* Additionally, the secret storage keys are cached during the scope of this function
* to ensure the user is prompted only once for their secret storage
* passphrase. The cache is then cleared once the provided function completes.
*
* @param {Function} [func] An operation to perform once secret storage has been
* bootstrapped. Optional.
* @param {bool} [forceReset] Reset secret storage even if it's already set up
*/
export async function accessSecretStorage(func = async () => { }, forceReset = false) {
const cli = MatrixClientPeg.get();
secretStorageBeingAccessed = true;
try {
if (!await cli.hasSecretStorageKey() || forceReset) {
// This dialog calls bootstrap itself after guiding the user through
// passphrase creation.
const { finished } = Modal.createTrackedDialogAsync('Create Secret Storage dialog', '',
import("./async-components/views/dialogs/security/CreateSecretStorageDialog"),
{
forceReset,
},
null,
/* priority = */ false,
/* static = */ true,
/* options = */ {
onBeforeClose: async (reason) => {
// If Secure Backup is required, you cannot leave the modal.
if (reason === "backgroundClick") {
return !isSecureBackupRequired();
}
return true;
},
},
);
const [confirmed] = await finished;
if (!confirmed) {
throw new Error("Secret storage creation canceled");
}
} else {
// FIXME: Using an import will result in test failures
const InteractiveAuthDialog = sdk.getComponent("dialogs.InteractiveAuthDialog");
await cli.bootstrapCrossSigning({
authUploadDeviceSigningKeys: async (makeRequest) => {
const { finished } = Modal.createTrackedDialog(
'Cross-signing keys dialog', '', InteractiveAuthDialog,
{
title: _t("Setting up keys"),
matrixClient: cli,
makeRequest,
},
);
const [confirmed] = await finished;
if (!confirmed) {
throw new Error("Cross-signing key upload auth canceled");
}
},
});
await cli.bootstrapSecretStorage({
getKeyBackupPassphrase: promptForBackupPassphrase,
});
const keyId = Object.keys(secretStorageKeys)[0];
if (keyId && SettingsStore.getValue("feature_dehydration")) {
let dehydrationKeyInfo = {};
if (secretStorageKeyInfo[keyId] && secretStorageKeyInfo[keyId].passphrase) {
dehydrationKeyInfo = { passphrase: secretStorageKeyInfo[keyId].passphrase };
}
console.log("Setting dehydration key");
await cli.setDehydrationKey(secretStorageKeys[keyId], dehydrationKeyInfo, "Backup device");
} else if (!keyId) {
console.warn("Not setting dehydration key: no SSSS key found");
} else {
console.log("Not setting dehydration key: feature disabled");
}
}
// `return await` needed here to ensure `finally` block runs after the
// inner operation completes.
return await func();
} catch (e) {
SecurityCustomisations.catchAccessSecretStorageError?.(e);
console.error(e);
// Re-throw so that higher level logic can abort as needed
throw e;
} finally {
// Clear secret storage key cache now that work is complete
secretStorageBeingAccessed = false;
if (!isCachingAllowed()) {
secretStorageKeys = {};
secretStorageKeyInfo = {};
}
}
}
// FIXME: this function name is a bit of a mouthful
export async function tryToUnlockSecretStorageWithDehydrationKey(
client: MatrixClient,
): Promise<void> {
const key = dehydrationCache.key;
let restoringBackup = false;
if (key && await client.isSecretStorageReady()) {
console.log("Trying to set up cross-signing using dehydration key");
secretStorageBeingAccessed = true;
nonInteractive = true;
try {
await client.checkOwnCrossSigningTrust();
// we also need to set a new dehydrated device to replace the
// device we rehydrated
let dehydrationKeyInfo = {};
if (dehydrationCache.keyInfo && dehydrationCache.keyInfo.passphrase) {
dehydrationKeyInfo = { passphrase: dehydrationCache.keyInfo.passphrase };
}
await client.setDehydrationKey(key, dehydrationKeyInfo, "Backup device");
// and restore from backup
const backupInfo = await client.getKeyBackupVersion();
if (backupInfo) {
restoringBackup = true;
// don't await, because this can take a long time
client.restoreKeyBackupWithSecretStorage(backupInfo)
.finally(() => {
secretStorageBeingAccessed = false;
nonInteractive = false;
if (!isCachingAllowed()) {
secretStorageKeys = {};
secretStorageKeyInfo = {};
}
});
}
} finally {
dehydrationCache = {};
// the secret storage cache is needed for restoring from backup, so
// don't clear it yet if we're restoring from backup
if (!restoringBackup) {
secretStorageBeingAccessed = false;
nonInteractive = false;
if (!isCachingAllowed()) {
secretStorageKeys = {};
secretStorageKeyInfo = {};
}
}
}
}
}

View file

@ -1,4 +1,3 @@
//@flow
/*
Copyright 2017 Aviral Dasgupta
@ -15,13 +14,22 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import _clamp from 'lodash/clamp';
import { clamp } from "lodash";
import { MatrixEvent } from "matrix-js-sdk/src/models/event";
import { SerializedPart } from "./editor/parts";
import EditorModel from "./editor/model";
interface IHistoryItem {
parts: SerializedPart[];
replyEventId?: string;
}
export default class SendHistoryManager {
history: Array<HistoryItem> = [];
history: Array<IHistoryItem> = [];
prefix: string;
lastIndex: number = 0; // used for indexing the storage
currentIndex: number = 0; // used for indexing the loaded validated history Array
lastIndex = 0; // used for indexing the storage
currentIndex = 0; // used for indexing the loaded validated history Array
constructor(roomId: string, prefix: string) {
this.prefix = prefix + roomId;
@ -32,8 +40,7 @@ export default class SendHistoryManager {
while (itemJSON = sessionStorage.getItem(`${this.prefix}[${index}]`)) {
try {
const serializedParts = JSON.parse(itemJSON);
this.history.push(serializedParts);
this.history.push(JSON.parse(itemJSON));
} catch (e) {
console.warn("Throwing away unserialisable history", e);
break;
@ -45,16 +52,23 @@ export default class SendHistoryManager {
this.currentIndex = this.lastIndex + 1;
}
save(editorModel: Object) {
const serializedParts = editorModel.serializeParts();
this.history.push(serializedParts);
this.currentIndex = this.history.length;
this.lastIndex += 1;
sessionStorage.setItem(`${this.prefix}[${this.lastIndex}]`, JSON.stringify(serializedParts));
static createItem(model: EditorModel, replyEvent?: MatrixEvent): IHistoryItem {
return {
parts: model.serializeParts(),
replyEventId: replyEvent ? replyEvent.getId() : undefined,
};
}
getItem(offset: number): ?HistoryItem {
this.currentIndex = _clamp(this.currentIndex + offset, 0, this.history.length - 1);
save(editorModel: EditorModel, replyEvent?: MatrixEvent) {
const item = SendHistoryManager.createItem(editorModel, replyEvent);
this.history.push(item);
this.currentIndex = this.history.length;
this.lastIndex += 1;
sessionStorage.setItem(`${this.prefix}[${this.lastIndex}]`, JSON.stringify(item));
}
getItem(offset: number): IHistoryItem {
this.currentIndex = clamp(this.currentIndex + offset, 0, this.history.length - 1);
return this.history[this.currentIndex];
}
}

View file

@ -23,7 +23,7 @@ class Skinner {
if (!name) throw new Error(`Invalid component name: ${name}`);
if (this.components === null) {
throw new Error(
"Attempted to get a component before a skin has been loaded."+
`Attempted to get a component (${name}) before a skin has been loaded.`+
" This is probably because either:"+
" a) Your app has not called sdk.loadSkin(), or"+
" b) A component has called getComponent at the root level",
@ -50,8 +50,8 @@ class Skinner {
return null;
}
// components have to be functions.
const validType = typeof comp === 'function';
// components have to be functions or forwardRef objects with a render function.
const validType = typeof comp === 'function' || comp.render;
if (!validType) {
throw new Error(`Not a valid component: ${name} (type = ${typeof(comp)}).`);
}

View file

@ -17,24 +17,44 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import * as React from 'react';
import { User } from "matrix-js-sdk/src/models/user";
import {MatrixClientPeg} from './MatrixClientPeg';
import dis from './dispatcher';
import * as sdk from './index';
import {_t, _td} from './languageHandler';
import * as ContentHelpers from 'matrix-js-sdk/src/content-helpers';
import { MatrixClientPeg } from './MatrixClientPeg';
import dis from './dispatcher/dispatcher';
import { _t, _td } from './languageHandler';
import Modal from './Modal';
import MultiInviter from './utils/MultiInviter';
import { linkifyAndSanitizeHtml } from './HtmlUtils';
import QuestionDialog from "./components/views/dialogs/QuestionDialog";
import WidgetUtils from "./utils/WidgetUtils";
import {textToHtmlRainbow} from "./utils/colour";
import { textToHtmlRainbow } from "./utils/colour";
import { getAddressType } from './UserAddress';
import { abbreviateUrl } from './utils/UrlUtils';
import { getDefaultIdentityServerUrl, useDefaultIdentityServer } from './utils/IdentityServerUtils';
import {isPermalinkHost, parsePermalink} from "./utils/permalinks/Permalinks";
import {inviteUsersToRoom} from "./RoomInvite";
import { isPermalinkHost, parsePermalink } from "./utils/permalinks/Permalinks";
import { inviteUsersToRoom } from "./RoomInvite";
import { WidgetType } from "./widgets/WidgetType";
import { Jitsi } from "./widgets/Jitsi";
import { parseFragment as parseHtml, Element as ChildElement } from "parse5";
import BugReportDialog from "./components/views/dialogs/BugReportDialog";
import { ensureDMExists } from "./createRoom";
import { ViewUserPayload } from "./dispatcher/payloads/ViewUserPayload";
import { Action } from "./dispatcher/actions";
import { EffectiveMembership, getEffectiveMembership, leaveRoomBehaviour } from "./utils/membership";
import SdkConfig from "./SdkConfig";
import SettingsStore from "./settings/SettingsStore";
import { UIFeature } from "./settings/UIFeature";
import { CHAT_EFFECTS } from "./effects";
import CallHandler from "./CallHandler";
import { guessAndSetDMRoom } from "./Rooms";
import UploadConfirmDialog from './components/views/dialogs/UploadConfirmDialog';
import ErrorDialog from './components/views/dialogs/ErrorDialog';
import DevtoolsDialog from './components/views/dialogs/DevtoolsDialog';
import RoomUpgradeWarningDialog from "./components/views/dialogs/RoomUpgradeWarningDialog";
import InfoDialog from "./components/views/dialogs/InfoDialog";
import SlashCommandHelpDialog from "./components/views/dialogs/SlashCommandHelpDialog";
// XXX: workaround for https://github.com/microsoft/TypeScript/issues/31816
interface HTMLInputEvent extends Event {
@ -48,7 +68,6 @@ const singleMxcUpload = async (): Promise<any> => {
fileSelector.onchange = (ev: HTMLInputEvent) => {
const file = ev.target.files[0];
const UploadConfirmDialog = sdk.getComponent("dialogs.UploadConfirmDialog");
Modal.createTrackedDialog('Upload Files confirmation', '', UploadConfirmDialog, {
file,
onFinished: (shouldContinue) => {
@ -66,6 +85,7 @@ export const CommandCategories = {
"actions": _td("Actions"),
"admin": _td("Admin"),
"advanced": _td("Advanced"),
"effects": _td("Effects"),
"other": _td("Other"),
};
@ -79,9 +99,10 @@ interface ICommandOpts {
runFn?: RunFn;
category: string;
hideCompletionAfterSpace?: boolean;
isEnabled?(): boolean;
}
class Command {
export class Command {
command: string;
aliases: string[];
args: undefined | string;
@ -89,6 +110,7 @@ class Command {
runFn: undefined | RunFn;
category: string;
hideCompletionAfterSpace: boolean;
_isEnabled?: () => boolean;
constructor(opts: ICommandOpts) {
this.command = opts.command;
@ -98,6 +120,7 @@ class Command {
this.runFn = opts.runFn;
this.category = opts.category || CommandCategories.other;
this.hideCompletionAfterSpace = opts.hideCompletionAfterSpace || false;
this._isEnabled = opts.isEnabled;
}
getCommand() {
@ -108,23 +131,31 @@ class Command {
return this.getCommand() + " " + this.args;
}
run(roomId: string, args: string, cmd: string) {
run(roomId: string, args: string) {
// if it has no runFn then its an ignored/nop command (autocomplete only) e.g `/me`
if (!this.runFn) return;
return this.runFn.bind(this)(roomId, args, cmd);
if (!this.runFn) return reject(_t("Command error"));
return this.runFn.bind(this)(roomId, args);
}
getUsage() {
return _t('Usage') + ': ' + this.getCommandWithArgs();
}
isEnabled() {
return this._isEnabled ? this._isEnabled() : true;
}
}
function reject(error) {
return {error};
return { error };
}
function success(promise?: Promise<any>) {
return {promise};
return { promise };
}
function successSync(value: any) {
return success(Promise.resolve(value));
}
/* Disable the "unexpected this" error for these commands - all of the run
@ -132,6 +163,18 @@ function success(promise?: Promise<any>) {
*/
export const Commands = [
new Command({
command: 'spoiler',
args: '<message>',
description: _td('Sends the given message as a spoiler'),
runFn: function(roomId, message) {
return successSync(ContentHelpers.makeHtmlMessage(
message,
`<span data-mx-spoiler>${message}</span>`,
));
},
category: CommandCategories.messages,
}),
new Command({
command: 'shrug',
args: '<message>',
@ -141,7 +184,46 @@ export const Commands = [
if (args) {
message = message + ' ' + args;
}
return success(MatrixClientPeg.get().sendTextMessage(roomId, message));
return successSync(ContentHelpers.makeTextMessage(message));
},
category: CommandCategories.messages,
}),
new Command({
command: 'tableflip',
args: '<message>',
description: _td('Prepends (╯°□°)╯︵ ┻━┻ to a plain-text message'),
runFn: function(roomId, args) {
let message = '(╯°□°)╯︵ ┻━┻';
if (args) {
message = message + ' ' + args;
}
return successSync(ContentHelpers.makeTextMessage(message));
},
category: CommandCategories.messages,
}),
new Command({
command: 'unflip',
args: '<message>',
description: _td('Prepends ┬──┬ ( ゜-゜ノ) to a plain-text message'),
runFn: function(roomId, args) {
let message = '┬──┬ ( ゜-゜ノ)';
if (args) {
message = message + ' ' + args;
}
return successSync(ContentHelpers.makeTextMessage(message));
},
category: CommandCategories.messages,
}),
new Command({
command: 'lenny',
args: '<message>',
description: _td('Prepends ( ͡° ͜ʖ ͡°) to a plain-text message'),
runFn: function(roomId, args) {
let message = '( ͡° ͜ʖ ͡°)';
if (args) {
message = message + ' ' + args;
}
return successSync(ContentHelpers.makeTextMessage(message));
},
category: CommandCategories.messages,
}),
@ -150,7 +232,7 @@ export const Commands = [
args: '<message>',
description: _td('Sends a message as plain text, without interpreting it as markdown'),
runFn: function(roomId, messages) {
return success(MatrixClientPeg.get().sendTextMessage(roomId, messages));
return successSync(ContentHelpers.makeTextMessage(messages));
},
category: CommandCategories.messages,
}),
@ -159,7 +241,7 @@ export const Commands = [
args: '<message>',
description: _td('Sends a message as html, without interpreting it as markdown'),
runFn: function(roomId, messages) {
return success(MatrixClientPeg.get().sendHtmlMessage(roomId, messages, messages));
return successSync(ContentHelpers.makeHtmlMessage(messages, messages));
},
category: CommandCategories.messages,
}),
@ -168,7 +250,6 @@ export const Commands = [
args: '<query>',
description: _td('Searches DuckDuckGo for results'),
runFn: function() {
const ErrorDialog = sdk.getComponent('dialogs.ErrorDialog');
// TODO Don't explain this away, actually show a search UI here.
Modal.createTrackedDialog('Slash Commands', '/ddg is not a command', ErrorDialog, {
title: _t('/ddg is not a command'),
@ -191,10 +272,8 @@ export const Commands = [
return reject(_t("You do not have the required permissions to use this command."));
}
const RoomUpgradeWarningDialog = sdk.getComponent("dialogs.RoomUpgradeWarningDialog");
const {finished} = Modal.createTrackedDialog('Slash Commands', 'upgrade room confirmation',
RoomUpgradeWarningDialog, {roomId: roomId, targetVersion: args}, /*className=*/null,
const { finished } = Modal.createTrackedDialog('Slash Commands', 'upgrade room confirmation',
RoomUpgradeWarningDialog, { roomId: roomId, targetVersion: args }, /*className=*/null,
/*isPriority=*/false, /*isStatic=*/true);
return success(finished.then(async ([resp]) => {
@ -210,7 +289,7 @@ export const Commands = [
if (resp.invite) {
checkForUpgradeFn = async (newRoom) => {
// The upgradePromise should be done by the time we await it here.
const {replacement_room: newRoomId} = await upgradePromise;
const { replacement_room: newRoomId } = await upgradePromise;
if (newRoom.roomId !== newRoomId) return;
const toInvite = [
@ -236,7 +315,6 @@ export const Commands = [
if (checkForUpgradeFn) cli.removeListener('Room', checkForUpgradeFn);
const ErrorDialog = sdk.getComponent('dialogs.ErrorDialog');
Modal.createTrackedDialog('Slash Commands', 'room upgrade error', ErrorDialog, {
title: _t('Error upgrading room'),
description: _t(
@ -292,7 +370,7 @@ export const Commands = [
return success(promise.then((url) => {
if (!url) return;
return MatrixClientPeg.get().sendStateEvent(roomId, 'm.room.avatar', {url}, '');
return MatrixClientPeg.get().sendStateEvent(roomId, 'm.room.avatar', { url }, '');
}));
},
category: CommandCategories.actions,
@ -350,16 +428,16 @@ export const Commands = [
return success(cli.setRoomTopic(roomId, args));
}
const room = cli.getRoom(roomId);
if (!room) return reject('Bad room ID: ' + roomId);
if (!room) return reject(_t("Failed to set topic"));
const topicEvents = room.currentState.getStateEvents('m.room.topic', '');
const topic = topicEvents && topicEvents.getContent().topic;
const topicHtml = topic ? linkifyAndSanitizeHtml(topic) : _t('This room has no topic.');
const InfoDialog = sdk.getComponent('dialogs.InfoDialog');
Modal.createTrackedDialog('Slash Commands', 'Topic', InfoDialog, {
title: room.name,
description: <div dangerouslySetInnerHTML={{ __html: topicHtml }} />,
hasCloseButton: true,
});
return success();
},
@ -379,26 +457,27 @@ export const Commands = [
}),
new Command({
command: 'invite',
args: '<user-id>',
args: '<user-id> [<reason>]',
description: _td('Invites user with given id to current room'),
runFn: function(roomId, args) {
if (args) {
const matches = args.match(/^(\S+)$/);
if (matches) {
const [address, reason] = args.split(/\s+(.+)/);
if (address) {
// We use a MultiInviter to re-use the invite logic, even though
// we're only inviting one user.
const address = matches[1];
// If we need an identity server but don't have one, things
// get a bit more complex here, but we try to show something
// meaningful.
let finished = Promise.resolve();
let prom = Promise.resolve();
if (
getAddressType(address) === 'email' &&
!MatrixClientPeg.get().getIdentityServerUrl()
) {
const defaultIdentityServerUrl = getDefaultIdentityServerUrl();
if (defaultIdentityServerUrl) {
({ finished } = Modal.createTrackedDialog('Slash Commands', 'Identity server',
const { finished } = Modal.createTrackedDialog<[boolean]>(
'Slash Commands',
'Identity server',
QuestionDialog, {
title: _t("Use an identity server"),
description: <p>{_t(
@ -411,9 +490,9 @@ export const Commands = [
)}</p>,
button: _t("Continue"),
},
));
);
finished = finished.then(([useDefault]: any) => {
prom = finished.then(([useDefault]) => {
if (useDefault) {
useDefaultIdentityServer();
return;
@ -425,8 +504,8 @@ export const Commands = [
}
}
const inviter = new MultiInviter(roomId);
return success(finished.then(() => {
return inviter.invite([address]);
return success(prom.then(() => {
return inviter.invite([address], reason);
}).then(() => {
if (inviter.getCompletionState(address) !== "invited") {
throw new Error(inviter.getErrorText(address));
@ -441,8 +520,8 @@ export const Commands = [
new Command({
command: 'join',
aliases: ['j', 'goto'],
args: '<room-alias>',
description: _td('Joins room with given alias'),
args: '<room-address>',
description: _td('Joins room with given address'),
runFn: function(_, args) {
if (args) {
// Note: we support 2 versions of this command. The first is
@ -467,7 +546,7 @@ export const Commands = [
const parsedUrl = new URL(params[0]);
const hostname = parsedUrl.host || parsedUrl.hostname; // takes first non-falsey value
// if we're using a Riot permalink handler, this will catch it before we get much further.
// if we're using a Element permalink handler, this will catch it before we get much further.
// see below where we make assumptions about parsing the URL.
if (isPermalinkHost(hostname)) {
isPermalink = true;
@ -483,11 +562,11 @@ export const Commands = [
action: 'view_room',
room_alias: roomAlias,
auto_join: true,
_type: "slash_command", // instrumentation
});
return success();
} else if (params[0][0] === '!') {
const roomId = params[0];
const viaServers = params.splice(0);
const [roomId, ...viaServers] = params;
dis.dispatch({
action: 'view_room',
@ -498,6 +577,7 @@ export const Commands = [
},
via_servers: viaServers, // for the rejoin button
auto_join: true,
_type: "slash_command", // instrumentation
});
return success();
} else if (isPermalink) {
@ -522,6 +602,7 @@ export const Commands = [
const dispatch = {
action: 'view_room',
auto_join: true,
_type: "slash_command", // instrumentation
};
if (entity[0] === '!') dispatch["room_id"] = entity;
@ -553,7 +634,7 @@ export const Commands = [
}),
new Command({
command: 'part',
args: '[<room-alias>]',
args: '[<room-address>]',
description: _td('Leave room'),
runFn: function(roomId, args) {
const cli = MatrixClientPeg.get();
@ -585,16 +666,12 @@ export const Commands = [
}
if (targetRoomId) break;
}
if (!targetRoomId) return reject(_t('Unrecognised room alias:') + ' ' + roomAlias);
if (!targetRoomId) return reject(_t('Unrecognised room address:') + ' ' + roomAlias);
}
}
if (!targetRoomId) targetRoomId = roomId;
return success(
cli.leaveRoomChain(targetRoomId).then(function() {
dis.dispatch({action: 'view_next_room'});
}),
);
return success(leaveRoomBehaviour(targetRoomId));
},
category: CommandCategories.actions,
}),
@ -652,18 +729,17 @@ export const Commands = [
if (args) {
const cli = MatrixClientPeg.get();
const matches = args.match(/^(\S+)$/);
const matches = args.match(/^(@[^:]+:\S+)$/);
if (matches) {
const userId = matches[1];
const ignoredUsers = cli.getIgnoredUsers();
ignoredUsers.push(userId); // de-duped internally in the js-sdk
return success(
cli.setIgnoredUsers(ignoredUsers).then(() => {
const InfoDialog = sdk.getComponent('dialogs.InfoDialog');
Modal.createTrackedDialog('Slash Commands', 'User ignored', InfoDialog, {
title: _t('Ignored user'),
description: <div>
<p>{ _t('You are now ignoring %(userId)s', {userId}) }</p>
<p>{ _t('You are now ignoring %(userId)s', { userId }) }</p>
</div>,
});
}),
@ -682,7 +758,7 @@ export const Commands = [
if (args) {
const cli = MatrixClientPeg.get();
const matches = args.match(/^(\S+)$/);
const matches = args.match(/(^@[^:]+:\S+$)/);
if (matches) {
const userId = matches[1];
const ignoredUsers = cli.getIgnoredUsers();
@ -690,11 +766,10 @@ export const Commands = [
if (index !== -1) ignoredUsers.splice(index, 1);
return success(
cli.setIgnoredUsers(ignoredUsers).then(() => {
const InfoDialog = sdk.getComponent('dialogs.InfoDialog');
Modal.createTrackedDialog('Slash Commands', 'User unignored', InfoDialog, {
title: _t('Unignored user'),
description: <div>
<p>{ _t('You are no longer ignoring %(userId)s', {userId}) }</p>
<p>{ _t('You are no longer ignoring %(userId)s', { userId }) }</p>
</div>,
});
}),
@ -721,8 +796,11 @@ export const Commands = [
if (!isNaN(powerLevel)) {
const cli = MatrixClientPeg.get();
const room = cli.getRoom(roomId);
if (!room) return reject('Bad room ID: ' + roomId);
if (!room) return reject(_t("Command failed"));
const member = room.getMember(userId);
if (!member || getEffectiveMembership(member.membership) === EffectiveMembership.Leave) {
return reject(_t("Could not find user in room"));
}
const powerLevelEvent = room.currentState.getStateEvents('m.room.power_levels', '');
return success(cli.setPowerLevel(roomId, userId, powerLevel, powerLevelEvent));
}
@ -742,9 +820,10 @@ export const Commands = [
if (matches) {
const cli = MatrixClientPeg.get();
const room = cli.getRoom(roomId);
if (!room) return reject('Bad room ID: ' + roomId);
if (!room) return reject(_t("Command failed"));
const powerLevelEvent = room.currentState.getStateEvents('m.room.power_levels', '');
if (!powerLevelEvent.getContent().users[args]) return reject(_t("Could not find user in room"));
return success(cli.setPowerLevel(roomId, args, undefined, powerLevelEvent));
}
}
@ -756,26 +835,58 @@ export const Commands = [
command: 'devtools',
description: _td('Opens the Developer Tools dialog'),
runFn: function(roomId) {
const DevtoolsDialog = sdk.getComponent('dialogs.DevtoolsDialog');
Modal.createDialog(DevtoolsDialog, {roomId});
Modal.createDialog(DevtoolsDialog, { roomId });
return success();
},
category: CommandCategories.advanced,
}),
new Command({
command: 'addwidget',
args: '<url>',
args: '<url | embed code | Jitsi url>',
description: _td('Adds a custom widget by URL to the room'),
runFn: function(roomId, args) {
if (!args || (!args.startsWith("https://") && !args.startsWith("http://"))) {
isEnabled: () => SettingsStore.getValue(UIFeature.Widgets),
runFn: function(roomId, widgetUrl) {
if (!widgetUrl) {
return reject(_t("Please supply a widget URL or embed code"));
}
// Try and parse out a widget URL from iframes
if (widgetUrl.toLowerCase().startsWith("<iframe ")) {
// We use parse5, which doesn't render/create a DOM node. It instead runs
// some superfast regex over the text so we don't have to.
const embed = parseHtml(widgetUrl);
if (embed && embed.childNodes && embed.childNodes.length === 1) {
const iframe = embed.childNodes[0] as ChildElement;
if (iframe.tagName.toLowerCase() === 'iframe' && iframe.attrs) {
const srcAttr = iframe.attrs.find(a => a.name === 'src');
console.log("Pulling URL out of iframe (embed code)");
widgetUrl = srcAttr.value;
}
}
}
if (!widgetUrl.startsWith("https://") && !widgetUrl.startsWith("http://")) {
return reject(_t("Please supply a https:// or http:// widget URL"));
}
if (WidgetUtils.canUserModifyWidgets(roomId)) {
const userId = MatrixClientPeg.get().getUserId();
const nowMs = (new Date()).getTime();
const widgetId = encodeURIComponent(`${roomId}_${userId}_${nowMs}`);
return success(WidgetUtils.setRoomWidget(
roomId, widgetId, "m.custom", args, "Custom Widget", {}));
let type = WidgetType.CUSTOM;
let name = "Custom Widget";
let data = {};
// Make the widget a Jitsi widget if it looks like a Jitsi widget
const jitsiData = Jitsi.getInstance().parsePreferredConferenceUrl(widgetUrl);
if (jitsiData) {
console.log("Making /addwidget widget a Jitsi conference");
type = WidgetType.JITSI;
name = "Jitsi Conference";
data = jitsiData;
widgetUrl = WidgetUtils.getLocalJitsiWrapperUrl();
}
return success(WidgetUtils.setRoomWidget(roomId, widgetId, type, widgetUrl, name, data));
} else {
return reject(_t("You cannot modify widgets in this room."));
}
@ -797,7 +908,7 @@ export const Commands = [
const fingerprint = matches[3];
return success((async () => {
const device = await cli.getStoredDevice(userId, deviceId);
const device = cli.getStoredDevice(userId, deviceId);
if (!device) {
throw new Error(_t('Unknown (user, session) pair:') + ` (${userId}, ${deviceId})`);
}
@ -817,18 +928,17 @@ export const Commands = [
_t('WARNING: KEY VERIFICATION FAILED! The signing key for %(userId)s and session' +
' %(deviceId)s is "%(fprint)s" which does not match the provided key ' +
'"%(fingerprint)s". This could mean your communications are being intercepted!',
{
fprint,
userId,
deviceId,
fingerprint,
}));
{
fprint,
userId,
deviceId,
fingerprint,
}));
}
await cli.setDeviceVerified(userId, deviceId, true);
// Tell the user we verified everything
const InfoDialog = sdk.getComponent('dialogs.InfoDialog');
Modal.createTrackedDialog('Slash Commands', 'Verified key', InfoDialog, {
title: _t('Verified key'),
description: <div>
@ -836,7 +946,7 @@ export const Commands = [
{
_t('The signing key you provided matches the signing key you received ' +
'from %(userId)s\'s session %(deviceId)s. Session marked as verified.',
{userId, deviceId})
{ userId, deviceId })
}
</p>
</div>,
@ -867,7 +977,7 @@ export const Commands = [
args: '<message>',
runFn: function(roomId, args) {
if (!args) return reject(this.getUserId());
return success(MatrixClientPeg.get().sendHtmlMessage(roomId, args, textToHtmlRainbow(args)));
return successSync(ContentHelpers.makeHtmlMessage(args, textToHtmlRainbow(args)));
},
category: CommandCategories.messages,
}),
@ -877,7 +987,7 @@ export const Commands = [
args: '<message>',
runFn: function(roomId, args) {
if (!args) return reject(this.getUserId());
return success(MatrixClientPeg.get().sendHtmlEmote(roomId, args, textToHtmlRainbow(args)));
return successSync(ContentHelpers.makeHtmlEmote(args, textToHtmlRainbow(args)));
},
category: CommandCategories.messages,
}),
@ -885,8 +995,6 @@ export const Commands = [
command: "help",
description: _td("Displays list of commands with usages and descriptions"),
runFn: function() {
const SlashCommandHelpDialog = sdk.getComponent('dialogs.SlashCommandHelpDialog');
Modal.createTrackedDialog('Slash Commands', 'Help', SlashCommandHelpDialog);
return success();
},
@ -902,14 +1010,133 @@ export const Commands = [
}
const member = MatrixClientPeg.get().getRoom(roomId).getMember(userId);
dis.dispatch({
action: 'view_user',
member: member || {userId},
dis.dispatch<ViewUserPayload>({
action: Action.ViewUser,
// XXX: We should be using a real member object and not assuming what the receiver wants.
member: member || { userId } as User,
});
return success();
},
category: CommandCategories.advanced,
}),
new Command({
command: "rageshake",
aliases: ["bugreport"],
description: _td("Send a bug report with logs"),
isEnabled: () => !!SdkConfig.get().bug_report_endpoint_url,
args: "<description>",
runFn: function(roomId, args) {
return success(
Modal.createTrackedDialog('Slash Commands', 'Bug Report Dialog', BugReportDialog, {
initialText: args,
}).finished,
);
},
category: CommandCategories.advanced,
}),
new Command({
command: "query",
description: _td("Opens chat with the given user"),
args: "<user-id>",
runFn: function(roomId, userId) {
// easter-egg for now: look up phone numbers through the thirdparty API
// (very dumb phone number detection...)
const isPhoneNumber = userId && /^\+?[0123456789]+$/.test(userId);
if (!userId || (!userId.startsWith("@") || !userId.includes(":")) && !isPhoneNumber) {
return reject(this.getUsage());
}
return success((async () => {
if (isPhoneNumber) {
const results = await CallHandler.sharedInstance().pstnLookup(this.state.value);
if (!results || results.length === 0 || !results[0].userid) {
throw new Error("Unable to find Matrix ID for phone number");
}
userId = results[0].userid;
}
const roomId = await ensureDMExists(MatrixClientPeg.get(), userId);
dis.dispatch({
action: 'view_room',
room_id: roomId,
});
})());
},
category: CommandCategories.actions,
}),
new Command({
command: "msg",
description: _td("Sends a message to the given user"),
args: "<user-id> <message>",
runFn: function(_, args) {
if (args) {
// matches the first whitespace delimited group and then the rest of the string
const matches = args.match(/^(\S+?)(?: +(.*))?$/s);
if (matches) {
const [userId, msg] = matches.slice(1);
if (msg && userId && userId.startsWith("@") && userId.includes(":")) {
return success((async () => {
const cli = MatrixClientPeg.get();
const roomId = await ensureDMExists(cli, userId);
dis.dispatch({
action: 'view_room',
room_id: roomId,
});
cli.sendTextMessage(roomId, msg);
})());
}
}
}
return reject(this.getUsage());
},
category: CommandCategories.actions,
}),
new Command({
command: "holdcall",
description: _td("Places the call in the current room on hold"),
category: CommandCategories.other,
runFn: function(roomId, args) {
const call = CallHandler.sharedInstance().getCallForRoom(roomId);
if (!call) {
return reject("No active call in this room");
}
call.setRemoteOnHold(true);
return success();
},
}),
new Command({
command: "unholdcall",
description: _td("Takes the call in the current room off hold"),
category: CommandCategories.other,
runFn: function(roomId, args) {
const call = CallHandler.sharedInstance().getCallForRoom(roomId);
if (!call) {
return reject("No active call in this room");
}
call.setRemoteOnHold(false);
return success();
},
}),
new Command({
command: "converttodm",
description: _td("Converts the room to a DM"),
category: CommandCategories.other,
runFn: function(roomId, args) {
const room = MatrixClientPeg.get().getRoom(roomId);
return success(guessAndSetDMRoom(room, true));
},
}),
new Command({
command: "converttoroom",
description: _td("Converts the DM to a room"),
category: CommandCategories.other,
runFn: function(roomId, args) {
const room = MatrixClientPeg.get().getRoom(roomId);
return success(guessAndSetDMRoom(room, false));
},
}),
// Command definitions for autocompletion ONLY:
// /me is special because its not handled by SlashCommands.js and is instead done inside the Composer classes
@ -920,10 +1147,34 @@ export const Commands = [
category: CommandCategories.messages,
hideCompletionAfterSpace: true,
}),
...CHAT_EFFECTS.map((effect) => {
return new Command({
command: effect.command,
description: effect.description(),
args: '<message>',
runFn: function(roomId, args) {
return success((async () => {
if (!args) {
args = effect.fallbackMessage();
MatrixClientPeg.get().sendEmoteMessage(roomId, args);
} else {
const content = {
msgtype: effect.msgType,
body: args,
};
MatrixClientPeg.get().sendMessage(roomId, content);
}
dis.dispatch({ action: `effects.${effect.command}` });
})());
},
category: CommandCategories.effects,
});
}),
];
// build a map from names and aliases to the Command objects.
export const CommandMap = new Map();
export const CommandMap = new Map<string, Command>();
Commands.forEach(cmd => {
CommandMap.set(cmd.command, cmd);
cmd.aliases.forEach(alias => {
@ -931,15 +1182,15 @@ Commands.forEach(cmd => {
});
});
export function parseCommandString(input) {
export function parseCommandString(input: string): { cmd?: string, args?: string } {
// trim any trailing whitespace, as it can confuse the parser for
// IRC-style commands
input = input.replace(/\s+$/, '');
if (input[0] !== '/') return null; // not a command
if (input[0] !== '/') return {}; // not a command
const bits = input.match(/^(\S+?)(?: +((.|\n)*))?$/);
let cmd;
let args;
const bits = input.match(/^(\S+?)(?:[ \n]+((.|\n)*))?$/);
let cmd: string;
let args: string;
if (bits) {
cmd = bits[1].substring(1).toLowerCase();
args = bits[2];
@ -947,7 +1198,12 @@ export function parseCommandString(input) {
cmd = input;
}
return {cmd, args};
return { cmd, args };
}
interface ICmd {
cmd?: Command;
args?: string;
}
/**
@ -958,10 +1214,14 @@ export function parseCommandString(input) {
* processing the command, or 'promise' if a request was sent out.
* Returns null if the input didn't match a command.
*/
export function getCommand(roomId, input) {
const {cmd, args} = parseCommandString(input);
export function getCommand(input: string): ICmd {
const { cmd, args } = parseCommandString(input);
if (CommandMap.has(cmd)) {
return () => CommandMap.get(cmd).run(roomId, args, cmd);
if (CommandMap.has(cmd) && CommandMap.get(cmd).isEnabled()) {
return {
cmd: CommandMap.get(cmd),
args,
};
}
return {};
}

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.
@ -15,9 +15,10 @@ limitations under the License.
*/
import classNames from 'classnames';
import { SERVICE_TYPES } from 'matrix-js-sdk/src/service-types';
import {MatrixClientPeg} from './MatrixClientPeg';
import * as sdk from './';
import { MatrixClientPeg } from './MatrixClientPeg';
import * as sdk from '.';
import Modal from './Modal';
export class TermsNotSignedError extends Error {}
@ -32,13 +33,34 @@ export class Service {
* @param {string} baseUrl The Base URL of the service (ie. before '/_matrix')
* @param {string} accessToken The user's access token for the service
*/
constructor(serviceType, baseUrl, accessToken) {
this.serviceType = serviceType;
this.baseUrl = baseUrl;
this.accessToken = accessToken;
constructor(public serviceType: SERVICE_TYPES, public baseUrl: string, public accessToken: string) {
}
}
export interface LocalisedPolicy {
name: string;
url: string;
}
export interface Policy {
// @ts-ignore: No great way to express indexed types together with other keys
version: string;
[lang: string]: LocalisedPolicy;
}
export type Policies = {
[policy: string]: Policy;
};
export type TermsInteractionCallback = (
policiesAndServicePairs: {
service: Service;
policies: Policies;
}[],
agreedUrls: string[],
extraClassNames?: string,
) => Promise<string[]>;
/**
* Start a flow where the user is presented with terms & conditions for some services
*
@ -51,8 +73,8 @@ export class Service {
* if they cancel.
*/
export async function startTermsFlow(
services,
interactionCallback = dialogTermsInteractionCallback,
services: Service[],
interactionCallback: TermsInteractionCallback = dialogTermsInteractionCallback,
) {
const termsPromises = services.map(
(s) => MatrixClientPeg.get().getTerms(s.serviceType, s.baseUrl),
@ -77,12 +99,12 @@ export async function startTermsFlow(
* }
*/
const terms = await Promise.all(termsPromises);
const terms: { policies: Policies }[] = await Promise.all(termsPromises);
const policiesAndServicePairs = terms.map((t, i) => { return { 'service': services[i], 'policies': t.policies }; });
// fetch the set of agreed policy URLs from account data
const currentAcceptedTerms = await MatrixClientPeg.get().getAccountData('m.accepted_terms');
let agreedUrlSet;
let agreedUrlSet: Set<string>;
if (!currentAcceptedTerms || !currentAcceptedTerms.getContent() || !currentAcceptedTerms.getContent().accepted) {
agreedUrlSet = new Set();
} else {
@ -96,7 +118,7 @@ export async function startTermsFlow(
// but that is not a thing the API supports, so probably best to just show
// things they've not agreed to yet.
const unagreedPoliciesAndServicePairs = [];
for (const {service, policies} of policiesAndServicePairs) {
for (const { service, policies } of policiesAndServicePairs) {
const unagreedPolicies = {};
for (const [policyName, policy] of Object.entries(policies)) {
let policyAgreed = false;
@ -110,7 +132,7 @@ export async function startTermsFlow(
if (!policyAgreed) unagreedPolicies[policyName] = policy;
}
if (Object.keys(unagreedPolicies).length > 0) {
unagreedPoliciesAndServicePairs.push({service, policies: unagreedPolicies});
unagreedPoliciesAndServicePairs.push({ service, policies: unagreedPolicies });
}
}
@ -127,7 +149,7 @@ export async function startTermsFlow(
// We only ever add to the set of URLs, so if anything has changed then we'd see a different length
if (agreedUrlSet.size !== numAcceptedBeforeAgreement) {
const newAcceptedTerms = {accepted: Array.from(agreedUrlSet)};
const newAcceptedTerms = { accepted: Array.from(agreedUrlSet) };
await MatrixClientPeg.get().setAccountData('m.accepted_terms', newAcceptedTerms);
}
@ -158,12 +180,16 @@ export async function startTermsFlow(
}
export function dialogTermsInteractionCallback(
policiesAndServicePairs,
agreedUrls,
extraClassNames,
) {
policiesAndServicePairs: {
service: Service;
policies: { [policy: string]: Policy };
}[],
agreedUrls: string[],
extraClassNames?: string,
): Promise<string[]> {
return new Promise((resolve, reject) => {
console.log("Terms that need agreement", policiesAndServicePairs);
// FIXME: Using an import will result in test failures
const TermsDialog = sdk.getComponent("views.dialogs.TermsDialog");
Modal.createTrackedDialog('Terms of Service', '', TermsDialog, {

View file

@ -1,618 +0,0 @@
/*
Copyright 2015, 2016 OpenMarket 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 CallHandler from './CallHandler';
import { _t } from './languageHandler';
import * as Roles from './Roles';
import {isValid3pidInvite} from "./RoomInvite";
import SettingsStore from "./settings/SettingsStore";
import {ALL_RULE_TYPES, ROOM_RULE_TYPES, SERVER_RULE_TYPES, USER_RULE_TYPES} from "./mjolnir/BanList";
function textForMemberEvent(ev) {
// XXX: SYJS-16 "sender is sometimes null for join messages"
const senderName = ev.sender ? ev.sender.name : ev.getSender();
const targetName = ev.target ? ev.target.name : ev.getStateKey();
const prevContent = ev.getPrevContent();
const content = ev.getContent();
const ConferenceHandler = CallHandler.getConferenceHandler();
const reason = content.reason ? (_t('Reason') + ': ' + content.reason) : '';
switch (content.membership) {
case 'invite': {
const threePidContent = content.third_party_invite;
if (threePidContent) {
if (threePidContent.display_name) {
return _t('%(targetName)s accepted the invitation for %(displayName)s.', {
targetName,
displayName: threePidContent.display_name,
});
} else {
return _t('%(targetName)s accepted an invitation.', {targetName});
}
} else {
if (ConferenceHandler && ConferenceHandler.isConferenceUser(ev.getStateKey())) {
return _t('%(senderName)s requested a VoIP conference.', {senderName});
} else {
return _t('%(senderName)s invited %(targetName)s.', {senderName, targetName});
}
}
}
case 'ban':
return _t('%(senderName)s banned %(targetName)s.', {senderName, targetName}) + ' ' + reason;
case 'join':
if (prevContent && prevContent.membership === 'join') {
if (prevContent.displayname && content.displayname && prevContent.displayname !== content.displayname) {
return _t('%(oldDisplayName)s changed their display name to %(displayName)s.', {
oldDisplayName: prevContent.displayname,
displayName: content.displayname,
});
} else if (!prevContent.displayname && content.displayname) {
return _t('%(senderName)s set their display name to %(displayName)s.', {
senderName: ev.getSender(),
displayName: content.displayname,
});
} else if (prevContent.displayname && !content.displayname) {
return _t('%(senderName)s removed their display name (%(oldDisplayName)s).', {
senderName,
oldDisplayName: prevContent.displayname,
});
} else if (prevContent.avatar_url && !content.avatar_url) {
return _t('%(senderName)s removed their profile picture.', {senderName});
} else if (prevContent.avatar_url && content.avatar_url &&
prevContent.avatar_url !== content.avatar_url) {
return _t('%(senderName)s changed their profile picture.', {senderName});
} else if (!prevContent.avatar_url && content.avatar_url) {
return _t('%(senderName)s set a profile picture.', {senderName});
} else if (SettingsStore.getValue("showHiddenEventsInTimeline")) {
// This is a null rejoin, it will only be visible if the Labs option is enabled
return _t("%(senderName)s made no change.", {senderName});
} else {
return "";
}
} else {
if (!ev.target) console.warn("Join message has no target! -- " + ev.getContent().state_key);
if (ConferenceHandler && ConferenceHandler.isConferenceUser(ev.getStateKey())) {
return _t('VoIP conference started.');
} else {
return _t('%(targetName)s joined the room.', {targetName});
}
}
case 'leave':
if (ev.getSender() === ev.getStateKey()) {
if (ConferenceHandler && ConferenceHandler.isConferenceUser(ev.getStateKey())) {
return _t('VoIP conference finished.');
} else if (prevContent.membership === "invite") {
return _t('%(targetName)s rejected the invitation.', {targetName});
} else {
return _t('%(targetName)s left the room.', {targetName});
}
} else if (prevContent.membership === "ban") {
return _t('%(senderName)s unbanned %(targetName)s.', {senderName, targetName});
} else if (prevContent.membership === "invite") {
return _t('%(senderName)s withdrew %(targetName)s\'s invitation.', {
senderName,
targetName,
}) + ' ' + reason;
} else {
// sender is not target and made the target leave, if not from invite/ban then this is a kick
return _t('%(senderName)s kicked %(targetName)s.', {senderName, targetName}) + ' ' + reason;
}
}
}
function textForTopicEvent(ev) {
const senderDisplayName = ev.sender && ev.sender.name ? ev.sender.name : ev.getSender();
return _t('%(senderDisplayName)s changed the topic to "%(topic)s".', {
senderDisplayName,
topic: ev.getContent().topic,
});
}
function textForRoomNameEvent(ev) {
const senderDisplayName = ev.sender && ev.sender.name ? ev.sender.name : ev.getSender();
if (!ev.getContent().name || ev.getContent().name.trim().length === 0) {
return _t('%(senderDisplayName)s removed the room name.', {senderDisplayName});
}
if (ev.getPrevContent().name) {
return _t('%(senderDisplayName)s changed the room name from %(oldRoomName)s to %(newRoomName)s.', {
senderDisplayName,
oldRoomName: ev.getPrevContent().name,
newRoomName: ev.getContent().name,
});
}
return _t('%(senderDisplayName)s changed the room name to %(roomName)s.', {
senderDisplayName,
roomName: ev.getContent().name,
});
}
function textForTombstoneEvent(ev) {
const senderDisplayName = ev.sender && ev.sender.name ? ev.sender.name : ev.getSender();
return _t('%(senderDisplayName)s upgraded this room.', {senderDisplayName});
}
function textForJoinRulesEvent(ev) {
const senderDisplayName = ev.sender && ev.sender.name ? ev.sender.name : ev.getSender();
switch (ev.getContent().join_rule) {
case "public":
return _t('%(senderDisplayName)s made the room public to whoever knows the link.', {senderDisplayName});
case "invite":
return _t('%(senderDisplayName)s made the room invite only.', {senderDisplayName});
default:
// The spec supports "knock" and "private", however nothing implements these.
return _t('%(senderDisplayName)s changed the join rule to %(rule)s', {
senderDisplayName,
rule: ev.getContent().join_rule,
});
}
}
function textForGuestAccessEvent(ev) {
const senderDisplayName = ev.sender && ev.sender.name ? ev.sender.name : ev.getSender();
switch (ev.getContent().guest_access) {
case "can_join":
return _t('%(senderDisplayName)s has allowed guests to join the room.', {senderDisplayName});
case "forbidden":
return _t('%(senderDisplayName)s has prevented guests from joining the room.', {senderDisplayName});
default:
// There's no other options we can expect, however just for safety's sake we'll do this.
return _t('%(senderDisplayName)s changed guest access to %(rule)s', {
senderDisplayName,
rule: ev.getContent().guest_access,
});
}
}
function textForRelatedGroupsEvent(ev) {
const senderDisplayName = ev.sender && ev.sender.name ? ev.sender.name : ev.getSender();
const groups = ev.getContent().groups || [];
const prevGroups = ev.getPrevContent().groups || [];
const added = groups.filter((g) => !prevGroups.includes(g));
const removed = prevGroups.filter((g) => !groups.includes(g));
if (added.length && !removed.length) {
return _t('%(senderDisplayName)s enabled flair for %(groups)s in this room.', {
senderDisplayName,
groups: added.join(', '),
});
} else if (!added.length && removed.length) {
return _t('%(senderDisplayName)s disabled flair for %(groups)s in this room.', {
senderDisplayName,
groups: removed.join(', '),
});
} else if (added.length && removed.length) {
return _t('%(senderDisplayName)s enabled flair for %(newGroups)s and disabled flair for ' +
'%(oldGroups)s in this room.', {
senderDisplayName,
newGroups: added.join(', '),
oldGroups: removed.join(', '),
});
} else {
// Don't bother rendering this change (because there were no changes)
return '';
}
}
function textForServerACLEvent(ev) {
const senderDisplayName = ev.sender && ev.sender.name ? ev.sender.name : ev.getSender();
const prevContent = ev.getPrevContent();
const changes = [];
const current = ev.getContent();
const prev = {
deny: Array.isArray(prevContent.deny) ? prevContent.deny : [],
allow: Array.isArray(prevContent.allow) ? prevContent.allow : [],
allow_ip_literals: !(prevContent.allow_ip_literals === false),
};
let text = "";
if (prev.deny.length === 0 && prev.allow.length === 0) {
text = `${senderDisplayName} set server ACLs for this room: `;
} else {
text = `${senderDisplayName} changed the server ACLs for this room: `;
}
if (!Array.isArray(current.allow)) {
current.allow = [];
}
/* If we know for sure everyone is banned, don't bother showing the diff view */
if (current.allow.length === 0) {
return text + "🎉 All servers are banned from participating! This room can no longer be used.";
}
if (!Array.isArray(current.deny)) {
current.deny = [];
}
const bannedServers = current.deny.filter((srv) => typeof(srv) === 'string' && !prev.deny.includes(srv));
const unbannedServers = prev.deny.filter((srv) => typeof(srv) === 'string' && !current.deny.includes(srv));
const allowedServers = current.allow.filter((srv) => typeof(srv) === 'string' && !prev.allow.includes(srv));
const unallowedServers = prev.allow.filter((srv) => typeof(srv) === 'string' && !current.allow.includes(srv));
if (bannedServers.length > 0) {
changes.push(`Servers matching ${bannedServers.join(", ")} are now banned.`);
}
if (unbannedServers.length > 0) {
changes.push(`Servers matching ${unbannedServers.join(", ")} were removed from the ban list.`);
}
if (allowedServers.length > 0) {
changes.push(`Servers matching ${allowedServers.join(", ")} are now allowed.`);
}
if (unallowedServers.length > 0) {
changes.push(`Servers matching ${unallowedServers.join(", ")} were removed from the allowed list.`);
}
if (prev.allow_ip_literals !== current.allow_ip_literals) {
const allowban = current.allow_ip_literals ? "allowed" : "banned";
changes.push(`Participating from a server using an IP literal hostname is now ${allowban}.`);
}
return text + changes.join(" ");
}
function textForMessageEvent(ev) {
const senderDisplayName = ev.sender && ev.sender.name ? ev.sender.name : ev.getSender();
let message = senderDisplayName + ': ' + ev.getContent().body;
if (ev.getContent().msgtype === "m.emote") {
message = "* " + senderDisplayName + " " + message;
} else if (ev.getContent().msgtype === "m.image") {
message = _t('%(senderDisplayName)s sent an image.', {senderDisplayName});
}
return message;
}
function textForCanonicalAliasEvent(ev) {
const senderName = ev.sender && ev.sender.name ? ev.sender.name : ev.getSender();
const oldAlias = ev.getPrevContent().alias;
const oldAltAliases = ev.getPrevContent().alt_aliases || [];
const newAlias = ev.getContent().alias;
const newAltAliases = ev.getContent().alt_aliases || [];
const removedAltAliases = oldAltAliases.filter(alias => !newAltAliases.includes(alias));
const addedAltAliases = newAltAliases.filter(alias => !oldAltAliases.includes(alias));
if (!removedAltAliases.length && !addedAltAliases.length) {
if (newAlias) {
return _t('%(senderName)s set the main address for this room to %(address)s.', {
senderName: senderName,
address: ev.getContent().alias,
});
} else if (oldAlias) {
return _t('%(senderName)s removed the main address for this room.', {
senderName: senderName,
});
}
} else if (newAlias === oldAlias) {
if (addedAltAliases.length && !removedAltAliases.length) {
return _t('%(senderName)s added the alternative addresses %(addresses)s for this room.', {
senderName: senderName,
addresses: addedAltAliases.join(", "),
count: addedAltAliases.length,
});
} if (removedAltAliases.length && !addedAltAliases.length) {
return _t('%(senderName)s removed the alternative addresses %(addresses)s for this room.', {
senderName: senderName,
addresses: removedAltAliases.join(", "),
count: removedAltAliases.length,
});
} if (removedAltAliases.length && addedAltAliases.length) {
return _t('%(senderName)s changed the alternative addresses for this room.', {
senderName: senderName,
});
}
} else {
// both alias and alt_aliases where modified
return _t('%(senderName)s changed the main and alternative addresses for this room.', {
senderName: senderName,
});
}
// in case there is no difference between the two events,
// say something as we can't simply hide the tile from here
return _t('%(senderName)s changed the addresses for this room.', {
senderName: senderName,
});
}
function textForCallAnswerEvent(event) {
const senderName = event.sender ? event.sender.name : _t('Someone');
const supported = MatrixClientPeg.get().supportsVoip() ? '' : _t('(not supported by this browser)');
return _t('%(senderName)s answered the call.', {senderName}) + ' ' + supported;
}
function textForCallHangupEvent(event) {
const senderName = event.sender ? event.sender.name : _t('Someone');
const eventContent = event.getContent();
let reason = "";
if (!MatrixClientPeg.get().supportsVoip()) {
reason = _t('(not supported by this browser)');
} else if (eventContent.reason) {
if (eventContent.reason === "ice_failed") {
reason = _t('(could not connect media)');
} else if (eventContent.reason === "invite_timeout") {
reason = _t('(no answer)');
} else if (eventContent.reason === "user hangup") {
// workaround for https://github.com/vector-im/riot-web/issues/5178
// it seems Android randomly sets a reason of "user hangup" which is
// interpreted as an error code :(
// https://github.com/vector-im/riot-android/issues/2623
reason = '';
} else {
reason = _t('(unknown failure: %(reason)s)', {reason: eventContent.reason});
}
}
return _t('%(senderName)s ended the call.', {senderName}) + ' ' + reason;
}
function textForCallInviteEvent(event) {
const senderName = event.sender ? event.sender.name : _t('Someone');
// FIXME: Find a better way to determine this from the event?
let isVoice = true;
if (event.getContent().offer && event.getContent().offer.sdp &&
event.getContent().offer.sdp.indexOf('m=video') !== -1) {
isVoice = false;
}
const isSupported = MatrixClientPeg.get().supportsVoip();
// This ladder could be reduced down to a couple string variables, however other languages
// can have a hard time translating those strings. In an effort to make translations easier
// and more accurate, we break out the string-based variables to a couple booleans.
if (isVoice && isSupported) {
return _t("%(senderName)s placed a voice call.", {senderName});
} else if (isVoice && !isSupported) {
return _t("%(senderName)s placed a voice call. (not supported by this browser)", {senderName});
} else if (!isVoice && isSupported) {
return _t("%(senderName)s placed a video call.", {senderName});
} else if (!isVoice && !isSupported) {
return _t("%(senderName)s placed a video call. (not supported by this browser)", {senderName});
}
}
function textForThreePidInviteEvent(event) {
const senderName = event.sender ? event.sender.name : event.getSender();
if (!isValid3pidInvite(event)) {
const targetDisplayName = event.getPrevContent().display_name || _t("Someone");
return _t('%(senderName)s revoked the invitation for %(targetDisplayName)s to join the room.', {
senderName,
targetDisplayName,
});
}
return _t('%(senderName)s sent an invitation to %(targetDisplayName)s to join the room.', {
senderName,
targetDisplayName: event.getContent().display_name,
});
}
function textForHistoryVisibilityEvent(event) {
const senderName = event.sender ? event.sender.name : event.getSender();
switch (event.getContent().history_visibility) {
case 'invited':
return _t('%(senderName)s made future room history visible to all room members, '
+ 'from the point they are invited.', {senderName});
case 'joined':
return _t('%(senderName)s made future room history visible to all room members, '
+ 'from the point they joined.', {senderName});
case 'shared':
return _t('%(senderName)s made future room history visible to all room members.', {senderName});
case 'world_readable':
return _t('%(senderName)s made future room history visible to anyone.', {senderName});
default:
return _t('%(senderName)s made future room history visible to unknown (%(visibility)s).', {
senderName,
visibility: event.getContent().history_visibility,
});
}
}
// Currently will only display a change if a user's power level is changed
function textForPowerEvent(event) {
const senderName = event.sender ? event.sender.name : event.getSender();
if (!event.getPrevContent() || !event.getPrevContent().users ||
!event.getContent() || !event.getContent().users) {
return '';
}
const userDefault = event.getContent().users_default || 0;
// Construct set of userIds
const users = [];
Object.keys(event.getContent().users).forEach(
(userId) => {
if (users.indexOf(userId) === -1) users.push(userId);
},
);
Object.keys(event.getPrevContent().users).forEach(
(userId) => {
if (users.indexOf(userId) === -1) users.push(userId);
},
);
const diff = [];
// XXX: This is also surely broken for i18n
users.forEach((userId) => {
// Previous power level
const from = event.getPrevContent().users[userId];
// Current power level
const to = event.getContent().users[userId];
if (to !== from) {
diff.push(
_t('%(userId)s from %(fromPowerLevel)s to %(toPowerLevel)s', {
userId,
fromPowerLevel: Roles.textualPowerLevel(from, userDefault),
toPowerLevel: Roles.textualPowerLevel(to, userDefault),
}),
);
}
});
if (!diff.length) {
return '';
}
return _t('%(senderName)s changed the power level of %(powerLevelDiffText)s.', {
senderName,
powerLevelDiffText: diff.join(", "),
});
}
function textForPinnedEvent(event) {
const senderName = event.sender ? event.sender.name : event.getSender();
return _t("%(senderName)s changed the pinned messages for the room.", {senderName});
}
function textForWidgetEvent(event) {
const senderName = event.getSender();
const {name: prevName, type: prevType, url: prevUrl} = event.getPrevContent();
const {name, type, url} = event.getContent() || {};
let widgetName = name || prevName || type || prevType || '';
// Apply sentence case to widget name
if (widgetName && widgetName.length > 0) {
widgetName = widgetName[0].toUpperCase() + widgetName.slice(1) + ' ';
}
// If the widget was removed, its content should be {}, but this is sufficiently
// equivalent to that condition.
if (url) {
if (prevUrl) {
return _t('%(widgetName)s widget modified by %(senderName)s', {
widgetName, senderName,
});
} else {
return _t('%(widgetName)s widget added by %(senderName)s', {
widgetName, senderName,
});
}
} else {
return _t('%(widgetName)s widget removed by %(senderName)s', {
widgetName, senderName,
});
}
}
function textForMjolnirEvent(event) {
const senderName = event.getSender();
const {entity: prevEntity} = event.getPrevContent();
const {entity, recommendation, reason} = event.getContent();
// Rule removed
if (!entity) {
if (USER_RULE_TYPES.includes(event.getType())) {
return _t("%(senderName)s removed the rule banning users matching %(glob)s",
{senderName, glob: prevEntity});
} else if (ROOM_RULE_TYPES.includes(event.getType())) {
return _t("%(senderName)s removed the rule banning rooms matching %(glob)s",
{senderName, glob: prevEntity});
} else if (SERVER_RULE_TYPES.includes(event.getType())) {
return _t("%(senderName)s removed the rule banning servers matching %(glob)s",
{senderName, glob: prevEntity});
}
// Unknown type. We'll say something, but we shouldn't end up here.
return _t("%(senderName)s removed a ban rule matching %(glob)s", {senderName, glob: prevEntity});
}
// Invalid rule
if (!recommendation || !reason) return _t(`%(senderName)s updated an invalid ban rule`, {senderName});
// Rule updated
if (entity === prevEntity) {
if (USER_RULE_TYPES.includes(event.getType())) {
return _t("%(senderName)s updated the rule banning users matching %(glob)s for %(reason)s",
{senderName, glob: entity, reason});
} else if (ROOM_RULE_TYPES.includes(event.getType())) {
return _t("%(senderName)s updated the rule banning rooms matching %(glob)s for %(reason)s",
{senderName, glob: entity, reason});
} else if (SERVER_RULE_TYPES.includes(event.getType())) {
return _t("%(senderName)s updated the rule banning servers matching %(glob)s for %(reason)s",
{senderName, glob: entity, reason});
}
// Unknown type. We'll say something but we shouldn't end up here.
return _t("%(senderName)s updated a ban rule matching %(glob)s for %(reason)s",
{senderName, glob: entity, reason});
}
// New rule
if (!prevEntity) {
if (USER_RULE_TYPES.includes(event.getType())) {
return _t("%(senderName)s created a rule banning users matching %(glob)s for %(reason)s",
{senderName, glob: entity, reason});
} else if (ROOM_RULE_TYPES.includes(event.getType())) {
return _t("%(senderName)s created a rule banning rooms matching %(glob)s for %(reason)s",
{senderName, glob: entity, reason});
} else if (SERVER_RULE_TYPES.includes(event.getType())) {
return _t("%(senderName)s created a rule banning servers matching %(glob)s for %(reason)s",
{senderName, glob: entity, reason});
}
// Unknown type. We'll say something but we shouldn't end up here.
return _t("%(senderName)s created a ban rule matching %(glob)s for %(reason)s",
{senderName, glob: entity, reason});
}
// else the entity !== prevEntity - count as a removal & add
if (USER_RULE_TYPES.includes(event.getType())) {
return _t("%(senderName)s changed a rule that was banning users matching %(oldGlob)s to matching " +
"%(newGlob)s for %(reason)s",
{senderName, oldGlob: prevEntity, newGlob: entity, reason});
} else if (ROOM_RULE_TYPES.includes(event.getType())) {
return _t("%(senderName)s changed a rule that was banning rooms matching %(oldGlob)s to matching " +
"%(newGlob)s for %(reason)s",
{senderName, oldGlob: prevEntity, newGlob: entity, reason});
} else if (SERVER_RULE_TYPES.includes(event.getType())) {
return _t("%(senderName)s changed a rule that was banning servers matching %(oldGlob)s to matching " +
"%(newGlob)s for %(reason)s",
{senderName, oldGlob: prevEntity, newGlob: entity, reason});
}
// Unknown type. We'll say something but we shouldn't end up here.
return _t("%(senderName)s updated a ban rule that was matching %(oldGlob)s to matching %(newGlob)s " +
"for %(reason)s", {senderName, oldGlob: prevEntity, newGlob: entity, reason});
}
const handlers = {
'm.room.message': textForMessageEvent,
'm.call.invite': textForCallInviteEvent,
'm.call.answer': textForCallAnswerEvent,
'm.call.hangup': textForCallHangupEvent,
};
const stateHandlers = {
'm.room.canonical_alias': textForCanonicalAliasEvent,
'm.room.name': textForRoomNameEvent,
'm.room.topic': textForTopicEvent,
'm.room.member': textForMemberEvent,
'm.room.third_party_invite': textForThreePidInviteEvent,
'm.room.history_visibility': textForHistoryVisibilityEvent,
'm.room.power_levels': textForPowerEvent,
'm.room.pinned_events': textForPinnedEvent,
'm.room.server_acl': textForServerACLEvent,
'm.room.tombstone': textForTombstoneEvent,
'm.room.join_rules': textForJoinRulesEvent,
'm.room.guest_access': textForGuestAccessEvent,
'm.room.related_groups': textForRelatedGroupsEvent,
'im.vector.modular.widgets': textForWidgetEvent,
};
// Add all the Mjolnir stuff to the renderer
for (const evType of ALL_RULE_TYPES) {
stateHandlers[evType] = textForMjolnirEvent;
}
export function textForEvent(ev) {
const handler = (ev.isState() ? stateHandlers : handlers)[ev.getType()];
if (handler) return handler(ev);
return '';
}

695
src/TextForEvent.tsx Normal file
View file

@ -0,0 +1,695 @@
/*
Copyright 2015, 2016 OpenMarket 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 React from 'react';
import { MatrixClientPeg } from './MatrixClientPeg';
import { _t } from './languageHandler';
import * as Roles from './Roles';
import { isValid3pidInvite } from "./RoomInvite";
import SettingsStore from "./settings/SettingsStore";
import { ALL_RULE_TYPES, ROOM_RULE_TYPES, SERVER_RULE_TYPES, USER_RULE_TYPES } from "./mjolnir/BanList";
import { WIDGET_LAYOUT_EVENT_TYPE } from "./stores/widgets/WidgetLayoutStore";
import { RightPanelPhases } from './stores/RightPanelStorePhases';
import { Action } from './dispatcher/actions';
import defaultDispatcher from './dispatcher/dispatcher';
import { SetRightPanelPhasePayload } from './dispatcher/payloads/SetRightPanelPhasePayload';
import { MatrixEvent } from "matrix-js-sdk/src/models/event";
// These functions are frequently used just to check whether an event has
// any text to display at all. For this reason they return deferred values
// to avoid the expense of looking up translations when they're not needed.
function textForMemberEvent(ev: MatrixEvent): () => string | null {
// XXX: SYJS-16 "sender is sometimes null for join messages"
const senderName = ev.sender ? ev.sender.name : ev.getSender();
const targetName = ev.target ? ev.target.name : ev.getStateKey();
const prevContent = ev.getPrevContent();
const content = ev.getContent();
const reason = content.reason;
switch (content.membership) {
case 'invite': {
const threePidContent = content.third_party_invite;
if (threePidContent) {
if (threePidContent.display_name) {
return () => _t('%(targetName)s accepted the invitation for %(displayName)s', {
targetName,
displayName: threePidContent.display_name,
});
} else {
return () => _t('%(targetName)s accepted an invitation', { targetName });
}
} else {
return () => _t('%(senderName)s invited %(targetName)s', { senderName, targetName });
}
}
case 'ban':
return () => reason
? _t('%(senderName)s banned %(targetName)s: %(reason)s', { senderName, targetName, reason })
: _t('%(senderName)s banned %(targetName)s', { senderName, targetName });
case 'join':
if (prevContent && prevContent.membership === 'join') {
if (prevContent.displayname && content.displayname && prevContent.displayname !== content.displayname) {
return () => _t('%(oldDisplayName)s changed their display name to %(displayName)s', {
oldDisplayName: prevContent.displayname,
displayName: content.displayname,
});
} else if (!prevContent.displayname && content.displayname) {
return () => _t('%(senderName)s set their display name to %(displayName)s', {
senderName: ev.getSender(),
displayName: content.displayname,
});
} else if (prevContent.displayname && !content.displayname) {
return () => _t('%(senderName)s removed their display name (%(oldDisplayName)s)', {
senderName,
oldDisplayName: prevContent.displayname,
});
} else if (prevContent.avatar_url && !content.avatar_url) {
return () => _t('%(senderName)s removed their profile picture', { senderName });
} else if (prevContent.avatar_url && content.avatar_url &&
prevContent.avatar_url !== content.avatar_url) {
return () => _t('%(senderName)s changed their profile picture', { senderName });
} else if (!prevContent.avatar_url && content.avatar_url) {
return () => _t('%(senderName)s set a profile picture', { senderName });
} else if (SettingsStore.getValue("showHiddenEventsInTimeline")) {
// This is a null rejoin, it will only be visible if using 'show hidden events' (labs)
return () => _t("%(senderName)s made no change", { senderName });
} else {
return null;
}
} else {
if (!ev.target) console.warn("Join message has no target! -- " + ev.getContent().state_key);
return () => _t('%(targetName)s joined the room', { targetName });
}
case 'leave':
if (ev.getSender() === ev.getStateKey()) {
if (prevContent.membership === "invite") {
return () => _t('%(targetName)s rejected the invitation', { targetName });
} else {
return () => reason
? _t('%(targetName)s left the room: %(reason)s', { targetName, reason })
: _t('%(targetName)s left the room', { targetName });
}
} else if (prevContent.membership === "ban") {
return () => _t('%(senderName)s unbanned %(targetName)s', { senderName, targetName });
} else if (prevContent.membership === "invite") {
return () => reason
? _t('%(senderName)s withdrew %(targetName)s\'s invitation: %(reason)s', {
senderName,
targetName,
reason,
})
: _t('%(senderName)s withdrew %(targetName)s\'s invitation', { senderName, targetName });
} else if (prevContent.membership === "join") {
return () => reason
? _t('%(senderName)s kicked %(targetName)s: %(reason)s', {
senderName,
targetName,
reason,
})
: _t('%(senderName)s kicked %(targetName)s', { senderName, targetName });
} else {
return null;
}
}
}
function textForTopicEvent(ev: MatrixEvent): () => string | null {
const senderDisplayName = ev.sender && ev.sender.name ? ev.sender.name : ev.getSender();
return () => _t('%(senderDisplayName)s changed the topic to "%(topic)s".', {
senderDisplayName,
topic: ev.getContent().topic,
});
}
function textForRoomNameEvent(ev: MatrixEvent): () => string | null {
const senderDisplayName = ev.sender && ev.sender.name ? ev.sender.name : ev.getSender();
if (!ev.getContent().name || ev.getContent().name.trim().length === 0) {
return () => _t('%(senderDisplayName)s removed the room name.', { senderDisplayName });
}
if (ev.getPrevContent().name) {
return () => _t('%(senderDisplayName)s changed the room name from %(oldRoomName)s to %(newRoomName)s.', {
senderDisplayName,
oldRoomName: ev.getPrevContent().name,
newRoomName: ev.getContent().name,
});
}
return () => _t('%(senderDisplayName)s changed the room name to %(roomName)s.', {
senderDisplayName,
roomName: ev.getContent().name,
});
}
function textForTombstoneEvent(ev: MatrixEvent): () => string | null {
const senderDisplayName = ev.sender && ev.sender.name ? ev.sender.name : ev.getSender();
return () => _t('%(senderDisplayName)s upgraded this room.', { senderDisplayName });
}
function textForJoinRulesEvent(ev: MatrixEvent): () => string | null {
const senderDisplayName = ev.sender && ev.sender.name ? ev.sender.name : ev.getSender();
switch (ev.getContent().join_rule) {
case "public":
return () => _t('%(senderDisplayName)s made the room public to whoever knows the link.', {
senderDisplayName,
});
case "invite":
return () => _t('%(senderDisplayName)s made the room invite only.', {
senderDisplayName,
});
default:
// The spec supports "knock" and "private", however nothing implements these.
return () => _t('%(senderDisplayName)s changed the join rule to %(rule)s', {
senderDisplayName,
rule: ev.getContent().join_rule,
});
}
}
function textForGuestAccessEvent(ev: MatrixEvent): () => string | null {
const senderDisplayName = ev.sender && ev.sender.name ? ev.sender.name : ev.getSender();
switch (ev.getContent().guest_access) {
case "can_join":
return () => _t('%(senderDisplayName)s has allowed guests to join the room.', { senderDisplayName });
case "forbidden":
return () => _t('%(senderDisplayName)s has prevented guests from joining the room.', { senderDisplayName });
default:
// There's no other options we can expect, however just for safety's sake we'll do this.
return () => _t('%(senderDisplayName)s changed guest access to %(rule)s', {
senderDisplayName,
rule: ev.getContent().guest_access,
});
}
}
function textForRelatedGroupsEvent(ev: MatrixEvent): () => string | null {
const senderDisplayName = ev.sender && ev.sender.name ? ev.sender.name : ev.getSender();
const groups = ev.getContent().groups || [];
const prevGroups = ev.getPrevContent().groups || [];
const added = groups.filter((g) => !prevGroups.includes(g));
const removed = prevGroups.filter((g) => !groups.includes(g));
if (added.length && !removed.length) {
return () => _t('%(senderDisplayName)s enabled flair for %(groups)s in this room.', {
senderDisplayName,
groups: added.join(', '),
});
} else if (!added.length && removed.length) {
return () => _t('%(senderDisplayName)s disabled flair for %(groups)s in this room.', {
senderDisplayName,
groups: removed.join(', '),
});
} else if (added.length && removed.length) {
return () => _t('%(senderDisplayName)s enabled flair for %(newGroups)s and disabled flair for ' +
'%(oldGroups)s in this room.', {
senderDisplayName,
newGroups: added.join(', '),
oldGroups: removed.join(', '),
});
} else {
// Don't bother rendering this change (because there were no changes)
return null;
}
}
function textForServerACLEvent(ev: MatrixEvent): () => string | null {
const senderDisplayName = ev.sender && ev.sender.name ? ev.sender.name : ev.getSender();
const prevContent = ev.getPrevContent();
const current = ev.getContent();
const prev = {
deny: Array.isArray(prevContent.deny) ? prevContent.deny : [],
allow: Array.isArray(prevContent.allow) ? prevContent.allow : [],
allow_ip_literals: !(prevContent.allow_ip_literals === false),
};
let getText = null;
if (prev.deny.length === 0 && prev.allow.length === 0) {
getText = () => _t("%(senderDisplayName)s set the server ACLs for this room.", { senderDisplayName });
} else {
getText = () => _t("%(senderDisplayName)s changed the server ACLs for this room.", { senderDisplayName });
}
if (!Array.isArray(current.allow)) {
current.allow = [];
}
// If we know for sure everyone is banned, mark the room as obliterated
if (current.allow.length === 0) {
return () => getText() + " " +
_t("🎉 All servers are banned from participating! This room can no longer be used.");
}
return getText;
}
function textForMessageEvent(ev: MatrixEvent): () => string | null {
return () => {
const senderDisplayName = ev.sender && ev.sender.name ? ev.sender.name : ev.getSender();
let message = senderDisplayName + ': ' + ev.getContent().body;
if (ev.getContent().msgtype === "m.emote") {
message = "* " + senderDisplayName + " " + message;
} else if (ev.getContent().msgtype === "m.image") {
message = _t('%(senderDisplayName)s sent an image.', { senderDisplayName });
}
return message;
};
}
function textForCanonicalAliasEvent(ev: MatrixEvent): () => string | null {
const senderName = ev.sender && ev.sender.name ? ev.sender.name : ev.getSender();
const oldAlias = ev.getPrevContent().alias;
const oldAltAliases = ev.getPrevContent().alt_aliases || [];
const newAlias = ev.getContent().alias;
const newAltAliases = ev.getContent().alt_aliases || [];
const removedAltAliases = oldAltAliases.filter(alias => !newAltAliases.includes(alias));
const addedAltAliases = newAltAliases.filter(alias => !oldAltAliases.includes(alias));
if (!removedAltAliases.length && !addedAltAliases.length) {
if (newAlias) {
return () => _t('%(senderName)s set the main address for this room to %(address)s.', {
senderName: senderName,
address: ev.getContent().alias,
});
} else if (oldAlias) {
return () => _t('%(senderName)s removed the main address for this room.', {
senderName: senderName,
});
}
} else if (newAlias === oldAlias) {
if (addedAltAliases.length && !removedAltAliases.length) {
return () => _t('%(senderName)s added the alternative addresses %(addresses)s for this room.', {
senderName: senderName,
addresses: addedAltAliases.join(", "),
count: addedAltAliases.length,
});
} if (removedAltAliases.length && !addedAltAliases.length) {
return () => _t('%(senderName)s removed the alternative addresses %(addresses)s for this room.', {
senderName: senderName,
addresses: removedAltAliases.join(", "),
count: removedAltAliases.length,
});
} if (removedAltAliases.length && addedAltAliases.length) {
return () => _t('%(senderName)s changed the alternative addresses for this room.', {
senderName: senderName,
});
}
} else {
// both alias and alt_aliases where modified
return () => _t('%(senderName)s changed the main and alternative addresses for this room.', {
senderName: senderName,
});
}
// in case there is no difference between the two events,
// say something as we can't simply hide the tile from here
return () => _t('%(senderName)s changed the addresses for this room.', {
senderName: senderName,
});
}
function textForCallAnswerEvent(event): () => string | null {
return () => {
const senderName = event.sender ? event.sender.name : _t('Someone');
const supported = MatrixClientPeg.get().supportsVoip() ? '' : _t('(not supported by this browser)');
return _t('%(senderName)s answered the call.', { senderName }) + ' ' + supported;
};
}
function textForCallHangupEvent(event): () => string | null {
const getSenderName = () => event.sender ? event.sender.name : _t('Someone');
const eventContent = event.getContent();
let getReason = () => "";
if (!MatrixClientPeg.get().supportsVoip()) {
getReason = () => _t('(not supported by this browser)');
} else if (eventContent.reason) {
if (eventContent.reason === "ice_failed") {
// We couldn't establish a connection at all
getReason = () => _t('(could not connect media)');
} else if (eventContent.reason === "ice_timeout") {
// We established a connection but it died
getReason = () => _t('(connection failed)');
} else if (eventContent.reason === "user_media_failed") {
// The other side couldn't open capture devices
getReason = () => _t("(their device couldn't start the camera / microphone)");
} else if (eventContent.reason === "unknown_error") {
// An error code the other side doesn't have a way to express
// (as opposed to an error code they gave but we don't know about,
// in which case we show the error code)
getReason = () => _t("(an error occurred)");
} else if (eventContent.reason === "invite_timeout") {
getReason = () => _t('(no answer)');
} else if (eventContent.reason === "user hangup" || eventContent.reason === "user_hangup") {
// workaround for https://github.com/vector-im/element-web/issues/5178
// it seems Android randomly sets a reason of "user hangup" which is
// interpreted as an error code :(
// https://github.com/vector-im/riot-android/issues/2623
// Also the correct hangup code as of VoIP v1 (with underscore)
getReason = () => '';
} else {
getReason = () => _t('(unknown failure: %(reason)s)', { reason: eventContent.reason });
}
}
return () => _t('%(senderName)s ended the call.', { senderName: getSenderName() }) + ' ' + getReason();
}
function textForCallRejectEvent(event): () => string | null {
return () => {
const senderName = event.sender ? event.sender.name : _t('Someone');
return _t('%(senderName)s declined the call.', { senderName });
};
}
function textForCallInviteEvent(event): () => string | null {
const getSenderName = () => event.sender ? event.sender.name : _t('Someone');
// FIXME: Find a better way to determine this from the event?
let isVoice = true;
if (event.getContent().offer && event.getContent().offer.sdp &&
event.getContent().offer.sdp.indexOf('m=video') !== -1) {
isVoice = false;
}
const isSupported = MatrixClientPeg.get().supportsVoip();
// This ladder could be reduced down to a couple string variables, however other languages
// can have a hard time translating those strings. In an effort to make translations easier
// and more accurate, we break out the string-based variables to a couple booleans.
if (isVoice && isSupported) {
return () => _t("%(senderName)s placed a voice call.", {
senderName: getSenderName(),
});
} else if (isVoice && !isSupported) {
return () => _t("%(senderName)s placed a voice call. (not supported by this browser)", {
senderName: getSenderName(),
});
} else if (!isVoice && isSupported) {
return () => _t("%(senderName)s placed a video call.", {
senderName: getSenderName(),
});
} else if (!isVoice && !isSupported) {
return () => _t("%(senderName)s placed a video call. (not supported by this browser)", {
senderName: getSenderName(),
});
}
}
function textForThreePidInviteEvent(event): () => string | null {
const senderName = event.sender ? event.sender.name : event.getSender();
if (!isValid3pidInvite(event)) {
return () => _t('%(senderName)s revoked the invitation for %(targetDisplayName)s to join the room.', {
senderName,
targetDisplayName: event.getPrevContent().display_name || _t("Someone"),
});
}
return () => _t('%(senderName)s sent an invitation to %(targetDisplayName)s to join the room.', {
senderName,
targetDisplayName: event.getContent().display_name,
});
}
function textForHistoryVisibilityEvent(event): () => string | null {
const senderName = event.sender ? event.sender.name : event.getSender();
switch (event.getContent().history_visibility) {
case 'invited':
return () => _t('%(senderName)s made future room history visible to all room members, '
+ 'from the point they are invited.', { senderName });
case 'joined':
return () => _t('%(senderName)s made future room history visible to all room members, '
+ 'from the point they joined.', { senderName });
case 'shared':
return () => _t('%(senderName)s made future room history visible to all room members.', { senderName });
case 'world_readable':
return () => _t('%(senderName)s made future room history visible to anyone.', { senderName });
default:
return () => _t('%(senderName)s made future room history visible to unknown (%(visibility)s).', {
senderName,
visibility: event.getContent().history_visibility,
});
}
}
// Currently will only display a change if a user's power level is changed
function textForPowerEvent(event): () => string | null {
const senderName = event.sender ? event.sender.name : event.getSender();
if (!event.getPrevContent() || !event.getPrevContent().users ||
!event.getContent() || !event.getContent().users) {
return null;
}
const previousUserDefault = event.getPrevContent().users_default || 0;
const currentUserDefault = event.getContent().users_default || 0;
// Construct set of userIds
const users = [];
Object.keys(event.getContent().users).forEach(
(userId) => {
if (users.indexOf(userId) === -1) users.push(userId);
},
);
Object.keys(event.getPrevContent().users).forEach(
(userId) => {
if (users.indexOf(userId) === -1) users.push(userId);
},
);
const diffs = [];
users.forEach((userId) => {
// Previous power level
let from = event.getPrevContent().users[userId];
if (!Number.isInteger(from)) {
from = previousUserDefault;
}
// Current power level
let to = event.getContent().users[userId];
if (!Number.isInteger(to)) {
to = currentUserDefault;
}
if (from === previousUserDefault && to === currentUserDefault) { return; }
if (to !== from) {
diffs.push({ userId, from, to });
}
});
if (!diffs.length) {
return null;
}
// XXX: This is also surely broken for i18n
return () => _t('%(senderName)s changed the power level of %(powerLevelDiffText)s.', {
senderName,
powerLevelDiffText: diffs.map(diff =>
_t('%(userId)s from %(fromPowerLevel)s to %(toPowerLevel)s', {
userId: diff.userId,
fromPowerLevel: Roles.textualPowerLevel(diff.from, previousUserDefault),
toPowerLevel: Roles.textualPowerLevel(diff.to, currentUserDefault),
}),
).join(", "),
});
}
const onPinnedMessagesClick = (): void => {
defaultDispatcher.dispatch<SetRightPanelPhasePayload>({
action: Action.SetRightPanelPhase,
phase: RightPanelPhases.PinnedMessages,
allowClose: false,
});
};
function textForPinnedEvent(event: MatrixEvent, allowJSX: boolean): () => string | JSX.Element | null {
if (!SettingsStore.getValue("feature_pinning")) return null;
const senderName = event.sender ? event.sender.name : event.getSender();
if (allowJSX) {
return () => (
<span>
{
_t(
"%(senderName)s changed the <a>pinned messages</a> for the room.",
{ senderName },
{ "a": (sub) => <a onClick={onPinnedMessagesClick}> { sub } </a> },
)
}
</span>
);
}
return () => _t("%(senderName)s changed the pinned messages for the room.", { senderName });
}
function textForWidgetEvent(event): () => string | null {
const senderName = event.getSender();
const { name: prevName, type: prevType, url: prevUrl } = event.getPrevContent();
const { name, type, url } = event.getContent() || {};
let widgetName = name || prevName || type || prevType || '';
// Apply sentence case to widget name
if (widgetName && widgetName.length > 0) {
widgetName = widgetName[0].toUpperCase() + widgetName.slice(1);
}
// If the widget was removed, its content should be {}, but this is sufficiently
// equivalent to that condition.
if (url) {
if (prevUrl) {
return () => _t('%(widgetName)s widget modified by %(senderName)s', {
widgetName, senderName,
});
} else {
return () => _t('%(widgetName)s widget added by %(senderName)s', {
widgetName, senderName,
});
}
} else {
return () => _t('%(widgetName)s widget removed by %(senderName)s', {
widgetName, senderName,
});
}
}
function textForWidgetLayoutEvent(event): () => string | null {
const senderName = event.sender?.name || event.getSender();
return () => _t("%(senderName)s has updated the widget layout", { senderName });
}
function textForMjolnirEvent(event): () => string | null {
const senderName = event.getSender();
const { entity: prevEntity } = event.getPrevContent();
const { entity, recommendation, reason } = event.getContent();
// Rule removed
if (!entity) {
if (USER_RULE_TYPES.includes(event.getType())) {
return () => _t("%(senderName)s removed the rule banning users matching %(glob)s",
{ senderName, glob: prevEntity });
} else if (ROOM_RULE_TYPES.includes(event.getType())) {
return () => _t("%(senderName)s removed the rule banning rooms matching %(glob)s",
{ senderName, glob: prevEntity });
} else if (SERVER_RULE_TYPES.includes(event.getType())) {
return () => _t("%(senderName)s removed the rule banning servers matching %(glob)s",
{ senderName, glob: prevEntity });
}
// Unknown type. We'll say something, but we shouldn't end up here.
return () => _t("%(senderName)s removed a ban rule matching %(glob)s", { senderName, glob: prevEntity });
}
// Invalid rule
if (!recommendation || !reason) return () => _t(`%(senderName)s updated an invalid ban rule`, { senderName });
// Rule updated
if (entity === prevEntity) {
if (USER_RULE_TYPES.includes(event.getType())) {
return () => _t("%(senderName)s updated the rule banning users matching %(glob)s for %(reason)s",
{ senderName, glob: entity, reason });
} else if (ROOM_RULE_TYPES.includes(event.getType())) {
return () => _t("%(senderName)s updated the rule banning rooms matching %(glob)s for %(reason)s",
{ senderName, glob: entity, reason });
} else if (SERVER_RULE_TYPES.includes(event.getType())) {
return () => _t("%(senderName)s updated the rule banning servers matching %(glob)s for %(reason)s",
{ senderName, glob: entity, reason });
}
// Unknown type. We'll say something but we shouldn't end up here.
return () => _t("%(senderName)s updated a ban rule matching %(glob)s for %(reason)s",
{ senderName, glob: entity, reason });
}
// New rule
if (!prevEntity) {
if (USER_RULE_TYPES.includes(event.getType())) {
return () => _t("%(senderName)s created a rule banning users matching %(glob)s for %(reason)s",
{ senderName, glob: entity, reason });
} else if (ROOM_RULE_TYPES.includes(event.getType())) {
return () => _t("%(senderName)s created a rule banning rooms matching %(glob)s for %(reason)s",
{ senderName, glob: entity, reason });
} else if (SERVER_RULE_TYPES.includes(event.getType())) {
return () => _t("%(senderName)s created a rule banning servers matching %(glob)s for %(reason)s",
{ senderName, glob: entity, reason });
}
// Unknown type. We'll say something but we shouldn't end up here.
return () => _t("%(senderName)s created a ban rule matching %(glob)s for %(reason)s",
{ senderName, glob: entity, reason });
}
// else the entity !== prevEntity - count as a removal & add
if (USER_RULE_TYPES.includes(event.getType())) {
return () => _t(
"%(senderName)s changed a rule that was banning users matching %(oldGlob)s to matching " +
"%(newGlob)s for %(reason)s",
{ senderName, oldGlob: prevEntity, newGlob: entity, reason },
);
} else if (ROOM_RULE_TYPES.includes(event.getType())) {
return () => _t(
"%(senderName)s changed a rule that was banning rooms matching %(oldGlob)s to matching " +
"%(newGlob)s for %(reason)s",
{ senderName, oldGlob: prevEntity, newGlob: entity, reason },
);
} else if (SERVER_RULE_TYPES.includes(event.getType())) {
return () => _t(
"%(senderName)s changed a rule that was banning servers matching %(oldGlob)s to matching " +
"%(newGlob)s for %(reason)s",
{ senderName, oldGlob: prevEntity, newGlob: entity, reason },
);
}
// Unknown type. We'll say something but we shouldn't end up here.
return () => _t("%(senderName)s updated a ban rule that was matching %(oldGlob)s to matching %(newGlob)s " +
"for %(reason)s", { senderName, oldGlob: prevEntity, newGlob: entity, reason });
}
interface IHandlers {
[type: string]: (ev: MatrixEvent, allowJSX?: boolean) => (() => string | JSX.Element | null);
}
const handlers: IHandlers = {
'm.room.message': textForMessageEvent,
'm.call.invite': textForCallInviteEvent,
'm.call.answer': textForCallAnswerEvent,
'm.call.hangup': textForCallHangupEvent,
'm.call.reject': textForCallRejectEvent,
};
const stateHandlers: IHandlers = {
'm.room.canonical_alias': textForCanonicalAliasEvent,
'm.room.name': textForRoomNameEvent,
'm.room.topic': textForTopicEvent,
'm.room.member': textForMemberEvent,
'm.room.third_party_invite': textForThreePidInviteEvent,
'm.room.history_visibility': textForHistoryVisibilityEvent,
'm.room.power_levels': textForPowerEvent,
'm.room.pinned_events': textForPinnedEvent,
'm.room.server_acl': textForServerACLEvent,
'm.room.tombstone': textForTombstoneEvent,
'm.room.join_rules': textForJoinRulesEvent,
'm.room.guest_access': textForGuestAccessEvent,
'm.room.related_groups': textForRelatedGroupsEvent,
// TODO: Enable support for m.widget event type (https://github.com/vector-im/element-web/issues/13111)
'im.vector.modular.widgets': textForWidgetEvent,
[WIDGET_LAYOUT_EVENT_TYPE]: textForWidgetLayoutEvent,
};
// Add all the Mjolnir stuff to the renderer
for (const evType of ALL_RULE_TYPES) {
stateHandlers[evType] = textForMjolnirEvent;
}
export function hasText(ev: MatrixEvent): boolean {
const handler = (ev.isState() ? stateHandlers : handlers)[ev.getType()];
return Boolean(handler?.(ev));
}
export function textForEvent(ev: MatrixEvent): string;
export function textForEvent(ev: MatrixEvent, allowJSX: true): string | JSX.Element;
export function textForEvent(ev: MatrixEvent, allowJSX = false): string | JSX.Element {
const handler = (ev.isState() ? stateHandlers : handlers)[ev.getType()];
return handler?.(ev, allowJSX)?.() || '';
}

View file

@ -1,458 +0,0 @@
/*
Copyright 2015 OpenMarket Ltd
Copyright 2017 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.
*/
const DEBUG = 0;
// utility to turn #rrggbb or rgb(r,g,b) into [red,green,blue]
function colorToRgb(color) {
if (!color) {
return [0, 0, 0];
}
if (color[0] === '#') {
color = color.slice(1);
if (color.length === 3) {
color = color[0] + color[0] +
color[1] + color[1] +
color[2] + color[2];
}
const val = parseInt(color, 16);
const r = (val >> 16) & 255;
const g = (val >> 8) & 255;
const b = val & 255;
return [r, g, b];
} else {
const match = color.match(/rgb\((.*?),(.*?),(.*?)\)/);
if (match) {
return [
parseInt(match[1]),
parseInt(match[2]),
parseInt(match[3]),
];
}
}
return [0, 0, 0];
}
// utility to turn [red,green,blue] into #rrggbb
function rgbToColor(rgb) {
const val = (rgb[0] << 16) | (rgb[1] << 8) | rgb[2];
return '#' + (0x1000000 + val).toString(16).slice(1);
}
class Tinter {
constructor() {
// The default colour keys to be replaced as referred to in CSS
// (should be overridden by .mx_theme_accentColor and .mx_theme_secondaryAccentColor)
this.keyRgb = [
"rgb(118, 207, 166)", // Vector Green
"rgb(234, 245, 240)", // Vector Light Green
"rgb(211, 239, 225)", // roomsublist-label-bg-color (20% Green overlaid on Light Green)
];
// Some algebra workings for calculating the tint % of Vector Green & Light Green
// x * 118 + (1 - x) * 255 = 234
// x * 118 + 255 - 255 * x = 234
// x * 118 - x * 255 = 234 - 255
// (255 - 118) x = 255 - 234
// x = (255 - 234) / (255 - 118) = 0.16
// The colour keys to be replaced as referred to in SVGs
this.keyHex = [
"#76CFA6", // Vector Green
"#EAF5F0", // Vector Light Green
"#D3EFE1", // roomsublist-label-bg-color (20% Green overlaid on Light Green)
"#FFFFFF", // white highlights of the SVGs (for switching to dark theme)
"#000000", // black lowlights of the SVGs (for switching to dark theme)
];
// track the replacement colours actually being used
// defaults to our keys.
this.colors = [
this.keyHex[0],
this.keyHex[1],
this.keyHex[2],
this.keyHex[3],
this.keyHex[4],
];
// track the most current tint request inputs (which may differ from the
// end result stored in this.colors
this.currentTint = [
undefined,
undefined,
undefined,
undefined,
undefined,
];
this.cssFixups = [
// { theme: {
// style: a style object that should be fixed up taken from a stylesheet
// attr: name of the attribute to be clobbered, e.g. 'color'
// index: ordinal of primary, secondary or tertiary
// },
// }
];
// CSS attributes to be fixed up
this.cssAttrs = [
"color",
"backgroundColor",
"borderColor",
"borderTopColor",
"borderBottomColor",
"borderLeftColor",
];
this.svgAttrs = [
"fill",
"stroke",
];
// List of functions to call when the tint changes.
this.tintables = [];
// the currently loaded theme (if any)
this.theme = undefined;
// whether to force a tint (e.g. after changing theme)
this.forceTint = false;
}
/**
* Register a callback to fire when the tint changes.
* This is used to rewrite the tintable SVGs with the new tint.
*
* It's not possible to unregister a tintable callback. So this can only be
* used to register a static callback. If a set of tintables will change
* over time then the best bet is to register a single callback for the
* entire set.
*
* To ensure the tintable work happens at least once, it is also called as
* part of registration.
*
* @param {Function} tintable Function to call when the tint changes.
*/
registerTintable(tintable) {
this.tintables.push(tintable);
tintable();
}
getKeyRgb() {
return this.keyRgb;
}
tint(primaryColor, secondaryColor, tertiaryColor) {
return;
// eslint-disable-next-line no-unreachable
this.currentTint[0] = primaryColor;
this.currentTint[1] = secondaryColor;
this.currentTint[2] = tertiaryColor;
this.calcCssFixups();
if (DEBUG) {
console.log("Tinter.tint(" + primaryColor + ", " +
secondaryColor + ", " +
tertiaryColor + ")");
}
if (!primaryColor) {
primaryColor = this.keyRgb[0];
secondaryColor = this.keyRgb[1];
tertiaryColor = this.keyRgb[2];
}
if (!secondaryColor) {
const x = 0.16; // average weighting factor calculated from vector green & light green
const rgb = colorToRgb(primaryColor);
rgb[0] = x * rgb[0] + (1 - x) * 255;
rgb[1] = x * rgb[1] + (1 - x) * 255;
rgb[2] = x * rgb[2] + (1 - x) * 255;
secondaryColor = rgbToColor(rgb);
}
if (!tertiaryColor) {
const x = 0.19;
const rgb1 = colorToRgb(primaryColor);
const rgb2 = colorToRgb(secondaryColor);
rgb1[0] = x * rgb1[0] + (1 - x) * rgb2[0];
rgb1[1] = x * rgb1[1] + (1 - x) * rgb2[1];
rgb1[2] = x * rgb1[2] + (1 - x) * rgb2[2];
tertiaryColor = rgbToColor(rgb1);
}
if (this.forceTint == false &&
this.colors[0] === primaryColor &&
this.colors[1] === secondaryColor &&
this.colors[2] === tertiaryColor) {
return;
}
this.forceTint = false;
this.colors[0] = primaryColor;
this.colors[1] = secondaryColor;
this.colors[2] = tertiaryColor;
if (DEBUG) {
console.log("Tinter.tint final: (" + primaryColor + ", " +
secondaryColor + ", " +
tertiaryColor + ")");
}
// go through manually fixing up the stylesheets.
this.applyCssFixups();
// tell all the SVGs to go fix themselves up
// we don't do this as a dispatch otherwise it will visually lag
this.tintables.forEach(function(tintable) {
tintable();
});
}
tintSvgWhite(whiteColor) {
this.currentTint[3] = whiteColor;
if (!whiteColor) {
whiteColor = this.colors[3];
}
if (this.colors[3] === whiteColor) {
return;
}
this.colors[3] = whiteColor;
this.tintables.forEach(function(tintable) {
tintable();
});
}
tintSvgBlack(blackColor) {
this.currentTint[4] = blackColor;
if (!blackColor) {
blackColor = this.colors[4];
}
if (this.colors[4] === blackColor) {
return;
}
this.colors[4] = blackColor;
this.tintables.forEach(function(tintable) {
tintable();
});
}
setTheme(theme) {
this.theme = theme;
// update keyRgb from the current theme CSS itself, if it defines it
if (document.getElementById('mx_theme_accentColor')) {
this.keyRgb[0] = window.getComputedStyle(
document.getElementById('mx_theme_accentColor')).color;
}
if (document.getElementById('mx_theme_secondaryAccentColor')) {
this.keyRgb[1] = window.getComputedStyle(
document.getElementById('mx_theme_secondaryAccentColor')).color;
}
if (document.getElementById('mx_theme_tertiaryAccentColor')) {
this.keyRgb[2] = window.getComputedStyle(
document.getElementById('mx_theme_tertiaryAccentColor')).color;
}
this.calcCssFixups();
this.forceTint = true;
this.tint(this.currentTint[0], this.currentTint[1], this.currentTint[2]);
if (theme === 'dark') {
// abuse the tinter to change all the SVG's #fff to #2d2d2d
// XXX: obviously this shouldn't be hardcoded here.
this.tintSvgWhite('#2d2d2d');
this.tintSvgBlack('#dddddd');
} else {
this.tintSvgWhite('#ffffff');
this.tintSvgBlack('#000000');
}
}
calcCssFixups() {
// cache our fixups
if (this.cssFixups[this.theme]) return;
if (DEBUG) {
console.debug("calcCssFixups start for " + this.theme + " (checking " +
document.styleSheets.length +
" stylesheets)");
}
this.cssFixups[this.theme] = [];
for (let i = 0; i < document.styleSheets.length; i++) {
const ss = document.styleSheets[i];
try {
if (!ss) continue; // well done safari >:(
// Chromium apparently sometimes returns null here; unsure why.
// see $14534907369972FRXBx:matrix.org in HQ
// ...ah, it's because there's a third party extension like
// privacybadger inserting its own stylesheet in there with a
// resource:// URI or something which results in a XSS error.
// See also #vector:matrix.org/$145357669685386ebCfr:matrix.org
// ...except some browsers apparently return stylesheets without
// hrefs, which we have no choice but ignore right now
// XXX seriously? we are hardcoding the name of vector's CSS file in
// here?
//
// Why do we need to limit it to vector's CSS file anyway - if there
// are other CSS files affecting the doc don't we want to apply the
// same transformations to them?
//
// Iterating through the CSS looking for matches to hack on feels
// pretty horrible anyway. And what if the application skin doesn't use
// Vector Green as its primary color?
// --richvdh
// Yes, tinting assumes that you are using the Riot skin for now.
// The right solution will be to move the CSS over to react-sdk.
// And yes, the default assets for the base skin might as well use
// Vector Green as any other colour.
// --matthew
// stylesheets we don't have permission to access (eg. ones from extensions) have a null
// href and will throw exceptions if we try to access their rules.
if (!ss.href || !ss.href.match(new RegExp('/theme-' + this.theme + '.css$'))) continue;
if (ss.disabled) continue;
if (!ss.cssRules) continue;
if (DEBUG) console.debug("calcCssFixups checking " + ss.cssRules.length + " rules for " + ss.href);
for (let j = 0; j < ss.cssRules.length; j++) {
const rule = ss.cssRules[j];
if (!rule.style) continue;
if (rule.selectorText && rule.selectorText.match(/#mx_theme/)) continue;
for (let k = 0; k < this.cssAttrs.length; k++) {
const attr = this.cssAttrs[k];
for (let l = 0; l < this.keyRgb.length; l++) {
if (rule.style[attr] === this.keyRgb[l]) {
this.cssFixups[this.theme].push({
style: rule.style,
attr: attr,
index: l,
});
}
}
}
}
} catch (e) {
// Catch any random exceptions that happen here: all sorts of things can go
// wrong with this (nulls, SecurityErrors) and mostly it's for other
// stylesheets that we don't want to proces anyway. We should not propagate an
// exception out since this will cause the app to fail to start.
console.log("Failed to calculate CSS fixups for a stylesheet: " + ss.href, e);
}
}
if (DEBUG) {
console.log("calcCssFixups end (" +
this.cssFixups[this.theme].length +
" fixups)");
}
}
applyCssFixups() {
if (DEBUG) {
console.log("applyCssFixups start (" +
this.cssFixups[this.theme].length +
" fixups)");
}
for (let i = 0; i < this.cssFixups[this.theme].length; i++) {
const cssFixup = this.cssFixups[this.theme][i];
try {
cssFixup.style[cssFixup.attr] = this.colors[cssFixup.index];
} catch (e) {
// Firefox Quantum explodes if you manually edit the CSS in the
// inspector and then try to do a tint, as apparently all the
// fixups are then stale.
console.error("Failed to apply cssFixup in Tinter! ", e.name);
}
}
if (DEBUG) console.log("applyCssFixups end");
}
// XXX: we could just move this all into TintableSvg, but as it's so similar
// to the CSS fixup stuff in Tinter (just that the fixups are stored in TintableSvg)
// keeping it here for now.
calcSvgFixups(svgs) {
// go through manually fixing up SVG colours.
// we could do this by stylesheets, but keeping the stylesheets
// updated would be a PITA, so just brute-force search for the
// key colour; cache the element and apply.
if (DEBUG) console.log("calcSvgFixups start for " + svgs);
const fixups = [];
for (let i = 0; i < svgs.length; i++) {
let svgDoc;
try {
svgDoc = svgs[i].contentDocument;
} catch (e) {
let msg = 'Failed to get svg.contentDocument of ' + svgs[i].toString();
if (e.message) {
msg += e.message;
}
if (e.stack) {
msg += ' | stack: ' + e.stack;
}
console.error(msg);
}
if (!svgDoc) continue;
const tags = svgDoc.getElementsByTagName("*");
for (let j = 0; j < tags.length; j++) {
const tag = tags[j];
for (let k = 0; k < this.svgAttrs.length; k++) {
const attr = this.svgAttrs[k];
for (let l = 0; l < this.keyHex.length; l++) {
if (tag.getAttribute(attr) &&
tag.getAttribute(attr).toUpperCase() === this.keyHex[l]) {
fixups.push({
node: tag,
attr: attr,
index: l,
});
}
}
}
}
}
if (DEBUG) console.log("calcSvgFixups end");
return fixups;
}
applySvgFixups(fixups) {
if (DEBUG) console.log("applySvgFixups start for " + fixups);
for (let i = 0; i < fixups.length; i++) {
const svgFixup = fixups[i];
svgFixup.node.setAttribute(svgFixup.attr, this.colors[svgFixup.index]);
}
if (DEBUG) console.log("applySvgFixups end");
}
}
if (global.singletonTinter === undefined) {
global.singletonTinter = new Tinter();
}
export default global.singletonTinter;

View file

@ -1,84 +0,0 @@
/*
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.
*/
// const OUTBOUND_API_NAME = 'toWidget';
// Initiate requests using the "toWidget" postMessage API and handle responses
// NOTE: ToWidgetPostMessageApi only handles message events with a data payload with a
// response field
export default class ToWidgetPostMessageApi {
constructor(timeoutMs) {
this._timeoutMs = timeoutMs || 5000; // default to 5s timer
this._counter = 0;
this._requestMap = {
// $ID: {resolve, reject}
};
this.start = this.start.bind(this);
this.stop = this.stop.bind(this);
this.onPostMessage = this.onPostMessage.bind(this);
}
start() {
window.addEventListener('message', this.onPostMessage);
}
stop() {
window.removeEventListener('message', this.onPostMessage);
}
onPostMessage(ev) {
// THIS IS ALL UNSAFE EXECUTION.
// We do not verify who the sender of `ev` is!
const payload = ev.data;
// NOTE: Workaround for running in a mobile WebView where a
// postMessage immediately triggers this callback even though it is
// not the response.
if (payload.response === undefined) {
return;
}
const promise = this._requestMap[payload.requestId];
if (!promise) {
return;
}
delete this._requestMap[payload.requestId];
promise.resolve(payload);
}
// Initiate outbound requests (toWidget)
exec(action, targetWindow, targetOrigin) {
targetWindow = targetWindow || window.parent; // default to parent window
targetOrigin = targetOrigin || "*";
this._counter += 1;
action.requestId = Date.now() + "-" + Math.random().toString(36) + "-" + this._counter;
return new Promise((resolve, reject) => {
this._requestMap[action.requestId] = {resolve, reject};
targetWindow.postMessage(action, targetOrigin);
if (this._timeoutMs > 0) {
setTimeout(() => {
if (!this._requestMap[action.requestId]) {
return;
}
console.error("postMessage request timed out. Sent object: " + JSON.stringify(action),
this._requestMap);
this._requestMap[action.requestId].reject(new Error("Timed out"));
delete this._requestMap[action.requestId];
}, this._timeoutMs);
}
});
}
}

View file

@ -1,5 +1,5 @@
/*
Copyright 2015, 2016 OpenMarket Ltd
Copyright 2015 - 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,36 +14,43 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import {MatrixClientPeg} from "./MatrixClientPeg";
import { Room } from "matrix-js-sdk/src/models/room";
import { MatrixEvent } from "matrix-js-sdk/src/models/event";
import { EventType } from "matrix-js-sdk/src/@types/event";
import { MatrixClientPeg } from "./MatrixClientPeg";
import shouldHideEvent from './shouldHideEvent';
import * as sdk from "./index";
import {haveTileForEvent} from "./components/views/rooms/EventTile";
import { haveTileForEvent } from "./components/views/rooms/EventTile";
/**
* Returns true iff this event arriving in a room should affect the room's
* count of unread messages
*
* @param {Object} ev The event
* @returns {boolean} True if the given event should affect the unread message count
*/
export function eventTriggersUnreadCount(ev) {
if (ev.sender && ev.sender.userId == MatrixClientPeg.get().credentials.userId) {
return false;
} else if (ev.getType() == 'm.room.member') {
return false;
} else if (ev.getType() == 'm.room.third_party_invite') {
return false;
} else if (ev.getType() == 'm.call.answer' || ev.getType() == 'm.call.hangup') {
return false;
} else if (ev.getType() == 'm.room.message' && ev.getContent().msgtype == 'm.notify') {
return false;
} else if (ev.getType() == 'm.room.aliases' || ev.getType() == 'm.room.canonical_alias') {
return false;
} else if (ev.getType() == 'm.room.server_acl') {
export function eventTriggersUnreadCount(ev: MatrixEvent): boolean {
if (ev.getSender() === MatrixClientPeg.get().credentials.userId) {
return false;
}
switch (ev.getType()) {
case EventType.RoomMember:
case EventType.RoomThirdPartyInvite:
case EventType.CallAnswer:
case EventType.CallHangup:
case EventType.RoomAliases:
case EventType.RoomCanonicalAlias:
case EventType.RoomServerAcl:
return false;
}
if (ev.isRedacted()) return false;
return haveTileForEvent(ev);
}
export function doesRoomHaveUnreadMessages(room) {
const myUserId = MatrixClientPeg.get().credentials.userId;
export function doesRoomHaveUnreadMessages(room: Room): boolean {
const myUserId = MatrixClientPeg.get().getUserId();
// get the most recent read receipt sent by our account.
// N.B. this is NOT a read marker (RM, aka "read up to marker"),
@ -52,13 +59,11 @@ export function doesRoomHaveUnreadMessages(room) {
// as we don't send RRs for our own messages, make sure we special case that
// if *we* sent the last message into the room, we consider it not unread!
// Should fix: https://github.com/vector-im/riot-web/issues/3263
// https://github.com/vector-im/riot-web/issues/2427
// Should fix: https://github.com/vector-im/element-web/issues/3263
// https://github.com/vector-im/element-web/issues/2427
// ...and possibly some of the others at
// https://github.com/vector-im/riot-web/issues/3363
if (room.timeline.length &&
room.timeline[room.timeline.length - 1].sender &&
room.timeline[room.timeline.length - 1].sender.userId === myUserId) {
// https://github.com/vector-im/element-web/issues/3363
if (room.timeline.length && room.timeline[room.timeline.length - 1].getSender() === myUserId) {
return false;
}

View file

@ -15,7 +15,7 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import dis from './dispatcher';
import dis from './dispatcher/dispatcher';
import Timer from './utils/Timer';
// important these are larger than the timeouts of timers
@ -38,26 +38,23 @@ const RECENTLY_ACTIVE_THRESHOLD_MS = 2 * 60 * 1000;
* see doc on the userActive* functions for what these mean.
*/
export default class UserActivity {
constructor(windowObj, documentObj) {
this._window = windowObj;
this._document = documentObj;
private readonly activeNowTimeout: Timer;
private readonly activeRecentlyTimeout: Timer;
private attachedActiveNowTimers: Timer[] = [];
private attachedActiveRecentlyTimers: Timer[] = [];
private lastScreenX = 0;
private lastScreenY = 0;
this._attachedActiveNowTimers = [];
this._attachedActiveRecentlyTimers = [];
this._activeNowTimeout = new Timer(CURRENTLY_ACTIVE_THRESHOLD_MS);
this._activeRecentlyTimeout = new Timer(RECENTLY_ACTIVE_THRESHOLD_MS);
this._onUserActivity = this._onUserActivity.bind(this);
this._onWindowBlurred = this._onWindowBlurred.bind(this);
this._onPageVisibilityChanged = this._onPageVisibilityChanged.bind(this);
this.lastScreenX = 0;
this.lastScreenY = 0;
constructor(private readonly window: Window, private readonly document: Document) {
this.activeNowTimeout = new Timer(CURRENTLY_ACTIVE_THRESHOLD_MS);
this.activeRecentlyTimeout = new Timer(RECENTLY_ACTIVE_THRESHOLD_MS);
}
static sharedInstance() {
if (global.mxUserActivity === undefined) {
global.mxUserActivity = new UserActivity(window, document);
if (window.mxUserActivity === undefined) {
window.mxUserActivity = new UserActivity(window, document);
}
return global.mxUserActivity;
return window.mxUserActivity;
}
/**
@ -69,8 +66,8 @@ export default class UserActivity {
* later on when the user does become active.
* @param {Timer} timer the timer to use
*/
timeWhileActiveNow(timer) {
this._timeWhile(timer, this._attachedActiveNowTimers);
public timeWhileActiveNow(timer: Timer) {
this.timeWhile(timer, this.attachedActiveNowTimers);
if (this.userActiveNow()) {
timer.start();
}
@ -85,14 +82,14 @@ export default class UserActivity {
* later on when the user does become active.
* @param {Timer} timer the timer to use
*/
timeWhileActiveRecently(timer) {
this._timeWhile(timer, this._attachedActiveRecentlyTimers);
public timeWhileActiveRecently(timer: Timer) {
this.timeWhile(timer, this.attachedActiveRecentlyTimers);
if (this.userActiveRecently()) {
timer.start();
}
}
_timeWhile(timer, attachedTimers) {
private timeWhile(timer: Timer, attachedTimers: Timer[]) {
// important this happens first
const index = attachedTimers.indexOf(timer);
if (index === -1) {
@ -112,36 +109,36 @@ export default class UserActivity {
/**
* Start listening to user activity
*/
start() {
this._document.addEventListener('mousedown', this._onUserActivity);
this._document.addEventListener('mousemove', this._onUserActivity);
this._document.addEventListener('keydown', this._onUserActivity);
this._document.addEventListener("visibilitychange", this._onPageVisibilityChanged);
this._window.addEventListener("blur", this._onWindowBlurred);
this._window.addEventListener("focus", this._onUserActivity);
public start() {
this.document.addEventListener('mousedown', this.onUserActivity);
this.document.addEventListener('mousemove', this.onUserActivity);
this.document.addEventListener('keydown', this.onUserActivity);
this.document.addEventListener("visibilitychange", this.onPageVisibilityChanged);
this.window.addEventListener("blur", this.onWindowBlurred);
this.window.addEventListener("focus", this.onUserActivity);
// can't use document.scroll here because that's only the document
// itself being scrolled. Need to use addEventListener's useCapture.
// also this needs to be the wheel event, not scroll, as scroll is
// fired when the view scrolls down for a new message.
this._window.addEventListener('wheel', this._onUserActivity, {
passive: true, capture: true,
this.window.addEventListener('wheel', this.onUserActivity, {
passive: true,
capture: true,
});
}
/**
* Stop tracking user activity
*/
stop() {
this._document.removeEventListener('mousedown', this._onUserActivity);
this._document.removeEventListener('mousemove', this._onUserActivity);
this._document.removeEventListener('keydown', this._onUserActivity);
this._window.removeEventListener('wheel', this._onUserActivity, {
passive: true, capture: true,
public stop() {
this.document.removeEventListener('mousedown', this.onUserActivity);
this.document.removeEventListener('mousemove', this.onUserActivity);
this.document.removeEventListener('keydown', this.onUserActivity);
this.window.removeEventListener('wheel', this.onUserActivity, {
capture: true,
});
this._document.removeEventListener("visibilitychange", this._onPageVisibilityChanged);
this._window.removeEventListener("blur", this._onWindowBlurred);
this._window.removeEventListener("focus", this._onUserActivity);
this.document.removeEventListener("visibilitychange", this.onPageVisibilityChanged);
this.window.removeEventListener("blur", this.onWindowBlurred);
this.window.removeEventListener("focus", this.onUserActivity);
}
/**
@ -151,8 +148,8 @@ export default class UserActivity {
* user's attention at any given moment.
* @returns {boolean} true if user is currently 'active'
*/
userActiveNow() {
return this._activeNowTimeout.isRunning();
public userActiveNow() {
return this.activeNowTimeout.isRunning();
}
/**
@ -163,27 +160,27 @@ export default class UserActivity {
* (or they may have gone to make tea and left the window focused).
* @returns {boolean} true if user has been active recently
*/
userActiveRecently() {
return this._activeRecentlyTimeout.isRunning();
public userActiveRecently() {
return this.activeRecentlyTimeout.isRunning();
}
_onPageVisibilityChanged(e) {
if (this._document.visibilityState === "hidden") {
this._activeNowTimeout.abort();
this._activeRecentlyTimeout.abort();
private onPageVisibilityChanged = e => {
if (this.document.visibilityState === "hidden") {
this.activeNowTimeout.abort();
this.activeRecentlyTimeout.abort();
} else {
this._onUserActivity(e);
this.onUserActivity(e);
}
}
};
_onWindowBlurred() {
this._activeNowTimeout.abort();
this._activeRecentlyTimeout.abort();
}
private onWindowBlurred = () => {
this.activeNowTimeout.abort();
this.activeRecentlyTimeout.abort();
};
_onUserActivity(event) {
private onUserActivity = (event: MouseEvent) => {
// ignore anything if the window isn't focused
if (!this._document.hasFocus()) return;
if (!this.document.hasFocus()) return;
if (event.screenX && event.type === "mousemove") {
if (event.screenX === this.lastScreenX && event.screenY === this.lastScreenY) {
@ -194,26 +191,26 @@ export default class UserActivity {
this.lastScreenY = event.screenY;
}
dis.dispatch({action: 'user_activity'});
if (!this._activeNowTimeout.isRunning()) {
this._activeNowTimeout.start();
dis.dispatch({action: 'user_activity_start'});
dis.dispatch({ action: 'user_activity' });
if (!this.activeNowTimeout.isRunning()) {
this.activeNowTimeout.start();
dis.dispatch({ action: 'user_activity_start' });
this._runTimersUntilTimeout(this._attachedActiveNowTimers, this._activeNowTimeout);
UserActivity.runTimersUntilTimeout(this.attachedActiveNowTimers, this.activeNowTimeout);
} else {
this._activeNowTimeout.restart();
this.activeNowTimeout.restart();
}
if (!this._activeRecentlyTimeout.isRunning()) {
this._activeRecentlyTimeout.start();
if (!this.activeRecentlyTimeout.isRunning()) {
this.activeRecentlyTimeout.start();
this._runTimersUntilTimeout(this._attachedActiveRecentlyTimers, this._activeRecentlyTimeout);
UserActivity.runTimersUntilTimeout(this.attachedActiveRecentlyTimers, this.activeRecentlyTimeout);
} else {
this._activeRecentlyTimeout.restart();
this.activeRecentlyTimeout.restart();
}
}
};
async _runTimersUntilTimeout(attachedTimers, timeout) {
private static async runTimersUntilTimeout(attachedTimers: Timer[], timeout: Timer) {
attachedTimers.forEach((t) => t.start());
try {
await timeout.finished();

View file

@ -1,5 +1,5 @@
/*
Copyright 2017 New Vector 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,15 +14,19 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
const emailRegex = /^\S+@\S+\.\S+$/;
import PropTypes from "prop-types";
const emailRegex = /^\S+@\S+\.\S+$/;
const mxUserIdRegex = /^@\S+:\S+$/;
const mxRoomIdRegex = /^!\S+:\S+$/;
import PropTypes from 'prop-types';
export const addressTypes = [
'mx-user-id', 'mx-room-id', 'email',
];
export const addressTypes = ['mx-user-id', 'mx-room-id', 'email'];
export enum AddressType {
Email = "email",
MatrixUserId = "mx-user-id",
MatrixRoomId = "mx-room-id",
}
// PropType definition for an object describing
// an address that can be invited to a room (which
@ -40,18 +44,13 @@ export const UserAddressType = PropTypes.shape({
isKnown: PropTypes.bool,
});
export function getAddressType(inputText) {
const isEmailAddress = emailRegex.test(inputText);
const isUserId = mxUserIdRegex.test(inputText);
const isRoomId = mxRoomIdRegex.test(inputText);
// sanity check the input for user IDs
if (isEmailAddress) {
return 'email';
} else if (isUserId) {
return 'mx-user-id';
} else if (isRoomId) {
return 'mx-room-id';
export function getAddressType(inputText: string): AddressType | null {
if (emailRegex.test(inputText)) {
return AddressType.Email;
} else if (mxUserIdRegex.test(inputText)) {
return AddressType.MatrixUserId;
} else if (mxRoomIdRegex.test(inputText)) {
return AddressType.MatrixRoomId;
} else {
return null;
}

View file

@ -1,135 +0,0 @@
/*
Copyright 2015, 2016 OpenMarket Ltd
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.
*/
import {createNewMatrixCall as jsCreateNewMatrixCall, Room} from "matrix-js-sdk";
import CallHandler from './CallHandler';
import {MatrixClientPeg} from "./MatrixClientPeg";
// FIXME: this is Riot (Vector) specific code, but will be removed shortly when
// we switch over to jitsi entirely for video conferencing.
// FIXME: This currently forces Vector to try to hit the matrix.org AS for conferencing.
// This is bad because it prevents people running their own ASes from being used.
// This isn't permanent and will be customisable in the future: see the proposal
// at docs/conferencing.md for more info.
const USER_PREFIX = "fs_";
const DOMAIN = "matrix.org";
export function ConferenceCall(matrixClient, groupChatRoomId) {
this.client = matrixClient;
this.groupRoomId = groupChatRoomId;
this.confUserId = getConferenceUserIdForRoom(this.groupRoomId);
}
ConferenceCall.prototype.setup = function() {
const self = this;
return this._joinConferenceUser().then(function() {
return self._getConferenceUserRoom();
}).then(function(room) {
// return a call for *this* room to be placed. We also tack on
// confUserId to speed up lookups (else we'd need to loop every room
// looking for a 1:1 room with this conf user ID!)
const call = jsCreateNewMatrixCall(self.client, room.roomId);
call.confUserId = self.confUserId;
call.groupRoomId = self.groupRoomId;
return call;
});
};
ConferenceCall.prototype._joinConferenceUser = function() {
// Make sure the conference user is in the group chat room
const groupRoom = this.client.getRoom(this.groupRoomId);
if (!groupRoom) {
return Promise.reject("Bad group room ID");
}
const member = groupRoom.getMember(this.confUserId);
if (member && member.membership === "join") {
return Promise.resolve();
}
return this.client.invite(this.groupRoomId, this.confUserId);
};
ConferenceCall.prototype._getConferenceUserRoom = function() {
// Use an existing 1:1 with the conference user; else make one
const rooms = this.client.getRooms();
let confRoom = null;
for (let i = 0; i < rooms.length; i++) {
const confUser = rooms[i].getMember(this.confUserId);
if (confUser && confUser.membership === "join" &&
rooms[i].getJoinedMemberCount() === 2) {
confRoom = rooms[i];
break;
}
}
if (confRoom) {
return Promise.resolve(confRoom);
}
return this.client.createRoom({
preset: "private_chat",
invite: [this.confUserId],
}).then(function(res) {
return new Room(res.room_id, null, MatrixClientPeg.get().getUserId());
});
};
/**
* Check if this user ID is in fact a conference bot.
* @param {string} userId The user ID to check.
* @return {boolean} True if it is a conference bot.
*/
export function isConferenceUser(userId) {
if (userId.indexOf("@" + USER_PREFIX) !== 0) {
return false;
}
const base64part = userId.split(":")[0].substring(1 + USER_PREFIX.length);
if (base64part) {
const decoded = new Buffer(base64part, "base64").toString();
// ! $STUFF : $STUFF
return /^!.+:.+/.test(decoded);
}
return false;
}
export function getConferenceUserIdForRoom(roomId) {
// abuse browserify's core node Buffer support (strip padding ='s)
const base64RoomId = new Buffer(roomId).toString("base64").replace(/=/g, "");
return "@" + USER_PREFIX + base64RoomId + ":" + DOMAIN;
}
export function createNewMatrixCall(client, roomId) {
const confCall = new ConferenceCall(
client, roomId,
);
return confCall.setup();
}
export function getConferenceCallForRoom(roomId) {
// search for a conference 1:1 call for this group chat room ID
const activeCall = CallHandler.getAnyActiveCall();
if (activeCall && activeCall.confUserId) {
const thisRoomConfUserId = getConferenceUserIdForRoom(
roomId,
);
if (thisRoomConfUserId === activeCall.confUserId) {
return activeCall;
}
}
return null;
}
// TODO: Document this.
export const slot = 'conference';

View file

@ -1,17 +0,0 @@
import Velocity from "velocity-animate";
// courtesy of https://github.com/julianshapiro/velocity/issues/283
// We only use easeOutBounce (easeInBounce is just sort of nonsensical)
function bounce( p ) {
let pow2;
let bounce = 4;
while ( p < ( ( pow2 = Math.pow( 2, --bounce ) ) - 1 ) / 11 ) {
// just sets pow2
}
return 1 / Math.pow( 4, 3 - bounce ) - 7.5625 * Math.pow( ( pow2 * 3 - 2 ) / 22 - p, 2 );
}
Velocity.Easings.easeOutBounce = function(p) {
return 1 - bounce(1 - p);
};

128
src/VoipUserMapper.ts Normal file
View file

@ -0,0 +1,128 @@
/*
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 { ensureVirtualRoomExists, findDMForUser } from './createRoom';
import { MatrixClientPeg } from "./MatrixClientPeg";
import DMRoomMap from "./utils/DMRoomMap";
import CallHandler, { VIRTUAL_ROOM_EVENT_TYPE } from './CallHandler';
import { Room } from 'matrix-js-sdk/src/models/room';
// Functions for mapping virtual users & rooms. Currently the only lookup
// is sip virtual: there could be others in the future.
export default class VoipUserMapper {
// We store mappings of virtual -> native room IDs here until the local echo for the
// account data arrives.
private virtualToNativeRoomIdCache = new Map<string, string>();
public static sharedInstance(): VoipUserMapper {
if (window.mxVoipUserMapper === undefined) window.mxVoipUserMapper = new VoipUserMapper();
return window.mxVoipUserMapper;
}
private async userToVirtualUser(userId: string): Promise<string> {
const results = await CallHandler.sharedInstance().sipVirtualLookup(userId);
if (results.length === 0 || !results[0].fields.lookup_success) return null;
return results[0].userid;
}
public async getOrCreateVirtualRoomForRoom(roomId: string): Promise<string> {
const userId = DMRoomMap.shared().getUserIdForRoomId(roomId);
if (!userId) return null;
const virtualUser = await this.userToVirtualUser(userId);
if (!virtualUser) return null;
const virtualRoomId = await ensureVirtualRoomExists(MatrixClientPeg.get(), virtualUser, roomId);
MatrixClientPeg.get().setRoomAccountData(virtualRoomId, VIRTUAL_ROOM_EVENT_TYPE, {
native_room: roomId,
});
this.virtualToNativeRoomIdCache.set(virtualRoomId, roomId);
return virtualRoomId;
}
public nativeRoomForVirtualRoom(roomId: string): string {
const cachedNativeRoomId = this.virtualToNativeRoomIdCache.get(roomId);
if (cachedNativeRoomId) {
console.log(
"Returning native room ID " + cachedNativeRoomId + " for virtual room ID " + roomId + " from cache",
);
return cachedNativeRoomId;
}
const virtualRoom = MatrixClientPeg.get().getRoom(roomId);
if (!virtualRoom) return null;
const virtualRoomEvent = virtualRoom.getAccountData(VIRTUAL_ROOM_EVENT_TYPE);
if (!virtualRoomEvent || !virtualRoomEvent.getContent()) return null;
const nativeRoomID = virtualRoomEvent.getContent()['native_room'];
const nativeRoom = MatrixClientPeg.get().getRoom(nativeRoomID);
if (!nativeRoom || nativeRoom.getMyMembership() !== 'join') return null;
return nativeRoomID;
}
public isVirtualRoom(room: Room): boolean {
if (this.nativeRoomForVirtualRoom(room.roomId)) return true;
if (this.virtualToNativeRoomIdCache.has(room.roomId)) return true;
// also look in the create event for the claimed native room ID, which is the only
// way we can recognise a virtual room we've created when it first arrives down
// our stream. We don't trust this in general though, as it could be faked by an
// inviter: our main source of truth is the DM state.
const roomCreateEvent = room.currentState.getStateEvents("m.room.create", "");
if (!roomCreateEvent || !roomCreateEvent.getContent()) return false;
// we only look at this for rooms we created (so inviters can't just cause rooms
// to be invisible)
if (roomCreateEvent.getSender() !== MatrixClientPeg.get().getUserId()) return false;
const claimedNativeRoomId = roomCreateEvent.getContent()[VIRTUAL_ROOM_EVENT_TYPE];
return Boolean(claimedNativeRoomId);
}
public async onNewInvitedRoom(invitedRoom: Room): Promise<void> {
if (!CallHandler.sharedInstance().getSupportsVirtualRooms()) return;
const inviterId = invitedRoom.getDMInviter();
console.log(`Checking virtual-ness of room ID ${invitedRoom.roomId}, invited by ${inviterId}`);
const result = await CallHandler.sharedInstance().sipNativeLookup(inviterId);
if (result.length === 0) {
return;
}
if (result[0].fields.is_virtual) {
const nativeUser = result[0].userid;
const nativeRoom = findDMForUser(MatrixClientPeg.get(), nativeUser);
if (nativeRoom) {
// It's a virtual room with a matching native room, so set the room account data. This
// will make sure we know where how to map calls and also allow us know not to display
// it in the future.
MatrixClientPeg.get().setRoomAccountData(invitedRoom.roomId, VIRTUAL_ROOM_EVENT_TYPE, {
native_room: nativeRoom.roomId,
});
// also auto-join the virtual room if we have a matching native room
// (possibly we should only join if we've also joined the native room, then we'd also have
// to make sure we joined virtual rooms on joining a native one)
MatrixClientPeg.get().joinRoom(invitedRoom.roomId);
}
// also put this room in the virtual room ID cache so isVirtualRoom return the right answer
// in however long it takes for the echo of setAccountData to come down the sync
this.virtualToNativeRoomIdCache.set(invitedRoom.roomId, nativeRoom.roomId);
}
}
}

View file

@ -14,19 +14,18 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import {MatrixClientPeg} from "./MatrixClientPeg";
import { Room } from "matrix-js-sdk/src/models/room";
import { RoomMember } from "matrix-js-sdk/src/models/room-member";
import { MatrixClientPeg } from "./MatrixClientPeg";
import { _t } from './languageHandler';
export function usersTypingApartFromMeAndIgnored(room) {
return usersTyping(
room, [MatrixClientPeg.get().credentials.userId].concat(MatrixClientPeg.get().getIgnoredUsers()),
);
export function usersTypingApartFromMeAndIgnored(room: Room): RoomMember[] {
return usersTyping(room, [MatrixClientPeg.get().getUserId()].concat(MatrixClientPeg.get().getIgnoredUsers()));
}
export function usersTypingApartFromMe(room) {
return usersTyping(
room, [MatrixClientPeg.get().credentials.userId],
);
export function usersTypingApartFromMe(room: Room): RoomMember[] {
return usersTyping(room, [MatrixClientPeg.get().getUserId()]);
}
/**
@ -34,15 +33,11 @@ export function usersTypingApartFromMe(room) {
* to exclude, return a list of user objects who are typing.
* @param {Room} room: room object to get users from.
* @param {string[]} exclude: list of user mxids to exclude.
* @returns {string[]} list of user objects who are typing.
* @returns {RoomMember[]} list of user objects who are typing.
*/
export function usersTyping(room, exclude) {
export function usersTyping(room: Room, exclude: string[] = []): RoomMember[] {
const whoIsTyping = [];
if (exclude === undefined) {
exclude = [];
}
const memberKeys = Object.keys(room.currentState.members);
for (let i = 0; i < memberKeys.length; ++i) {
const userId = memberKeys[i];
@ -57,26 +52,27 @@ export function usersTyping(room, exclude) {
return whoIsTyping;
}
export function whoIsTypingString(whoIsTyping, limit) {
export function whoIsTypingString(whoIsTyping: RoomMember[], limit: number): string {
let othersCount = 0;
if (whoIsTyping.length > limit) {
othersCount = whoIsTyping.length - limit + 1;
}
if (whoIsTyping.length === 0) {
return '';
} else if (whoIsTyping.length === 1) {
return _t('%(displayName)s is typing …', {displayName: whoIsTyping[0].name});
return _t('%(displayName)s is typing …', { displayName: whoIsTyping[0].name });
}
const names = whoIsTyping.map(function(m) {
return m.name;
});
if (othersCount>=1) {
const names = whoIsTyping.map(m => m.name);
if (othersCount >= 1) {
return _t('%(names)s and %(count)s others are typing …', {
names: names.slice(0, limit - 1).join(', '),
count: othersCount,
});
} else {
const lastPerson = names.pop();
return _t('%(names)s and %(lastPerson)s are typing …', {names: names.join(', '), lastPerson: lastPerson});
return _t('%(names)s and %(lastPerson)s are typing …', { names: names.join(', '), lastPerson: lastPerson });
}
}

View file

@ -1,185 +0,0 @@
/*
Copyright 2017 New Vector Ltd
Copyright 2019 Travis Ralston
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.
*/
/*
* See - https://docs.google.com/document/d/1uPF7XWY_dXTKVKV7jZQ2KmsI19wn9-kFRgQ1tFQP7wQ/edit?usp=sharing for
* spec. details / documentation.
*/
import FromWidgetPostMessageApi from './FromWidgetPostMessageApi';
import ToWidgetPostMessageApi from './ToWidgetPostMessageApi';
import Modal from "./Modal";
import {MatrixClientPeg} from "./MatrixClientPeg";
import SettingsStore from "./settings/SettingsStore";
import WidgetOpenIDPermissionsDialog from "./components/views/dialogs/WidgetOpenIDPermissionsDialog";
import WidgetUtils from "./utils/WidgetUtils";
import {KnownWidgetActions} from "./widgets/WidgetApi";
if (!global.mxFromWidgetMessaging) {
global.mxFromWidgetMessaging = new FromWidgetPostMessageApi();
global.mxFromWidgetMessaging.start();
}
if (!global.mxToWidgetMessaging) {
global.mxToWidgetMessaging = new ToWidgetPostMessageApi();
global.mxToWidgetMessaging.start();
}
const OUTBOUND_API_NAME = 'toWidget';
export default class WidgetMessaging {
constructor(widgetId, widgetUrl, isUserWidget, target) {
this.widgetId = widgetId;
this.widgetUrl = widgetUrl;
this.isUserWidget = isUserWidget;
this.target = target;
this.fromWidget = global.mxFromWidgetMessaging;
this.toWidget = global.mxToWidgetMessaging;
this._onOpenIdRequest = this._onOpenIdRequest.bind(this);
this.start();
}
messageToWidget(action) {
action.widgetId = this.widgetId; // Required to be sent for all outbound requests
return this.toWidget.exec(action, this.target).then((data) => {
// Check for errors and reject if found
if (data.response === undefined) { // null is valid
throw new Error("Missing 'response' field");
}
if (data.response && data.response.error) {
const err = data.response.error;
const msg = String(err.message ? err.message : "An error was returned");
if (err._error) {
console.error(err._error);
}
// Potential XSS attack if 'msg' is not appropriately sanitized,
// as it is untrusted input by our parent window (which we assume is Riot).
// We can't aggressively sanitize [A-z0-9] since it might be a translation.
throw new Error(msg);
}
// Return the response field for the request
return data.response;
});
}
/**
* Tells the widget that the client is ready to handle further widget requests.
* @returns {Promise<*>} Resolves after the widget has acknowledged the ready message.
*/
flagReadyToContinue() {
return this.messageToWidget({
api: OUTBOUND_API_NAME,
action: KnownWidgetActions.ClientReady,
});
}
/**
* Request a screenshot from a widget
* @return {Promise} To be resolved with screenshot data when it has been generated
*/
getScreenshot() {
console.log('Requesting screenshot for', this.widgetId);
return this.messageToWidget({
api: OUTBOUND_API_NAME,
action: "screenshot",
})
.catch((error) => new Error("Failed to get screenshot: " + error.message))
.then((response) => response.screenshot);
}
/**
* Request capabilities required by the widget
* @return {Promise} To be resolved with an array of requested widget capabilities
*/
getCapabilities() {
console.log('Requesting capabilities for', this.widgetId);
return this.messageToWidget({
api: OUTBOUND_API_NAME,
action: "capabilities",
}).then((response) => {
console.log('Got capabilities for', this.widgetId, response.capabilities);
return response.capabilities;
});
}
sendVisibility(visible) {
return this.messageToWidget({
api: OUTBOUND_API_NAME,
action: "visibility",
visible,
})
.catch((error) => {
console.error("Failed to send visibility: ", error);
});
}
start() {
this.fromWidget.addEndpoint(this.widgetId, this.widgetUrl);
this.fromWidget.addListener("get_openid", this._onOpenIdRequest);
}
stop() {
this.fromWidget.removeEndpoint(this.widgetId, this.widgetUrl);
this.fromWidget.removeListener("get_openid", this._onOpenIdRequest);
}
async _onOpenIdRequest(ev, rawEv) {
if (ev.widgetId !== this.widgetId) return; // not interesting
const widgetSecurityKey = WidgetUtils.getWidgetSecurityKey(this.widgetId, this.widgetUrl, this.isUserWidget);
const settings = SettingsStore.getValue("widgetOpenIDPermissions");
if (settings.deny && settings.deny.includes(widgetSecurityKey)) {
this.fromWidget.sendResponse(rawEv, {state: "blocked"});
return;
}
if (settings.allow && settings.allow.includes(widgetSecurityKey)) {
const responseBody = {state: "allowed"};
const credentials = await MatrixClientPeg.get().getOpenIdToken();
Object.assign(responseBody, credentials);
this.fromWidget.sendResponse(rawEv, responseBody);
return;
}
// Confirm that we received the request
this.fromWidget.sendResponse(rawEv, {state: "request"});
// Actually ask for permission to send the user's data
Modal.createTrackedDialog("OpenID widget permissions", '',
WidgetOpenIDPermissionsDialog, {
widgetUrl: this.widgetUrl,
widgetId: this.widgetId,
isUserWidget: this.isUserWidget,
onFinished: async (confirm) => {
const responseBody = {success: confirm};
if (confirm) {
const credentials = await MatrixClientPeg.get().getOpenIdToken();
Object.assign(responseBody, credentials);
}
this.messageToWidget({
api: OUTBOUND_API_NAME,
action: "openid_credentials",
data: responseBody,
}).catch((error) => {
console.error("Failed to send OpenID credentials: ", error);
});
},
},
);
}
}

View file

@ -1,37 +0,0 @@
/*
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.
*/
/**
* Represents mapping of widget instance to URLs for trusted postMessage communication.
*/
export default class WidgetMessageEndpoint {
/**
* Mapping of widget instance to URL for trusted postMessage communication.
* @param {string} widgetId Unique widget identifier
* @param {string} endpointUrl Widget wurl origin.
*/
constructor(widgetId, endpointUrl) {
if (!widgetId) {
throw new Error("No widgetId specified in widgetMessageEndpoint constructor");
}
if (!endpointUrl) {
throw new Error("No endpoint specified in widgetMessageEndpoint constructor");
}
this.widgetId = widgetId;
this.endpointUrl = endpointUrl;
}
}

View file

@ -17,10 +17,10 @@ limitations under the License.
import * as React from "react";
import classNames from "classnames";
import * as sdk from "../index";
import Modal from "../Modal";
import { _t, _td } from "../languageHandler";
import {isMac, Key} from "../Keyboard";
import { isMac, Key } from "../Keyboard";
import InfoDialog from "../components/views/dialogs/InfoDialog";
// TS: once languageHandler is TS we can probably inline this into the enum
_td("Navigation");
@ -34,6 +34,7 @@ export enum Categories {
CALLS = "Calls",
COMPOSER = "Composer",
ROOM_LIST = "Room List",
ROOM = "Room",
AUTOCOMPLETE = "Autocomplete",
}
@ -56,6 +57,8 @@ export enum Modifiers {
// Meta-modifier: isMac ? CMD : CONTROL
export const CMD_OR_CTRL = isMac ? Modifiers.COMMAND : Modifiers.CONTROL;
// Meta-key representing the digits [0-9] often found at the top of standard keyboard layouts
export const DIGITS = "digits";
interface IKeybind {
modifiers?: Modifiers[];
@ -142,6 +145,40 @@ const shortcuts: Record<Categories, IShortcut[]> = {
},
],
[Categories.ROOM]: [
{
keybinds: [{
key: Key.PAGE_UP,
}, {
key: Key.PAGE_DOWN,
}],
description: _td("Scroll up/down in the timeline"),
}, {
keybinds: [{
key: Key.ESCAPE,
}],
description: _td("Dismiss read marker and jump to bottom"),
}, {
keybinds: [{
modifiers: [Modifiers.SHIFT],
key: Key.PAGE_UP,
}],
description: _td("Jump to oldest unread message"),
}, {
keybinds: [{
modifiers: [CMD_OR_CTRL, Modifiers.SHIFT],
key: Key.U,
}],
description: _td("Upload a file"),
}, {
keybinds: [{
modifiers: [CMD_OR_CTRL],
key: Key.F,
}],
description: _td("Search (must be enabled)"),
},
],
[Categories.ROOM_LIST]: [
{
keybinds: [{
@ -181,13 +218,6 @@ const shortcuts: Record<Categories, IShortcut[]> = {
[Categories.NAVIGATION]: [
{
keybinds: [{
key: Key.PAGE_UP,
}, {
key: Key.PAGE_DOWN,
}],
description: _td("Scroll up/down in the timeline"),
}, {
keybinds: [{
modifiers: [Modifiers.ALT, Modifiers.SHIFT],
key: Key.ARROW_UP,
@ -235,6 +265,12 @@ const shortcuts: Record<Categories, IShortcut[]> = {
key: Key.SLASH,
}],
description: _td("Toggle this dialog"),
}, {
keybinds: [{
modifiers: [Modifiers.CONTROL, isMac ? Modifiers.SHIFT : Modifiers.ALT],
key: Key.H,
}],
description: _td("Go to Home View"),
},
],
@ -257,10 +293,11 @@ const shortcuts: Record<Categories, IShortcut[]> = {
const categoryOrder = [
Categories.COMPOSER,
Categories.CALLS,
Categories.ROOM_LIST,
Categories.AUTOCOMPLETE,
Categories.ROOM,
Categories.ROOM_LIST,
Categories.NAVIGATION,
Categories.CALLS,
];
interface IModal {
@ -284,6 +321,7 @@ const alternateKeyName: Record<string, string> = {
[Key.SPACE]: _td("Space"),
[Key.HOME]: _td("Home"),
[Key.END]: _td("End"),
[DIGITS]: _td("[number]"),
};
const keyIcon: Record<string, string> = {
[Key.ARROW_UP]: "↑",
@ -294,7 +332,7 @@ const keyIcon: Record<string, string> = {
const Shortcut: React.FC<{
shortcut: IShortcut;
}> = ({shortcut}) => {
}> = ({ shortcut }) => {
const classes = classNames({
"mx_KeyboardShortcutsDialog_inline": shortcut.keybinds.every(k => !k.modifiers || k.modifiers.length === 0),
});
@ -337,7 +375,6 @@ export const toggleDialog = () => {
</div>;
});
const InfoDialog = sdk.getComponent('dialogs.InfoDialog');
activeModal = Modal.createTrackedDialog("Keyboard Shortcuts", "", InfoDialog, {
className: "mx_KeyboardShortcutsDialog",
title: _t("Keyboard Shortcuts"),

View file

@ -22,9 +22,12 @@ import React, {
useMemo,
useRef,
useReducer,
Reducer,
Dispatch,
} from "react";
import PropTypes from "prop-types";
import {Key} from "../Keyboard";
import { Key } from "../Keyboard";
import { FocusHandler, Ref } from "./roving/types";
/**
* Module to simplify implementing the Roving TabIndex accessibility technique
@ -41,7 +44,17 @@ import {Key} from "../Keyboard";
const DOCUMENT_POSITION_PRECEDING = 2;
const RovingTabIndexContext = createContext({
export interface IState {
activeRef: Ref;
refs: Ref[];
}
interface IContext {
state: IState;
dispatch: Dispatch<IAction>;
}
const RovingTabIndexContext = createContext<IContext>({
state: {
activeRef: null,
refs: [], // list of refs in DOM order
@ -50,16 +63,22 @@ const RovingTabIndexContext = createContext({
});
RovingTabIndexContext.displayName = "RovingTabIndexContext";
// TODO use a TypeScript type here
const types = {
REGISTER: "REGISTER",
UNREGISTER: "UNREGISTER",
SET_FOCUS: "SET_FOCUS",
};
enum Type {
Register = "REGISTER",
Unregister = "UNREGISTER",
SetFocus = "SET_FOCUS",
}
const reducer = (state, action) => {
interface IAction {
type: Type;
payload: {
ref: Ref;
};
}
const reducer = (state: IState, action: IAction) => {
switch (action.type) {
case types.REGISTER: {
case Type.Register: {
if (state.refs.length === 0) {
// Our list of refs was empty, set activeRef to this first item
return {
@ -92,7 +111,7 @@ const reducer = (state, action) => {
],
};
}
case types.UNREGISTER: {
case Type.Unregister: {
// filter out the ref which we are removing
const refs = state.refs.filter(r => r !== action.payload.ref);
@ -117,7 +136,7 @@ const reducer = (state, action) => {
refs,
};
}
case types.SET_FOCUS: {
case Type.SetFocus: {
// update active ref
return {
...state,
@ -129,17 +148,26 @@ const reducer = (state, action) => {
}
};
export const RovingTabIndexProvider = ({children, handleHomeEnd, onKeyDown}) => {
const [state, dispatch] = useReducer(reducer, {
interface IProps {
handleHomeEnd?: boolean;
children(renderProps: {
onKeyDownHandler(ev: React.KeyboardEvent);
});
onKeyDown?(ev: React.KeyboardEvent, state: IState);
}
export const RovingTabIndexProvider: React.FC<IProps> = ({ children, handleHomeEnd, onKeyDown }) => {
const [state, dispatch] = useReducer<Reducer<IState, IAction>>(reducer, {
activeRef: null,
refs: [],
});
const context = useMemo(() => ({state, dispatch}), [state]);
const context = useMemo<IContext>(() => ({ state, dispatch }), [state]);
const onKeyDownHandler = useCallback((ev) => {
let handled = false;
if (handleHomeEnd) {
// Don't interfere with input default keydown behaviour
if (handleHomeEnd && ev.target.tagName !== "INPUT" && ev.target.tagName !== "TEXTAREA") {
// check if we actually have any items
switch (ev.key) {
case Key.HOME:
@ -163,27 +191,23 @@ export const RovingTabIndexProvider = ({children, handleHomeEnd, onKeyDown}) =>
ev.preventDefault();
ev.stopPropagation();
} else if (onKeyDown) {
return onKeyDown(ev);
return onKeyDown(ev, context.state);
}
}, [context.state, onKeyDown, handleHomeEnd]);
return <RovingTabIndexContext.Provider value={context}>
{ children({onKeyDownHandler}) }
{ children({ onKeyDownHandler }) }
</RovingTabIndexContext.Provider>;
};
RovingTabIndexProvider.propTypes = {
handleHomeEnd: PropTypes.bool,
onKeyDown: PropTypes.func,
};
// Hook to register a roving tab index
// inputRef parameter specifies the ref to use
// onFocus should be called when the index gained focus in any manner
// isActive should be used to set tabIndex in a manner such as `tabIndex={isActive ? 0 : -1}`
// ref should be passed to a DOM node which will be used for DOM compareDocumentPosition
export const useRovingTabIndex = (inputRef) => {
export const useRovingTabIndex = (inputRef?: Ref): [FocusHandler, boolean, Ref] => {
const context = useContext(RovingTabIndexContext);
let ref = useRef(null);
let ref = useRef<HTMLElement>(null);
if (inputRef) {
// if we are given a ref, use it instead of ours
@ -193,22 +217,22 @@ export const useRovingTabIndex = (inputRef) => {
// setup (after refs)
useLayoutEffect(() => {
context.dispatch({
type: types.REGISTER,
payload: {ref},
type: Type.Register,
payload: { ref },
});
// teardown
return () => {
context.dispatch({
type: types.UNREGISTER,
payload: {ref},
type: Type.Unregister,
payload: { ref },
});
};
}, []); // eslint-disable-line react-hooks/exhaustive-deps
const onFocus = useCallback(() => {
context.dispatch({
type: types.SET_FOCUS,
payload: {ref},
type: Type.SetFocus,
payload: { ref },
});
}, [ref, context]);
@ -216,9 +240,7 @@ export const useRovingTabIndex = (inputRef) => {
return [onFocus, isActive, ref];
};
// Wrapper to allow use of useRovingTabIndex outside of React Functional Components.
export const RovingTabIndexWrapper = ({children, inputRef}) => {
const [onFocus, isActive, ref] = useRovingTabIndex(inputRef);
return children({onFocus, isActive, ref});
};
// re-export the semantic helper components for simplicity
export { RovingTabIndexWrapper } from "./roving/RovingTabIndexWrapper";
export { RovingAccessibleButton } from "./roving/RovingAccessibleButton";
export { RovingAccessibleTooltipButton } from "./roving/RovingAccessibleTooltipButton";

View file

@ -0,0 +1,71 @@
/*
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 React from "react";
import { IState, RovingTabIndexProvider } from "./RovingTabIndex";
import { Key } from "../Keyboard";
interface IProps extends Omit<React.HTMLProps<HTMLDivElement>, "onKeyDown"> {
}
// This component implements the Toolbar design pattern from the WAI-ARIA Authoring Practices guidelines.
// https://www.w3.org/TR/wai-aria-practices-1.1/#toolbar
// All buttons passed in children must use RovingTabIndex to set `onFocus`, `isActive`, `ref`
const Toolbar: React.FC<IProps> = ({ children, ...props }) => {
const onKeyDown = (ev: React.KeyboardEvent, state: IState) => {
const target = ev.target as HTMLElement;
// Don't interfere with input default keydown behaviour
if (target.tagName === "INPUT") return;
let handled = true;
// HOME and END are handled by RovingTabIndexProvider
switch (ev.key) {
case Key.ARROW_UP:
case Key.ARROW_DOWN:
if (target.hasAttribute('aria-haspopup')) {
target.click();
}
break;
case Key.ARROW_LEFT:
case Key.ARROW_RIGHT:
if (state.refs.length > 0) {
const i = state.refs.findIndex(r => r === state.activeRef);
const delta = ev.key === Key.ARROW_RIGHT ? 1 : -1;
state.refs.slice((i + delta) % state.refs.length)[0].current.focus();
}
break;
default:
handled = false;
}
if (handled) {
ev.preventDefault();
ev.stopPropagation();
}
};
return <RovingTabIndexProvider handleHomeEnd={true} onKeyDown={onKeyDown}>
{({ onKeyDownHandler }) => <div {...props} onKeyDown={onKeyDownHandler} role="toolbar">
{ children }
</div>}
</RovingTabIndexProvider>;
};
export default Toolbar;

View file

@ -0,0 +1,51 @@
/*
Copyright 2015, 2016 OpenMarket Ltd
Copyright 2018 New Vector Ltd
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.
*/
import React from "react";
import AccessibleButton from "../../components/views/elements/AccessibleButton";
interface IProps extends React.ComponentProps<typeof AccessibleButton> {
label?: string;
// whether or not the context menu is currently open
isExpanded: boolean;
}
// Semantic component for representing the AccessibleButton which launches a <ContextMenu />
export const ContextMenuButton: React.FC<IProps> = ({
label,
isExpanded,
children,
onClick,
onContextMenu,
...props
}) => {
return (
<AccessibleButton
{...props}
onClick={onClick}
onContextMenu={onContextMenu || onClick}
title={label}
aria-label={label}
aria-haspopup={true}
aria-expanded={isExpanded}
>
{ children }
</AccessibleButton>
);
};

View file

@ -0,0 +1,47 @@
/*
Copyright 2015, 2016 OpenMarket Ltd
Copyright 2018 New Vector Ltd
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.
*/
import React from "react";
import AccessibleTooltipButton from "../../components/views/elements/AccessibleTooltipButton";
interface IProps extends React.ComponentProps<typeof AccessibleTooltipButton> {
// whether or not the context menu is currently open
isExpanded: boolean;
}
// Semantic component for representing the AccessibleButton which launches a <ContextMenu />
export const ContextMenuTooltipButton: React.FC<IProps> = ({
isExpanded,
children,
onClick,
onContextMenu,
...props
}) => {
return (
<AccessibleTooltipButton
{...props}
onClick={onClick}
onContextMenu={onContextMenu || onClick}
aria-haspopup={true}
aria-expanded={isExpanded}
>
{ children }
</AccessibleTooltipButton>
);
};

View file

@ -1,4 +1,5 @@
/*
Copyright 2015, 2016 OpenMarket Ltd
Copyright 2018 New Vector Ltd
Copyright 2019 The Matrix.org Foundation C.I.C.
@ -15,7 +16,15 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
export FixedDistributor from "./distributors/fixed";
export CollapseDistributor from "./distributors/collapse";
export RoomSubListDistributor from "./distributors/roomsublist";
export Resizer from "./resizer";
import React from "react";
interface IProps extends React.HTMLAttributes<HTMLDivElement> {
label: string;
}
// Semantic component for representing a role=group for grouping menu radios/checkboxes
export const MenuGroup: React.FC<IProps> = ({ children, label, ...props }) => {
return <div {...props} role="group" aria-label={label}>
{ children }
</div>;
};

View file

@ -0,0 +1,45 @@
/*
Copyright 2015, 2016 OpenMarket Ltd
Copyright 2018 New Vector Ltd
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.
*/
import React from "react";
import AccessibleButton from "../../components/views/elements/AccessibleButton";
import AccessibleTooltipButton from "../../components/views/elements/AccessibleTooltipButton";
interface IProps extends React.ComponentProps<typeof AccessibleButton> {
label?: string;
tooltip?: string;
}
// Semantic component for representing a role=menuitem
export const MenuItem: React.FC<IProps> = ({ children, label, tooltip, ...props }) => {
const ariaLabel = props["aria-label"] || label;
if (tooltip) {
return <AccessibleTooltipButton {...props} role="menuitem" tabIndex={-1} aria-label={ariaLabel} title={tooltip}>
{ children }
</AccessibleTooltipButton>;
}
return (
<AccessibleButton {...props} role="menuitem" tabIndex={-1} aria-label={ariaLabel}>
{ children }
</AccessibleButton>
);
};

View file

@ -0,0 +1,43 @@
/*
Copyright 2015, 2016 OpenMarket Ltd
Copyright 2018 New Vector Ltd
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.
*/
import React from "react";
import AccessibleButton from "../../components/views/elements/AccessibleButton";
interface IProps extends React.ComponentProps<typeof AccessibleButton> {
label?: string;
active: boolean;
}
// Semantic component for representing a role=menuitemcheckbox
export const MenuItemCheckbox: React.FC<IProps> = ({ children, label, active, disabled, ...props }) => {
return (
<AccessibleButton
{...props}
role="menuitemcheckbox"
aria-checked={active}
aria-disabled={disabled}
disabled={disabled}
tabIndex={-1}
aria-label={label}
>
{ children }
</AccessibleButton>
);
};

View file

@ -0,0 +1,43 @@
/*
Copyright 2015, 2016 OpenMarket Ltd
Copyright 2018 New Vector Ltd
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.
*/
import React from "react";
import AccessibleButton from "../../components/views/elements/AccessibleButton";
interface IProps extends React.ComponentProps<typeof AccessibleButton> {
label?: string;
active: boolean;
}
// Semantic component for representing a role=menuitemradio
export const MenuItemRadio: React.FC<IProps> = ({ children, label, active, disabled, ...props }) => {
return (
<AccessibleButton
{...props}
role="menuitemradio"
aria-checked={active}
aria-disabled={disabled}
disabled={disabled}
tabIndex={-1}
aria-label={label}
>
{ children }
</AccessibleButton>
);
};

View file

@ -0,0 +1,64 @@
/*
Copyright 2015, 2016 OpenMarket Ltd
Copyright 2018 New Vector Ltd
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.
*/
import React from "react";
import { Key } from "../../Keyboard";
import StyledCheckbox from "../../components/views/elements/StyledCheckbox";
interface IProps extends React.ComponentProps<typeof StyledCheckbox> {
label?: string;
onChange(); // we handle keyup/down ourselves so lose the ChangeEvent
onClose(): void; // gets called after onChange on Key.ENTER
}
// Semantic component for representing a styled role=menuitemcheckbox
export const StyledMenuItemCheckbox: React.FC<IProps> = ({ children, label, onChange, onClose, ...props }) => {
const onKeyDown = (e: React.KeyboardEvent) => {
if (e.key === Key.ENTER || e.key === Key.SPACE) {
e.stopPropagation();
e.preventDefault();
onChange();
// Implements https://www.w3.org/TR/wai-aria-practices/#keyboard-interaction-12
if (e.key === Key.ENTER) {
onClose();
}
}
};
const onKeyUp = (e: React.KeyboardEvent) => {
// prevent the input default handler as we handle it on keydown to match
// https://www.w3.org/TR/wai-aria-practices/examples/menubar/menubar-2/menubar-2.html
if (e.key === Key.SPACE || e.key === Key.ENTER) {
e.stopPropagation();
e.preventDefault();
}
};
return (
<StyledCheckbox
{...props}
role="menuitemcheckbox"
tabIndex={-1}
aria-label={label}
onChange={onChange}
onKeyDown={onKeyDown}
onKeyUp={onKeyUp}
>
{ children }
</StyledCheckbox>
);
};

View file

@ -0,0 +1,64 @@
/*
Copyright 2015, 2016 OpenMarket Ltd
Copyright 2018 New Vector Ltd
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.
*/
import React from "react";
import { Key } from "../../Keyboard";
import StyledRadioButton from "../../components/views/elements/StyledRadioButton";
interface IProps extends React.ComponentProps<typeof StyledRadioButton> {
label?: string;
onChange(); // we handle keyup/down ourselves so lose the ChangeEvent
onClose(): void; // gets called after onChange on Key.ENTER
}
// Semantic component for representing a styled role=menuitemradio
export const StyledMenuItemRadio: React.FC<IProps> = ({ children, label, onChange, onClose, ...props }) => {
const onKeyDown = (e: React.KeyboardEvent) => {
if (e.key === Key.ENTER || e.key === Key.SPACE) {
e.stopPropagation();
e.preventDefault();
onChange();
// Implements https://www.w3.org/TR/wai-aria-practices/#keyboard-interaction-12
if (e.key === Key.ENTER) {
onClose();
}
}
};
const onKeyUp = (e: React.KeyboardEvent) => {
// prevent the input default handler as we handle it on keydown to match
// https://www.w3.org/TR/wai-aria-practices/examples/menubar/menubar-2/menubar-2.html
if (e.key === Key.SPACE || e.key === Key.ENTER) {
e.stopPropagation();
e.preventDefault();
}
};
return (
<StyledRadioButton
{...props}
role="menuitemradio"
tabIndex={-1}
aria-label={label}
onChange={onChange}
onKeyDown={onKeyDown}
onKeyUp={onKeyUp}
>
{ children }
</StyledRadioButton>
);
};

View file

@ -0,0 +1,32 @@
/*
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 React from "react";
import AccessibleButton from "../../components/views/elements/AccessibleButton";
import { useRovingTabIndex } from "../RovingTabIndex";
import { Ref } from "./types";
interface IProps extends Omit<React.ComponentProps<typeof AccessibleButton>, "onFocus" | "inputRef" | "tabIndex"> {
inputRef?: Ref;
}
// Wrapper to allow use of useRovingTabIndex for simple AccessibleButtons outside of React Functional Components.
export const RovingAccessibleButton: React.FC<IProps> = ({ inputRef, ...props }) => {
const [onFocus, isActive, ref] = useRovingTabIndex(inputRef);
return <AccessibleButton {...props} onFocus={onFocus} inputRef={ref} tabIndex={isActive ? 0 : -1} />;
};

View file

@ -0,0 +1,33 @@
/*
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 React from "react";
import AccessibleTooltipButton from "../../components/views/elements/AccessibleTooltipButton";
import { useRovingTabIndex } from "../RovingTabIndex";
import { Ref } from "./types";
type ATBProps = React.ComponentProps<typeof AccessibleTooltipButton>;
interface IProps extends Omit<ATBProps, "onFocus" | "inputRef" | "tabIndex"> {
inputRef?: Ref;
}
// Wrapper to allow use of useRovingTabIndex for simple AccessibleTooltipButtons outside of React Functional Components.
export const RovingAccessibleTooltipButton: React.FC<IProps> = ({ inputRef, ...props }) => {
const [onFocus, isActive, ref] = useRovingTabIndex(inputRef);
return <AccessibleTooltipButton {...props} onFocus={onFocus} inputRef={ref} tabIndex={isActive ? 0 : -1} />;
};

View file

@ -0,0 +1,35 @@
/*
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 React from "react";
import { useRovingTabIndex } from "../RovingTabIndex";
import { FocusHandler, Ref } from "./types";
interface IProps {
inputRef?: Ref;
children(renderProps: {
onFocus: FocusHandler;
isActive: boolean;
ref: Ref;
});
}
// Wrapper to allow use of useRovingTabIndex outside of React Functional Components.
export const RovingTabIndexWrapper: React.FC<IProps> = ({ children, inputRef }) => {
const [onFocus, isActive, ref] = useRovingTabIndex(inputRef);
return children({ onFocus, isActive, ref });
};

View file

@ -0,0 +1,21 @@
/*
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 { RefObject } from "react";
export type Ref = RefObject<HTMLElement>;
export type FocusHandler = () => void;

View file

@ -1,34 +0,0 @@
/*
Copyright 2017 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 { asyncAction } from './actionCreators';
const GroupActions = {};
/**
* Creates an action thunk that will do an asynchronous request to fetch
* the groups to which a user is joined.
*
* @param {MatrixClient} matrixClient the matrix client to query.
* @returns {function} an action thunk that will dispatch actions
* indicating the status of the request.
* @see asyncAction
*/
GroupActions.fetchJoinedGroups = function(matrixClient) {
return asyncAction('GroupActions.fetchJoinedGroups', () => matrixClient.getJoinedGroups());
};
export default GroupActions;

View file

@ -0,0 +1,34 @@
/*
Copyright 2017 New Vector 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.
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 { asyncAction } from './actionCreators';
import { AsyncActionPayload } from "../dispatcher/payloads";
import { MatrixClient } from "matrix-js-sdk/src/client";
export default class GroupActions {
/**
* Creates an action thunk that will do an asynchronous request to fetch
* the groups to which a user is joined.
*
* @param {MatrixClient} matrixClient the matrix client to query.
* @returns {AsyncActionPayload} An async action payload.
* @see asyncAction
*/
public static fetchJoinedGroups(matrixClient: MatrixClient): AsyncActionPayload {
return asyncAction('GroupActions.fetchJoinedGroups', () => matrixClient.getJoinedGroups(), null);
}
}

View file

@ -1,5 +1,5 @@
/*
Copyright 2017 New Vector 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,13 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import dis from '../dispatcher';
import { MatrixClient } from "matrix-js-sdk/src/client";
import { MatrixEvent } from "matrix-js-sdk/src/models/event";
import { Room } from "matrix-js-sdk/src/models/room";
import { EventTimeline } from "matrix-js-sdk/src/models/event-timeline";
import dis from "../dispatcher/dispatcher";
import { ActionPayload } from "../dispatcher/payloads";
// TODO: migrate from sync_state to MatrixActions.sync so that more js-sdk events
// become dispatches in the same place.
@ -27,7 +33,7 @@ import dis from '../dispatcher';
* @param {string} prevState the previous sync state.
* @returns {Object} an action of type MatrixActions.sync.
*/
function createSyncAction(matrixClient, state, prevState) {
function createSyncAction(matrixClient: MatrixClient, state: string, prevState: string): ActionPayload {
return {
action: 'MatrixActions.sync',
state,
@ -53,7 +59,7 @@ function createSyncAction(matrixClient, state, prevState) {
* @param {MatrixEvent} accountDataEvent the account data event.
* @returns {AccountDataAction} an action of type MatrixActions.accountData.
*/
function createAccountDataAction(matrixClient, accountDataEvent) {
function createAccountDataAction(matrixClient: MatrixClient, accountDataEvent: MatrixEvent): ActionPayload {
return {
action: 'MatrixActions.accountData',
event: accountDataEvent,
@ -81,7 +87,11 @@ function createAccountDataAction(matrixClient, accountDataEvent) {
* @param {Room} room the room where account data was changed
* @returns {RoomAccountDataAction} an action of type MatrixActions.Room.accountData.
*/
function createRoomAccountDataAction(matrixClient, accountDataEvent, room) {
function createRoomAccountDataAction(
matrixClient: MatrixClient,
accountDataEvent: MatrixEvent,
room: Room,
): ActionPayload {
return {
action: 'MatrixActions.Room.accountData',
event: accountDataEvent,
@ -106,7 +116,7 @@ function createRoomAccountDataAction(matrixClient, accountDataEvent, room) {
* @param {Room} room the Room that was stored.
* @returns {RoomAction} an action of type `MatrixActions.Room`.
*/
function createRoomAction(matrixClient, room) {
function createRoomAction(matrixClient: MatrixClient, room: Room): ActionPayload {
return { action: 'MatrixActions.Room', room };
}
@ -127,7 +137,7 @@ function createRoomAction(matrixClient, room) {
* @param {Room} room the Room whose tags were changed.
* @returns {RoomTagsAction} an action of type `MatrixActions.Room.tags`.
*/
function createRoomTagsAction(matrixClient, roomTagsEvent, room) {
function createRoomTagsAction(matrixClient: MatrixClient, roomTagsEvent: MatrixEvent, room: Room): ActionPayload {
return { action: 'MatrixActions.Room.tags', room };
}
@ -140,7 +150,7 @@ function createRoomTagsAction(matrixClient, roomTagsEvent, room) {
* @param {Room} room the room the receipt happened in.
* @returns {Object} an action of type MatrixActions.Room.receipt.
*/
function createRoomReceiptAction(matrixClient, event, room) {
function createRoomReceiptAction(matrixClient: MatrixClient, event: MatrixEvent, room: Room): ActionPayload {
return {
action: 'MatrixActions.Room.receipt',
event,
@ -178,7 +188,17 @@ function createRoomReceiptAction(matrixClient, event, room) {
* @param {EventTimeline} data.timeline the timeline being altered.
* @returns {RoomTimelineAction} an action of type `MatrixActions.Room.timeline`.
*/
function createRoomTimelineAction(matrixClient, timelineEvent, room, toStartOfTimeline, removed, data) {
function createRoomTimelineAction(
matrixClient: MatrixClient,
timelineEvent: MatrixEvent,
room: Room,
toStartOfTimeline: boolean,
removed: boolean,
data: {
liveEvent: boolean;
timeline: EventTimeline;
},
): ActionPayload {
return {
action: 'MatrixActions.Room.timeline',
event: timelineEvent,
@ -208,8 +228,13 @@ function createRoomTimelineAction(matrixClient, timelineEvent, room, toStartOfTi
* @param {string} oldMembership the previous membership, can be null.
* @returns {RoomMembershipAction} an action of type `MatrixActions.Room.myMembership`.
*/
function createSelfMembershipAction(matrixClient, room, membership, oldMembership) {
return { action: 'MatrixActions.Room.myMembership', room, membership, oldMembership};
function createSelfMembershipAction(
matrixClient: MatrixClient,
room: Room,
membership: string,
oldMembership: string,
): ActionPayload {
return { action: 'MatrixActions.Room.myMembership', room, membership, oldMembership };
}
/**
@ -228,61 +253,65 @@ function createSelfMembershipAction(matrixClient, room, membership, oldMembershi
* @param {MatrixEvent} event the matrix event that was decrypted.
* @returns {EventDecryptedAction} an action of type `MatrixActions.Event.decrypted`.
*/
function createEventDecryptedAction(matrixClient, event) {
function createEventDecryptedAction(matrixClient: MatrixClient, event: MatrixEvent): ActionPayload {
return { action: 'MatrixActions.Event.decrypted', event };
}
type Listener = () => void;
type ActionCreator = (matrixClient: MatrixClient, ...args: any) => ActionPayload;
// A list of callbacks to call to unregister all listeners added
let matrixClientListenersStop: Listener[] = [];
/**
* Start listening to events of type eventName on matrixClient and when they are emitted,
* dispatch an action created by the actionCreator function.
* @param {MatrixClient} matrixClient a MatrixClient to register a listener with.
* @param {string} eventName the event to listen to on MatrixClient.
* @param {function} actionCreator a function that should return an action to dispatch
* when given the MatrixClient as an argument as well as
* arguments emitted in the MatrixClient event.
*/
function addMatrixClientListener(matrixClient: MatrixClient, eventName: string, actionCreator: ActionCreator): void {
const listener: Listener = (...args) => {
const payload = actionCreator(matrixClient, ...args);
if (payload) {
dis.dispatch(payload, true);
}
};
matrixClient.on(eventName, listener);
matrixClientListenersStop.push(() => {
matrixClient.removeListener(eventName, listener);
});
}
/**
* This object is responsible for dispatching actions when certain events are emitted by
* the given MatrixClient.
*/
export default {
// A list of callbacks to call to unregister all listeners added
_matrixClientListenersStop: [],
/**
* Start listening to certain events from the MatrixClient and dispatch actions when
* they are emitted.
* @param {MatrixClient} matrixClient the MatrixClient to listen to events from
*/
start(matrixClient) {
this._addMatrixClientListener(matrixClient, 'sync', createSyncAction);
this._addMatrixClientListener(matrixClient, 'accountData', createAccountDataAction);
this._addMatrixClientListener(matrixClient, 'Room.accountData', createRoomAccountDataAction);
this._addMatrixClientListener(matrixClient, 'Room', createRoomAction);
this._addMatrixClientListener(matrixClient, 'Room.tags', createRoomTagsAction);
this._addMatrixClientListener(matrixClient, 'Room.receipt', createRoomReceiptAction);
this._addMatrixClientListener(matrixClient, 'Room.timeline', createRoomTimelineAction);
this._addMatrixClientListener(matrixClient, 'Room.myMembership', createSelfMembershipAction);
this._addMatrixClientListener(matrixClient, 'Event.decrypted', createEventDecryptedAction);
},
/**
* Start listening to events of type eventName on matrixClient and when they are emitted,
* dispatch an action created by the actionCreator function.
* @param {MatrixClient} matrixClient a MatrixClient to register a listener with.
* @param {string} eventName the event to listen to on MatrixClient.
* @param {function} actionCreator a function that should return an action to dispatch
* when given the MatrixClient as an argument as well as
* arguments emitted in the MatrixClient event.
*/
_addMatrixClientListener(matrixClient, eventName, actionCreator) {
const listener = (...args) => {
const payload = actionCreator(matrixClient, ...args);
if (payload) {
dis.dispatch(payload, true);
}
};
matrixClient.on(eventName, listener);
this._matrixClientListenersStop.push(() => {
matrixClient.removeListener(eventName, listener);
});
start(matrixClient: MatrixClient) {
addMatrixClientListener(matrixClient, 'sync', createSyncAction);
addMatrixClientListener(matrixClient, 'accountData', createAccountDataAction);
addMatrixClientListener(matrixClient, 'Room.accountData', createRoomAccountDataAction);
addMatrixClientListener(matrixClient, 'Room', createRoomAction);
addMatrixClientListener(matrixClient, 'Room.tags', createRoomTagsAction);
addMatrixClientListener(matrixClient, 'Room.receipt', createRoomReceiptAction);
addMatrixClientListener(matrixClient, 'Room.timeline', createRoomTimelineAction);
addMatrixClientListener(matrixClient, 'Room.myMembership', createSelfMembershipAction);
addMatrixClientListener(matrixClient, 'Event.decrypted', createEventDecryptedAction);
},
/**
* Stop listening to events.
*/
stop() {
this._matrixClientListenersStop.forEach((stopListener) => stopListener());
matrixClientListenersStop.forEach((stopListener) => stopListener());
matrixClientListenersStop = [];
},
};

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