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

 Conflicts:
	package.json
	src/components/views/messages/MImageBody.js
	yarn.lock
This commit is contained in:
Michael Telatynski 2021-05-18 13:48:40 +01:00
commit bf01ebae6d
1121 changed files with 97896 additions and 34373 deletions

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

@ -1,5 +1,5 @@
/*
Copyright 2020 The Matrix.org Foundation C.I.C.
Copyright 2020-2021 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
@ -14,12 +14,12 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import "matrix-js-sdk/src/@types/global"; // load matrix-js-sdk's type extensions first
import * as ModernizrStatic from "modernizr";
import ContentMessages from "../ContentMessages";
import { IMatrixClientPeg } from "../MatrixClientPeg";
import ToastStore from "../stores/ToastStore";
import DeviceListener from "../DeviceListener";
import RebrandListener from "../RebrandListener";
import { RoomListStoreClass } from "../stores/room-list/RoomListStore";
import { PlatformPeg } from "../PlatformPeg";
import RoomListLayoutStore from "../stores/room-list/RoomListLayoutStore";
@ -28,19 +28,36 @@ 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";
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;
mxRebrandListener: RebrandListener;
mxRoomListStore: RoomListStoreClass;
mxRoomListLayoutStore: RoomListLayoutStore;
mxActiveRoomObserver: ActiveRoomObserver;
@ -49,20 +66,40 @@ declare global {
singletonModalManager: ModalManager;
mxSettingsStore: SettingsStore;
mxNotifier: typeof Notifier;
}
// workaround for https://github.com/microsoft/TypeScript/issues/30933
interface ObjectConstructor {
fromEntries?(xs: [string|number|symbol, any][]): object;
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;
}
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 {
@ -84,5 +121,57 @@ declare global {
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);
}
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>;
}
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[];
}
);
}

View file

@ -14,15 +14,10 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import React from 'react';
import sanitizeHtml from 'sanitize-html';
interface IProps {
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;
}
const PulsedAvatar: React.FC<IProps> = (props) => {
return <div className="mx_PulsedAvatar">
{props.children}
</div>;
};
export default PulsedAvatar;

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': {
@ -120,7 +138,7 @@ const customVariables = {
},
};
function whitelistRedact(whitelist, str) {
function whitelistRedact(whitelist: string[], str: string): string {
if (whitelist.includes(str)) return str;
return '<redacted>';
}
@ -130,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) {
@ -145,97 +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());
const hostname = window.location.hostname;
if (hostname === 'riot.im') {
this._setVisitVariable('Instance', window.location.pathname);
this.setVisitVariable('Instance', window.location.pathname);
} else if (hostname.endsWith('.element.io')) {
this._setVisitVariable('Instance', hostname.replace('.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);
@ -244,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();
@ -260,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",
@ -277,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
@ -299,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,
@ -313,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();
@ -326,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);
@ -356,7 +382,7 @@ class Analytics {
'e.g. <CurrentPageURL>',
{},
{
CurrentPageURL: getRedactedUrl(),
CurrentPageURL: getRedactedUrl,
},
),
},
@ -397,7 +423,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

@ -15,7 +15,7 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import createReactClass from 'create-react-class';
import React from "react";
import * as sdk from './index';
import PropTypes from 'prop-types';
import { _t } from './languageHandler';
@ -24,21 +24,19 @@ import { _t } from './languageHandler';
* Wrap an asynchronous loader function with a react component which shows a
* spinner until the real component loads.
*/
export default createReactClass({
propTypes: {
export default class AsyncWrapper extends React.Component {
static propTypes = {
/** A promise which resolves with the real component
*/
prom: PropTypes.object.isRequired,
},
};
getInitialState: function() {
return {
component: null,
error: null,
};
},
state = {
component: null,
error: null,
};
componentDidMount: function() {
componentDidMount() {
this._unmounted = false;
// XXX: temporary logging to try to diagnose
// https://github.com/vector-im/element-web/issues/3148
@ -56,17 +54,17 @@ export default createReactClass({
console.warn('AsyncWrapper promise failed', e);
this.setState({error: e});
});
},
}
componentWillUnmount: function() {
componentWillUnmount() {
this._unmounted = true;
},
}
_onWrapperCancelClick: function() {
_onWrapperCancelClick = () => {
this.props.onFinished(false);
},
};
render: function() {
render() {
if (this.state.component) {
const Component = this.state.component;
return <Component {...this.props} />;
@ -87,6 +85,6 @@ export default createReactClass({
const Spinner = sdk.getComponent("elements.Spinner");
return <Spinner />;
}
},
});
}
}

View file

@ -14,23 +14,21 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
'use strict';
import {MatrixClientPeg} from './MatrixClientPeg';
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 DMRoomMap from './utils/DMRoomMap';
import {getHttpUriForMxc} from "matrix-js-sdk/src/content-repo";
import {mediaFromMxc} from "./customisations/Media";
import SettingsStore from "./settings/SettingsStore";
export type ResizeMethod = "crop" | "scale";
// Not to be used for BaseAvatar urls as that has similar default avatar fallback already
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,
);
export function avatarUrlForMember(member: RoomMember, width: number, height: number, resizeMethod: ResizeMethod) {
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
@ -41,27 +39,19 @@ export function avatarUrlForMember(member, width, height, resizeMethod) {
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 avatarUrlForUser(user: User, width: number, height: number, resizeMethod?: ResizeMethod) {
if (!user.avatarUrl) return null;
return mediaFromMxc(user.avatarUrl).getThumbnailOfSourceHttp(width, height, resizeMethod);
}
function isValidHexColor(color) {
function isValidHexColor(color: string): boolean {
return typeof color === "string" &&
(color.length === 7 || color.lengh === 9) &&
(color.length === 7 || color.length === 9) &&
color.charAt(0) === "#" &&
!color.substr(1).split("").some(c => isNaN(parseInt(c, 16)));
}
function urlForColor(color) {
function urlForColor(color: string): string {
const size = 40;
const canvas = document.createElement("canvas");
canvas.width = size;
@ -79,9 +69,10 @@ function urlForColor(color) {
// 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();
const colorToDataURLCache = new Map<string, string>();
export function defaultAvatarUrlForString(s) {
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) {
@ -112,7 +103,7 @@ export function defaultAvatarUrlForString(s) {
* @param {string} name
* @return {string} the first letter
*/
export function getInitialLetter(name) {
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");
@ -145,20 +136,16 @@ export function getInitialLetter(name) {
return firstChar.toUpperCase();
}
export function avatarUrlForRoom(room, width, height, resizeMethod) {
export function avatarUrlForRoom(room: Room, width: number, height: number, resizeMethod?: ResizeMethod) {
if (!room) return null; // null-guard
const explicitRoomAvatar = room.getAvatarUrl(
MatrixClientPeg.get().getHomeserverUrl(),
width,
height,
resizeMethod,
false,
);
if (explicitRoomAvatar) {
return explicitRoomAvatar;
if (room.getMxcAvatarUrl()) {
return mediaFromMxc(room.getMxcAvatarUrl()).getThumbnailOfSourceHttp(width, height, resizeMethod);
}
// space rooms cannot be DMs so skip the rest
if (SettingsStore.getValue("feature_spaces") && room.isSpaceRoom()) return null;
let otherMember = null;
const otherUserId = DMRoomMap.shared().getUserIdForRoomId(room.roomId);
if (otherUserId) {
@ -168,14 +155,8 @@ export function avatarUrlForRoom(room, width, height, resizeMethod) {
// 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,
);
if (otherMember?.getMxcAvatarUrl()) {
return mediaFromMxc(otherMember.getMxcAvatarUrl()).getThumbnailOfSourceHttp(width, height, resizeMethod);
}
return null;
}

View file

@ -18,15 +18,19 @@ 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",
@ -53,7 +57,7 @@ export default abstract class BasePlatform {
this.startUpdateCheck = this.startUpdateCheck.bind(this);
}
abstract async getConfig(): Promise<{}>;
abstract getConfig(): Promise<{}>;
abstract getDefaultDeviceDisplayName(): string;
@ -105,6 +109,9 @@ export default abstract class BasePlatform {
* @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;
@ -124,6 +131,14 @@ export default abstract class BasePlatform {
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.
@ -155,7 +170,13 @@ export default abstract class BasePlatform {
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();
}
}
/**
@ -191,6 +212,18 @@ export default abstract class BasePlatform {
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;
}
@ -225,7 +258,17 @@ export default abstract class BasePlatform {
return null;
}
setLanguage(preferredLangs: string[]) {}
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);
@ -238,15 +281,19 @@ export default abstract class BasePlatform {
* @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) {
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); // redirect to SSO
window.location.href = mxClient.getSsoLoginUrl(callbackUrl.toString(), loginType, idpId); // redirect to SSO
}
onKeyDown(ev: KeyboardEvent): boolean {
@ -262,7 +309,40 @@ export default abstract class BasePlatform {
* pickle key has been stored.
*/
async getPickleKey(userId: string, deviceId: string): Promise<string | null> {
return 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;
}
}
/**
@ -273,7 +353,37 @@ export default abstract class BasePlatform {
* support storing pickle keys.
*/
async createPickleKey(userId: string, deviceId: string): Promise<string | null> {
return 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);
}
/**
@ -282,5 +392,8 @@ export default abstract class BasePlatform {
* @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) {}
}
}

View file

@ -1,509 +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/dispatcher';
import WidgetUtils from './utils/WidgetUtils';
import WidgetEchoStore from './stores/WidgetEchoStore';
import SettingsStore from './settings/SettingsStore';
import {generateHumanReadableId} from "./utils/NamingUtils";
import {Jitsi} from "./widgets/Jitsi";
import {WidgetType} from "./widgets/WidgetType";
import {SettingLevel} from "./settings/SettingLevel";
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/element-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 _setCallListeners(call) {
call.on("error", function(err) {
console.error("Call error:", err);
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 currentJitsiWidgets = WidgetUtils.getRoomWidgetsOfType(room, WidgetType.JITSI);
if (WidgetEchoStore.roomHasPendingWidgetsOfType(roomId, currentJitsiWidgets, WidgetType.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;
}
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, WidgetType.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. Element 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 Element 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 Element 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;

992
src/CallHandler.tsx Normal file
View file

@ -0,0 +1,992 @@
/*
Copyright 2015, 2016 OpenMarket Ltd
Copyright 2017, 2018 New Vector Ltd
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.
*/
/*
* 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 React from 'react';
import {MatrixClientPeg} from './MatrixClientPeg';
import PlatformPeg from './PlatformPeg';
import Modal from './Modal';
import { _t } from './languageHandler';
import dis from './dispatcher/dispatcher';
import WidgetUtils from './utils/WidgetUtils';
import WidgetEchoStore from './stores/WidgetEchoStore';
import SettingsStore from './settings/SettingsStore';
import {Jitsi} from "./widgets/Jitsi";
import {WidgetType} from "./widgets/WidgetType";
import {SettingLevel} from "./settings/SettingLevel";
import { ActionPayload } from "./dispatcher/payloads";
import {base32} from "rfc4648";
import QuestionDialog from "./components/views/dialogs/QuestionDialog";
import ErrorDialog from "./components/views/dialogs/ErrorDialog";
import WidgetStore from "./stores/WidgetStore";
import { WidgetMessagingStore } from "./stores/widgets/WidgetMessagingStore";
import { ElementWidgetActions } from "./stores/widgets/ElementWidgetActions";
import { MatrixCall, CallErrorCode, CallState, CallEvent, CallParty, CallType } from "matrix-js-sdk/src/webrtc/call";
import Analytics from './Analytics';
import CountlyAnalytics from "./CountlyAnalytics";
import {UIFeature} from "./settings/UIFeature";
import { CallError } from "matrix-js-sdk/src/webrtc/call";
import { logger } from 'matrix-js-sdk/src/logger';
import DesktopCapturerSourcePicker from "./components/views/elements/DesktopCapturerSourcePicker"
import { Action } from './dispatcher/actions';
import VoipUserMapper from './VoipUserMapper';
import { addManagedHybridWidget, isManagedHybridWidgetEnabled } from './widgets/ManagedHybrid';
import { randomUppercaseString, randomLowercaseString } from "matrix-js-sdk/src/randomstring";
import EventEmitter from 'events';
import SdkConfig from './SdkConfig';
import { ensureDMExists, findDMForUser } from './createRoom';
export const PROTOCOL_PSTN = 'm.protocol.pstn';
export const PROTOCOL_PSTN_PREFIXED = 'im.vector.protocol.pstn';
export const PROTOCOL_SIP_NATIVE = 'im.vector.protocol.sip_native';
export const PROTOCOL_SIP_VIRTUAL = 'im.vector.protocol.sip_virtual';
const CHECK_PROTOCOLS_ATTEMPTS = 3;
// Event type for room account data and room creation content used to mark rooms as virtual rooms
// (and store the ID of their native room)
export const VIRTUAL_ROOM_EVENT_TYPE = 'im.vector.is_virtual_room';
enum AudioID {
Ring = 'ringAudio',
Ringback = 'ringbackAudio',
CallEnd = 'callendAudio',
Busy = 'busyAudio',
}
interface ThirdpartyLookupResponseFields {
/* eslint-disable camelcase */
// im.vector.sip_native
virtual_mxid?: string;
is_virtual?: boolean;
// im.vector.sip_virtual
native_mxid?: string;
is_native?: boolean;
// common
lookup_success?: boolean;
/* eslint-enable camelcase */
}
interface ThirdpartyLookupResponse {
userid: string,
protocol: string,
fields: ThirdpartyLookupResponseFields,
}
// Unlike 'CallType' in js-sdk, this one includes screen sharing
// (because a screen sharing call is only a screen sharing call to the caller,
// to the callee it's just a video call, at least as far as the current impl
// is concerned).
export enum PlaceCallType {
Voice = 'voice',
Video = 'video',
ScreenSharing = 'screensharing',
}
export enum CallHandlerEvent {
CallsChanged = "calls_changed",
CallChangeRoom = "call_change_room",
}
export default class CallHandler extends EventEmitter {
private calls = new Map<string, MatrixCall>(); // roomId -> call
// Calls started as an attended transfer, ie. with the intention of transferring another
// call with a different party to this one.
private transferees = new Map<string, MatrixCall>(); // callId (target) -> call (transferee)
private audioPromises = new Map<AudioID, Promise<void>>();
private dispatcherRef: string = null;
private supportsPstnProtocol = null;
private pstnSupportPrefixed = null; // True if the server only support the prefixed pstn protocol
private supportsSipNativeVirtual = null; // im.vector.protocol.sip_virtual and im.vector.protocol.sip_native
private pstnSupportCheckTimer: NodeJS.Timeout; // number actually because we're in the browser
// For rooms we've been invited to, true if they're from virtual user, false if we've checked and they aren't.
private invitedRoomsAreVirtual = new Map<string, boolean>();
private invitedRoomCheckInProgress = false;
// Map of the asserted identity users after we've looked them up using the API.
// We need to be be able to determine the mapped room synchronously, so we
// do the async lookup when we get new information and then store these mappings here
private assertedIdentityNativeUsers = new Map<string, string>();
static sharedInstance() {
if (!window.mxCallHandler) {
window.mxCallHandler = new CallHandler()
}
return window.mxCallHandler;
}
/*
* Gets the user-facing room associated with a call (call.roomId may be the call "virtual room"
* if a voip_mxid_translate_pattern is set in the config)
*/
public roomIdForCall(call: MatrixCall): string {
if (!call) return null;
const voipConfig = SdkConfig.get()['voip'];
if (voipConfig && voipConfig.obeyAssertedIdentity) {
const nativeUser = this.assertedIdentityNativeUsers[call.callId];
if (nativeUser) {
const room = findDMForUser(MatrixClientPeg.get(), nativeUser);
if (room) return room.roomId
}
}
return VoipUserMapper.sharedInstance().nativeRoomForVirtualRoom(call.roomId) || call.roomId;
}
start() {
this.dispatcherRef = dis.register(this.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() {});
}
if (SettingsStore.getValue(UIFeature.Voip)) {
MatrixClientPeg.get().on('Call.incoming', this.onCallIncoming);
}
this.checkProtocols(CHECK_PROTOCOLS_ATTEMPTS);
}
stop() {
const cli = MatrixClientPeg.get();
if (cli) {
cli.removeListener('Call.incoming', this.onCallIncoming);
}
if (this.dispatcherRef !== null) {
dis.unregister(this.dispatcherRef);
this.dispatcherRef = null;
}
}
private async checkProtocols(maxTries) {
try {
const protocols = await MatrixClientPeg.get().getThirdpartyProtocols();
if (protocols[PROTOCOL_PSTN] !== undefined) {
this.supportsPstnProtocol = Boolean(protocols[PROTOCOL_PSTN]);
if (this.supportsPstnProtocol) this.pstnSupportPrefixed = false;
} else if (protocols[PROTOCOL_PSTN_PREFIXED] !== undefined) {
this.supportsPstnProtocol = Boolean(protocols[PROTOCOL_PSTN_PREFIXED]);
if (this.supportsPstnProtocol) this.pstnSupportPrefixed = true;
} else {
this.supportsPstnProtocol = null;
}
dis.dispatch({action: Action.PstnSupportUpdated});
if (protocols[PROTOCOL_SIP_NATIVE] !== undefined && protocols[PROTOCOL_SIP_VIRTUAL] !== undefined) {
this.supportsSipNativeVirtual = Boolean(
protocols[PROTOCOL_SIP_NATIVE] && protocols[PROTOCOL_SIP_VIRTUAL],
);
}
dis.dispatch({action: Action.VirtualRoomSupportUpdated});
} catch (e) {
if (maxTries === 1) {
console.log("Failed to check for protocol support and no retries remain: assuming no support", e);
} else {
console.log("Failed to check for protocol support: will retry", e);
this.pstnSupportCheckTimer = setTimeout(() => {
this.checkProtocols(maxTries - 1);
}, 10000);
}
}
}
public getSupportsPstnProtocol() {
return this.supportsPstnProtocol;
}
public getSupportsVirtualRooms() {
return this.supportsPstnProtocol;
}
public pstnLookup(phoneNumber: string): Promise<ThirdpartyLookupResponse[]> {
return MatrixClientPeg.get().getThirdpartyUser(
this.pstnSupportPrefixed ? PROTOCOL_PSTN_PREFIXED : PROTOCOL_PSTN, {
'm.id.phone': phoneNumber,
},
);
}
public sipVirtualLookup(nativeMxid: string): Promise<ThirdpartyLookupResponse[]> {
return MatrixClientPeg.get().getThirdpartyUser(
PROTOCOL_SIP_VIRTUAL, {
'native_mxid': nativeMxid,
},
);
}
public sipNativeLookup(virtualMxid: string): Promise<ThirdpartyLookupResponse[]> {
return MatrixClientPeg.get().getThirdpartyUser(
PROTOCOL_SIP_NATIVE, {
'virtual_mxid': virtualMxid,
},
);
}
private onCallIncoming = (call) => {
// we dispatch this synchronously to make sure that the event
// handlers on the call are set up immediately (so that if
// we get an immediate hangup, we don't get a stuck call)
dis.dispatch({
action: 'incoming_call',
call: call,
}, true);
}
getCallForRoom(roomId: string): MatrixCall {
return this.calls.get(roomId) || null;
}
getAnyActiveCall() {
for (const call of this.calls.values()) {
if (call.state !== CallState.Ended) {
return call;
}
}
return null;
}
getAllActiveCalls() {
const activeCalls = [];
for (const call of this.calls.values()) {
if (call.state !== CallState.Ended && call.state !== CallState.Ringing) {
activeCalls.push(call);
}
}
return activeCalls;
}
getAllActiveCallsNotInRoom(notInThisRoomId) {
const callsNotInThatRoom = [];
for (const [roomId, call] of this.calls.entries()) {
if (roomId !== notInThisRoomId && call.state !== CallState.Ended) {
callsNotInThatRoom.push(call);
}
}
return callsNotInThatRoom;
}
getTransfereeForCallId(callId: string): MatrixCall {
return this.transferees[callId];
}
play(audioId: AudioID) {
// TODO: Attach an invisible element for this instead
// which listens?
const audio = document.getElementById(audioId) as HTMLMediaElement;
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/element-web/issues/7657
console.log("Unable to play audio clip", e);
}
};
if (this.audioPromises.has(audioId)) {
this.audioPromises.set(audioId, this.audioPromises.get(audioId).then(() => {
audio.load();
return playAudio();
}));
} else {
this.audioPromises.set(audioId, playAudio());
}
}
}
pause(audioId: AudioID) {
// TODO: Attach an invisible element for this instead
// which listens?
const audio = document.getElementById(audioId) as HTMLMediaElement;
if (audio) {
if (this.audioPromises.has(audioId)) {
this.audioPromises.set(audioId, this.audioPromises.get(audioId).then(() => audio.pause()));
} else {
// pause doesn't return a promise, so just do it
audio.pause();
}
}
}
private matchesCallForThisRoom(call: MatrixCall) {
// We don't allow placing more than one call per room, but that doesn't mean there
// can't be more than one, eg. in a glare situation. This checks that the given call
// is the call we consider 'the' call for its room.
const mappedRoomId = this.roomIdForCall(call);
const callForThisRoom = this.getCallForRoom(mappedRoomId);
return callForThisRoom && call.callId === callForThisRoom.callId;
}
private setCallListeners(call: MatrixCall) {
let mappedRoomId = CallHandler.sharedInstance().roomIdForCall(call);
call.on(CallEvent.Error, (err: CallError) => {
if (!this.matchesCallForThisRoom(call)) return;
Analytics.trackEvent('voip', 'callError', 'error', err.toString());
console.error("Call error:", err);
if (err.code === CallErrorCode.NoUserMedia) {
this.showMediaCaptureError(call);
return;
}
if (
MatrixClientPeg.get().getTurnServers().length === 0 &&
SettingsStore.getValue("fallbackICEServerAllowed") === null
) {
this.showICEFallbackPrompt();
return;
}
Modal.createTrackedDialog('Call Failed', '', ErrorDialog, {
title: _t('Call Failed'),
description: err.message,
});
});
call.on(CallEvent.Hangup, () => {
if (!this.matchesCallForThisRoom(call)) return;
Analytics.trackEvent('voip', 'callHangup');
this.removeCallForRoom(mappedRoomId);
});
call.on(CallEvent.State, (newState: CallState, oldState: CallState) => {
if (!this.matchesCallForThisRoom(call)) return;
this.setCallState(call, newState);
switch (oldState) {
case CallState.Ringing:
this.pause(AudioID.Ring);
break;
case CallState.InviteSent:
this.pause(AudioID.Ringback);
break;
}
switch (newState) {
case CallState.Ringing:
this.play(AudioID.Ring);
break;
case CallState.InviteSent:
this.play(AudioID.Ringback);
break;
case CallState.Ended:
{
Analytics.trackEvent('voip', 'callEnded', 'hangupReason', call.hangupReason);
this.removeCallForRoom(mappedRoomId);
if (oldState === CallState.InviteSent && (
call.hangupParty === CallParty.Remote ||
(call.hangupParty === CallParty.Local && call.hangupReason === CallErrorCode.InviteTimeout)
)) {
this.play(AudioID.Busy);
let title;
let description;
if (call.hangupReason === CallErrorCode.UserHangup) {
title = _t("Call Declined");
description = _t("The other party declined the call.");
} else if (call.hangupReason === CallErrorCode.InviteTimeout) {
title = _t("Call Failed");
// XXX: full stop appended as some relic here, but these
// strings need proper input from design anyway, so let's
// not change this string until we have a proper one.
description = _t('The remote side failed to pick up') + '.';
} else {
title = _t("Call Failed");
description = _t("The call could not be established");
}
Modal.createTrackedDialog('Call Handler', 'Call Failed', ErrorDialog, {
title, description,
});
} else if (
call.hangupReason === CallErrorCode.AnsweredElsewhere && oldState === CallState.Connecting
) {
Modal.createTrackedDialog('Call Handler', 'Call Failed', ErrorDialog, {
title: _t("Answered Elsewhere"),
description: _t("The call was answered on another device."),
});
} else if (oldState !== CallState.Fledgling && oldState !== CallState.Ringing) {
// don't play the end-call sound for calls that never got off the ground
this.play(AudioID.CallEnd);
}
this.logCallStats(call, mappedRoomId);
break;
}
}
});
call.on(CallEvent.Replaced, (newCall: MatrixCall) => {
if (!this.matchesCallForThisRoom(call)) return;
console.log(`Call ID ${call.callId} is being replaced by call ID ${newCall.callId}`);
if (call.state === CallState.Ringing) {
this.pause(AudioID.Ring);
} else if (call.state === CallState.InviteSent) {
this.pause(AudioID.Ringback);
}
this.calls.set(mappedRoomId, newCall);
this.emit(CallHandlerEvent.CallsChanged, this.calls);
this.setCallListeners(newCall);
this.setCallState(newCall, newCall.state);
});
call.on(CallEvent.AssertedIdentityChanged, async () => {
if (!this.matchesCallForThisRoom(call)) return;
console.log(`Call ID ${call.callId} got new asserted identity:`, call.getRemoteAssertedIdentity());
const newAssertedIdentity = call.getRemoteAssertedIdentity().id;
let newNativeAssertedIdentity = newAssertedIdentity;
if (newAssertedIdentity) {
const response = await this.sipNativeLookup(newAssertedIdentity);
if (response.length) newNativeAssertedIdentity = response[0].userid;
}
console.log(`Asserted identity ${newAssertedIdentity} mapped to ${newNativeAssertedIdentity}`);
if (newNativeAssertedIdentity) {
this.assertedIdentityNativeUsers[call.callId] = newNativeAssertedIdentity;
// If we don't already have a room with this user, make one. This will be slightly odd
// if they called us because we'll be inviting them, but there's not much we can do about
// this if we want the actual, native room to exist (which we do). This is why it's
// important to only obey asserted identity in trusted environments, since anyone you're
// on a call with can cause you to send a room invite to someone.
await ensureDMExists(MatrixClientPeg.get(), newNativeAssertedIdentity);
const newMappedRoomId = this.roomIdForCall(call);
console.log(`Old room ID: ${mappedRoomId}, new room ID: ${newMappedRoomId}`);
if (newMappedRoomId !== mappedRoomId) {
this.removeCallForRoom(mappedRoomId);
mappedRoomId = newMappedRoomId;
this.calls.set(mappedRoomId, call);
this.emit(CallHandlerEvent.CallChangeRoom, call);
}
}
});
}
private async logCallStats(call: MatrixCall, mappedRoomId: string) {
const stats = await call.getCurrentCallStats();
logger.debug(
`Call completed. Call ID: ${call.callId}, virtual room ID: ${call.roomId}, ` +
`user-facing room ID: ${mappedRoomId}, direction: ${call.direction}, ` +
`our Party ID: ${call.ourPartyId}, hangup party: ${call.hangupParty}, ` +
`hangup reason: ${call.hangupReason}`,
);
if (!stats) {
logger.debug(
"Call statistics are undefined. The call has " +
"probably failed before a peerConn was established",
);
return;
}
logger.debug("Local candidates:");
for (const cand of stats.filter(item => item.type === 'local-candidate')) {
const address = cand.address || cand.ip; // firefox uses 'address', chrome uses 'ip'
logger.debug(
`${cand.id} - type: ${cand.candidateType}, address: ${address}, port: ${cand.port}, ` +
`protocol: ${cand.protocol}, relay protocol: ${cand.relayProtocol}, network type: ${cand.networkType}`,
);
}
logger.debug("Remote candidates:");
for (const cand of stats.filter(item => item.type === 'remote-candidate')) {
const address = cand.address || cand.ip; // firefox uses 'address', chrome uses 'ip'
logger.debug(
`${cand.id} - type: ${cand.candidateType}, address: ${address}, port: ${cand.port}, ` +
`protocol: ${cand.protocol}`,
);
}
logger.debug("Candidate pairs:");
for (const pair of stats.filter(item => item.type === 'candidate-pair')) {
logger.debug(
`${pair.localCandidateId} / ${pair.remoteCandidateId} - state: ${pair.state}, ` +
`nominated: ${pair.nominated}, ` +
`requests sent ${pair.requestsSent}, requests received ${pair.requestsReceived}, ` +
`responses received: ${pair.responsesReceived}, responses sent: ${pair.responsesSent}, ` +
`bytes received: ${pair.bytesReceived}, bytes sent: ${pair.bytesSent}, `,
);
}
}
private setCallState(call: MatrixCall, status: CallState) {
const mappedRoomId = CallHandler.sharedInstance().roomIdForCall(call);
console.log(
`Call state in ${mappedRoomId} changed to ${status}`,
);
dis.dispatch({
action: 'call_state',
room_id: mappedRoomId,
state: status,
});
}
private removeCallForRoom(roomId: string) {
this.calls.delete(roomId);
this.emit(CallHandlerEvent.CallsChanged, this.calls);
}
private showICEFallbackPrompt() {
const cli = MatrixClientPeg.get();
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);
}
private showMediaCaptureError(call: MatrixCall) {
let title;
let description;
if (call.type === CallType.Voice) {
title = _t("Unable to access microphone");
description = <div>
{_t(
"Call failed because microphone could not be accessed. " +
"Check that a microphone is plugged in and set up correctly.",
)}
</div>;
} else if (call.type === CallType.Video) {
title = _t("Unable to access webcam / microphone");
description = <div>
{_t("Call failed because webcam or microphone could not be accessed. Check that:")}
<ul>
<li>{_t("A microphone and webcam are plugged in and set up correctly")}</li>
<li>{_t("Permission is granted to use the webcam")}</li>
<li>{_t("No other application is using the webcam")}</li>
</ul>
</div>;
}
Modal.createTrackedDialog('Media capture failed', '', ErrorDialog, {
title, description,
}, null, true);
}
private async placeCall(roomId: string, type: PlaceCallType, transferee: MatrixCall) {
Analytics.trackEvent('voip', 'placeCall', 'type', type);
CountlyAnalytics.instance.trackStartCall(roomId, type === PlaceCallType.Video, false);
const mappedRoomId = (await VoipUserMapper.sharedInstance().getOrCreateVirtualRoomForRoom(roomId)) || roomId;
logger.debug("Mapped real room " + roomId + " to room ID " + mappedRoomId);
const timeUntilTurnCresExpire = MatrixClientPeg.get().getTurnServersExpiry() - Date.now();
console.log("Current turn creds expire in " + timeUntilTurnCresExpire + " ms");
const call = MatrixClientPeg.get().createCall(mappedRoomId);
this.calls.set(roomId, call);
this.emit(CallHandlerEvent.CallsChanged, this.calls);
if (transferee) {
this.transferees[call.callId] = transferee;
}
this.setCallListeners(call);
this.setActiveCallRoomId(roomId);
if (type === PlaceCallType.Voice) {
call.placeVoiceCall();
} else if (type === 'video') {
call.placeVideoCall();
} else if (type === PlaceCallType.ScreenSharing) {
const screenCapErrorString = PlatformPeg.get().screenCaptureErrorString();
if (screenCapErrorString) {
this.removeCallForRoom(roomId);
console.log("Can't capture screen: " + screenCapErrorString);
Modal.createTrackedDialog('Call Handler', 'Unable to capture screen', ErrorDialog, {
title: _t('Unable to capture screen'),
description: screenCapErrorString,
});
return;
}
call.placeScreenSharingCall(
async (): Promise<DesktopCapturerSource> => {
const {finished} = Modal.createDialog(DesktopCapturerSourcePicker);
const [source] = await finished;
return source;
},
);
} else {
console.error("Unknown conf call type: " + type);
}
}
private onAction = (payload: ActionPayload) => {
switch (payload.action) {
case 'place_call':
{
// We might be using managed hybrid widgets
if (isManagedHybridWidgetEnabled()) {
addManagedHybridWidget(payload.room_id);
return;
}
// if the runtime env doesn't do VoIP, whine.
if (!MatrixClientPeg.get().supportsVoip()) {
Modal.createTrackedDialog('Call Handler', 'VoIP is unsupported', ErrorDialog, {
title: _t('VoIP is unsupported'),
description: _t('You cannot place VoIP calls in this browser.'),
});
return;
}
// don't allow > 2 calls to be placed.
if (this.getAllActiveCalls().length > 1) {
Modal.createTrackedDialog('Call Handler', 'Existing Call', ErrorDialog, {
title: _t('Too Many Calls'),
description: _t("You've reached the maximum number of simultaneous calls."),
});
return;
}
const room = MatrixClientPeg.get().getRoom(payload.room_id);
if (!room) {
console.error(`Room ${payload.room_id} does not exist.`);
return;
}
if (this.getCallForRoom(room.roomId)) {
Modal.createTrackedDialog('Call Handler', 'Existing Call with user', ErrorDialog, {
title: _t('Already in call'),
description: _t("You're already in a call with this person."),
});
return;
}
const members = room.getJoinedMembers();
if (members.length <= 1) {
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 ${payload.type} call in ${payload.room_id}`);
this.placeCall(payload.room_id, payload.type, payload.transferee);
} else { // > 2
dis.dispatch({
action: "place_conference_call",
room_id: payload.room_id,
type: payload.type,
});
}
}
break;
case 'place_conference_call':
console.info("Place conference call in " + payload.room_id);
Analytics.trackEvent('voip', 'placeConferenceCall');
CountlyAnalytics.instance.trackStartCall(payload.room_id, payload.type === PlaceCallType.Video, true);
this.startCallApp(payload.room_id, payload.type);
break;
case 'end_conference':
console.info("Terminating conference call in " + payload.room_id);
this.terminateCallApp(payload.room_id);
break;
case 'hangup_conference':
console.info("Leaving conference call in "+ payload.room_id);
this.hangupCallApp(payload.room_id);
break;
case 'incoming_call':
{
// if the runtime env doesn't do VoIP, stop here.
if (!MatrixClientPeg.get().supportsVoip()) {
return;
}
const call = payload.call as MatrixCall;
const mappedRoomId = CallHandler.sharedInstance().roomIdForCall(call);
if (this.getCallForRoom(mappedRoomId)) {
// ignore multiple incoming calls to the same room
return;
}
Analytics.trackEvent('voip', 'receiveCall', 'type', call.type);
this.calls.set(mappedRoomId, call)
this.emit(CallHandlerEvent.CallsChanged, this.calls);
this.setCallListeners(call);
// get ready to send encrypted events in the room, so if the user does answer
// the call, we'll be ready to send. NB. This is the protocol-level room ID not
// the mapped one: that's where we'll send the events.
const cli = MatrixClientPeg.get();
cli.prepareToEncrypt(cli.getRoom(call.roomId));
}
break;
case 'hangup':
case 'reject':
if (!this.calls.get(payload.room_id)) {
return; // no call to hangup
}
if (payload.action === 'reject') {
this.calls.get(payload.room_id).reject();
} else {
this.calls.get(payload.room_id).hangup(CallErrorCode.UserHangup, false);
}
// don't remove the call yet: let the hangup event handler do it (otherwise it will throw
// the hangup event away)
break;
case 'hangup_all':
for (const call of this.calls.values()) {
call.hangup(CallErrorCode.UserHangup, false);
}
break;
case 'answer': {
if (!this.calls.has(payload.room_id)) {
return; // no call to answer
}
if (this.getAllActiveCalls().length > 1) {
Modal.createTrackedDialog('Call Handler', 'Existing Call', ErrorDialog, {
title: _t('Too Many Calls'),
description: _t("You've reached the maximum number of simultaneous calls."),
});
return;
}
const call = this.calls.get(payload.room_id);
call.answer();
this.setActiveCallRoomId(payload.room_id);
CountlyAnalytics.instance.trackJoinCall(payload.room_id, call.type === CallType.Video, false);
dis.dispatch({
action: "view_room",
room_id: payload.room_id,
});
break;
}
}
}
setActiveCallRoomId(activeCallRoomId: string) {
logger.info("Setting call in room " + activeCallRoomId + " active");
for (const [roomId, call] of this.calls.entries()) {
if (call.state === CallState.Ended) continue;
if (roomId === activeCallRoomId) {
call.setRemoteOnHold(false);
} else {
logger.info("Holding call in room " + roomId + " because another call is being set active");
call.setRemoteOnHold(true);
}
}
}
/**
* @returns true if we are currently in any call where we haven't put the remote party on hold
*/
hasAnyUnheldCall() {
for (const call of this.calls.values()) {
if (call.state === CallState.Ended) continue;
if (!call.isRemoteOnHold()) return true;
}
return false;
}
private async startCallApp(roomId: string, type: string) {
dis.dispatch({
action: 'appsDrawer',
show: true,
});
// prevent double clicking the call button
const room = MatrixClientPeg.get().getRoom(roomId);
const currentJitsiWidgets = WidgetUtils.getRoomWidgetsOfType(room, WidgetType.JITSI);
const hasJitsi = currentJitsiWidgets.length > 0
|| WidgetEchoStore.roomHasPendingWidgetsOfType(roomId, currentJitsiWidgets, WidgetType.JITSI);
if (hasJitsi) {
Modal.createTrackedDialog('Call already in progress', '', ErrorDialog, {
title: _t('Call in Progress'),
description: _t('A call is currently being placed!'),
});
return;
}
const jitsiDomain = Jitsi.getInstance().preferredDomain;
const jitsiAuth = await Jitsi.getInstance().getJitsiAuth();
let confId;
if (jitsiAuth === 'openidtoken-jwt') {
// Create conference ID from room ID
// For compatibility with Jitsi, use base32 without padding.
// More details here:
// https://github.com/matrix-org/prosody-mod-auth-matrix-user-verification
confId = base32.stringify(Buffer.from(roomId), { pad: false });
} else {
// Create a random conference ID
const random = randomUppercaseString(1) + randomLowercaseString(23);
confId = 'Jitsi' + random;
}
let widgetUrl = WidgetUtils.getLocalJitsiWrapperUrl({auth: jitsiAuth});
// 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,
auth: jitsiAuth,
roomName: room.name,
};
const widgetId = (
'jitsi_' +
MatrixClientPeg.get().credentials.userId +
'_' +
Date.now()
);
WidgetUtils.setRoomWidget(roomId, widgetId, WidgetType.JITSI, widgetUrl, 'Jitsi', widgetData).then(() => {
console.log('Jitsi widget added');
}).catch((e) => {
if (e.errcode === 'M_FORBIDDEN') {
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);
});
}
private terminateCallApp(roomId: string) {
Modal.createTrackedDialog('Confirm Jitsi Terminate', '', QuestionDialog, {
hasCancelButton: true,
title: _t("End conference"),
description: _t("This will end the conference for everyone. Continue?"),
button: _t("End conference"),
onFinished: (proceed) => {
if (!proceed) return;
// We'll just obliterate them all. There should only ever be one, but might as well
// be safe.
const roomInfo = WidgetStore.instance.getRoom(roomId);
const jitsiWidgets = roomInfo.widgets.filter(w => WidgetType.JITSI.matches(w.type));
jitsiWidgets.forEach(w => {
// setting invalid content removes it
WidgetUtils.setRoomWidget(roomId, w.id);
});
},
});
}
private hangupCallApp(roomId: string) {
const roomInfo = WidgetStore.instance.getRoom(roomId);
if (!roomInfo) return; // "should never happen" clauses go here
const jitsiWidgets = roomInfo.widgets.filter(w => WidgetType.JITSI.matches(w.type));
jitsiWidgets.forEach(w => {
const messaging = WidgetMessagingStore.instance.getMessagingForId(w.id);
if (!messaging) return; // more "should never happen" words
messaging.transport.send(ElementWidgetActions.HangupCall, {});
});
}
}

View file

@ -14,9 +14,9 @@
limitations under the License.
*/
import * as Matrix from 'matrix-js-sdk';
import SettingsStore from "./settings/SettingsStore";
import {SettingLevel} from "./settings/SettingLevel";
import {setMatrixCallAudioInput, setMatrixCallVideoInput} from "matrix-js-sdk/src/matrix";
export default {
hasAnyLabeledDevices: async function() {
@ -50,28 +50,25 @@ export default {
},
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);
setMatrixCallAudioInput(audioDeviceId);
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);
setMatrixCallAudioInput(deviceId);
},
setVideoInput: function(deviceId) {
SettingsStore.setValue("webrtc_videoinput", null, SettingLevel.DEVICE, deviceId);
Matrix.setMatrixCallVideoInput(deviceId);
setMatrixCallVideoInput(deviceId);
},
getAudioOutput: function() {

View file

@ -17,7 +17,6 @@ limitations under the License.
*/
import React from "react";
import extend from './extend';
import dis from './dispatcher/dispatcher';
import {MatrixClientPeg} from './MatrixClientPeg';
import {MatrixClient} from "matrix-js-sdk/src/client";
@ -32,6 +31,15 @@ import Spinner from "./components/views/elements/Spinner";
// Polyfill for Canvas.toBlob API using Canvas.toDataURL
import "blueimp-canvas-to-blob";
import { Action } from "./dispatcher/actions";
import CountlyAnalytics from "./CountlyAnalytics";
import {
UploadCanceledPayload,
UploadErrorPayload,
UploadFinishedPayload,
UploadProgressPayload,
UploadStartedPayload,
} from "./dispatcher/payloads/UploadPayload";
import {IUpload} from "./models/IUpload";
const MAX_WIDTH = 800;
const MAX_HEIGHT = 600;
@ -44,15 +52,6 @@ export class UploadCanceledError extends Error {}
type ThumbnailableElement = HTMLImageElement | HTMLVideoElement;
interface IUpload {
fileName: string;
roomId: string;
total: number;
loaded: number;
promise: Promise<any>;
canceled?: boolean;
}
interface IMediaConfig {
"m.upload.size"?: number;
}
@ -70,6 +69,7 @@ interface IContent {
interface IThumbnail {
info: {
// eslint-disable-next-line camelcase
thumbnail_info: {
w: number;
h: number;
@ -104,7 +104,12 @@ interface IAbortablePromise<T> extends Promise<T> {
* @return {Promise} A promise that resolves with an object with an info key
* and a thumbnail key.
*/
function createThumbnail(element: ThumbnailableElement, inputWidth: number, inputHeight: number, mimeType: string): Promise<IThumbnail> {
function createThumbnail(
element: ThumbnailableElement,
inputWidth: number,
inputHeight: number,
mimeType: string,
): Promise<IThumbnail> {
return new Promise((resolve) => {
let targetWidth = inputWidth;
let targetHeight = inputHeight;
@ -368,10 +373,13 @@ export default class ContentMessages {
private mediaConfig: IMediaConfig = null;
sendStickerContentToRoom(url: string, roomId: string, info: string, text: string, matrixClient: MatrixClient) {
return MatrixClientPeg.get().sendStickerMessage(roomId, url, info, text).catch((e) => {
const startTime = CountlyAnalytics.getTimestamp();
const prom = MatrixClientPeg.get().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() {
@ -442,11 +450,13 @@ export default class ContentMessages {
for (let i = 0; i < okFiles.length; ++i) {
const file = okFiles[i];
if (!uploadAll) {
const {finished} = Modal.createTrackedDialog<[boolean, boolean]>('Upload Files confirmation', '', UploadConfirmDialog, {
file,
currentIndex: i,
totalFiles: okFiles.length,
});
const {finished} = Modal.createTrackedDialog<[boolean, boolean]>('Upload Files confirmation',
'', UploadConfirmDialog, {
file,
currentIndex: i,
totalFiles: okFiles.length,
},
);
const [shouldContinue, shouldUploadAll] = await finished;
if (!shouldContinue) break;
if (shouldUploadAll) {
@ -472,11 +482,12 @@ export default class ContentMessages {
if (upload) {
upload.canceled = true;
MatrixClientPeg.get().cancelUpload(upload.promise);
dis.dispatch({action: 'upload_canceled', upload});
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: {
@ -490,11 +501,11 @@ export default class ContentMessages {
content.info.mimetype = file.type;
}
const prom = new Promise((resolve) => {
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);
Object.assign(content.info, imageInfo);
resolve();
}, (e) => {
console.error(e);
@ -507,7 +518,7 @@ export default class ContentMessages {
} else if (file.type.indexOf('video/') === 0) {
content.msgtype = 'm.video';
infoForVideoFile(matrixClient, roomId, file).then((videoInfo) => {
extend(content.info, videoInfo);
Object.assign(content.info, videoInfo);
resolve();
}, (e) => {
content.msgtype = 'm.file';
@ -532,7 +543,7 @@ export default class ContentMessages {
promise: prom,
};
this.inprogress.push(upload);
dis.dispatch({action: 'upload_started'});
dis.dispatch<UploadStartedPayload>({action: Action.UploadStarted, upload});
// Focus the composer view
dis.fire(Action.FocusComposer);
@ -540,7 +551,7 @@ export default class ContentMessages {
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;
@ -562,7 +573,9 @@ export default class ContentMessages {
return promBefore;
}).then(function() {
if (upload.canceled) throw new UploadCanceledError();
return matrixClient.sendMessage(roomId, content);
const prom = matrixClient.sendMessage(roomId, content);
CountlyAnalytics.instance.trackSendMessage(startTime, prom, roomId, false, false, content);
return prom;
}, function(err) {
error = err;
if (!upload.canceled) {
@ -593,9 +606,9 @@ export default class ContentMessages {
if (error && error.http_status === 413) {
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<UploadFinishedPayload>({action: Action.UploadFinished, upload});
dis.dispatch({action: 'message_sent'});
}
});

973
src/CountlyAnalytics.ts Normal file
View file

@ -0,0 +1,973 @@
/*
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 {getCurrentLanguage} from './languageHandler';
import PlatformPeg from './PlatformPeg';
import SdkConfig from './SdkConfig';
import {MatrixClientPeg} from "./MatrixClientPeg";
import {sleep} from "./utils/promise";
import RoomViewStore from "./stores/RoomViewStore";
// polyfill textencoder if necessary
import * as TextEncodingUtf8 from 'text-encoding-utf-8';
let TextEncoder = window.TextEncoder;
if (!TextEncoder) {
TextEncoder = TextEncodingUtf8.TextEncoder;
}
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: "join_room";
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: NodeJS.Timeout;
private activityIntervalId: NodeJS.Timeout;
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.innerWidth > window.innerHeight ? 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);
window.addEventListener("scroll", this.onUserActivity);
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>("join_room", { 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: {format?: string, msgtype: string},
) {
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,248 +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 {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;
}
export class AccessCancelledError extends Error {
constructor() {
super("Secret storage access canceled");
}
}
async function confirmToDismiss() {
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;
}
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();
}
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 === "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 = {
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

@ -15,6 +15,7 @@ limitations under the License.
*/
import {MatrixClientPeg} from './MatrixClientPeg';
import dis from "./dispatcher/dispatcher";
import {
hideToast as hideBulkUnverifiedSessionsToast,
showToast as showBulkUnverifiedSessionsToast,
@ -28,11 +29,15 @@ import {
hideToast as hideUnverifiedSessionsToast,
showToast as showUnverifiedSessionsToast,
} from "./toasts/UnverifiedSessionToast";
import {privateShouldBeEncrypted} from "./createRoom";
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?
@ -60,6 +65,8 @@ export default class DeviceListener {
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();
}
@ -72,6 +79,11 @@ export default class DeviceListener {
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;
@ -158,6 +170,21 @@ export default class DeviceListener {
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() {
@ -170,9 +197,10 @@ export default class DeviceListener {
}
private shouldShowSetupEncryptionToast() {
// In a default configuration, show the toasts. If the well-known config causes e2ee default to be false
// then do not show the toasts until user is in at least one encrypted room.
if (privateShouldBeEncrypted()) return true;
// 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));
}
@ -189,15 +217,20 @@ export default class DeviceListener {
if (!cli.isInitialSyncComplete()) return;
const crossSigningReady = await cli.isCrossSigningReady();
const secretStorageReady = await cli.isSecretStorageReady();
const allSystemsReady = crossSigningReady && secretStorageReady;
if (this.dismissedThisDeviceToast || crossSigningReady) {
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.getStoredCrossSigningForUser(cli.getUserId())) {
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 {
@ -207,7 +240,15 @@ export default class DeviceListener {
showSetupEncryptionToast(SetupKind.UPGRADE_ENCRYPTION);
} else {
// No cross-signing or key backup on account (set up encryption)
showSetupEncryptionToast(SetupKind.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);
}
}
}
}

View file

@ -1,275 +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/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";
import {objectClone} from "./utils/objects";
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 = objectClone(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 = objectClone(event.data);
data.response = {
error: {
message: msg,
},
};
if (nestedError) {
data.response.error._error = nestedError;
}
event.source.postMessage(data, event.origin);
}
}

View file

@ -148,13 +148,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

@ -19,6 +19,7 @@ limitations under the License.
import React from 'react';
import sanitizeHtml from 'sanitize-html';
import { IExtendedSanitizeOptions } from './@types/sanitize-html';
import * as linkify from 'linkifyjs';
import linkifyMatrix from './linkify-matrix';
import _linkifyElement from 'linkifyjs/element';
@ -26,11 +27,15 @@ import _linkifyString from 'linkifyjs/string';
import classNames from 'classnames';
import EMOJIBASE_REGEX from 'emojibase-regex';
import url from 'url';
import katex from 'katex';
import { AllHtmlEntities } from 'html-entities';
import SettingsStore from './settings/SettingsStore';
import cheerio from 'cheerio';
import {MatrixClientPeg} from './MatrixClientPeg';
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);
@ -52,7 +57,7 @@ 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'];
/*
* Return true if the given string contains emoji
@ -125,11 +130,14 @@ export function sanitizedHtmlNode(insaneHtml: string) {
return <div dangerouslySetInnerHTML={{ __html: saneHtml }} dir="auto" />;
}
export function sanitizedHtmlNodeInnerText(insaneHtml: string) {
const saneHtml = sanitizeHtml(insaneHtml, sanitizeHtmlParams);
const contentDiv = document.createElement("div");
contentDiv.innerHTML = saneHtml;
return contentDiv.innerText;
export function getHtmlText(insaneHtml: string) {
return sanitizeHtml(insaneHtml, {
allowedTags: [],
allowedAttributes: {},
selfClosing: [],
allowedSchemes: [],
disallowedTagsMode: 'discard',
})
}
/**
@ -151,14 +159,14 @@ export function isUrlPermitted(inputUrl: string) {
}
}
const transformTags: sanitizeHtml.IOptions["transformTags"] = { // custom to matrix
const transformTags: IExtendedSanitizeOptions["transformTags"] = { // custom to matrix
// add blank targets to all hyperlinks except vector URLs
'a': function(tagName: string, attribs: sanitizeHtml.Attributes) {
if (attribs.href) {
attribs.target = '_blank'; // by default
const transformed = tryTransformPermalinkToLocalHref(attribs.href);
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;
}
@ -170,14 +178,15 @@ const transformTags: sanitizeHtml.IOptions["transformTags"] = { // custom to mat
// 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://')) {
// 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 (!attribs.src || !attribs.src.startsWith('mxc://') || !SettingsStore.getValue("showImages")) {
return { tagName, attribs: {}};
}
attribs.src = MatrixClientPeg.get().mxcUrlToHttp(
attribs.src,
attribs.width || 800,
attribs.height || 600,
);
const width = Number(attribs.width) || 800;
const height = Number(attribs.height) || 600;
attribs.src = mediaFromMxc(attribs.src).getThumbnailOfSourceHttp(width, height);
return { tagName, attribs };
},
'code': function(tagName: string, attribs: sanitizeHtml.Attributes) {
@ -224,18 +233,20 @@ const transformTags: sanitizeHtml.IOptions["transformTags"] = { // custom to mat
},
};
const sanitizeHtmlParams: sanitizeHtml.IOptions = {
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'],
@ -245,13 +256,14 @@ const sanitizeHtmlParams: sanitizeHtml.IOptions = {
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: sanitizeHtml.IOptions = {
const composerSanitizeHtmlParams: IExtendedSanitizeOptions = {
...sanitizeHtmlParams,
transformTags: {
'code': transformTags['code'],
@ -339,33 +351,9 @@ class HtmlHighlighter extends BaseHighlighter<string> {
}
}
class TextHighlighter extends BaseHighlighter<React.ReactNode> {
private 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
*/
protected processSnippet(snippet: string, highlight: boolean): React.ReactNode {
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 IContent {
format?: string;
// eslint-disable-next-line camelcase
formatted_body?: string;
body: string;
}
@ -421,7 +409,7 @@ export function bodyToHtml(content: IContent, highlights: string[], opts: IOpts
}
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;
@ -432,18 +420,41 @@ export function bodyToHtml(content: IContent, highlights: string[], opts: IOpts
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.
@ -474,8 +485,13 @@ export function bodyToHtml(content: IContent, highlights: string[], opts: IOpts
});
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>;
}
/**
@ -528,7 +544,6 @@ export function checkBlockNode(node: Node) {
case "H6":
case "PRE":
case "BLOCKQUOTE":
case "DIV":
case "P":
case "UL":
case "OL":
@ -541,6 +556,9 @@ export function checkBlockNode(node: 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,7 +14,8 @@ 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 Modal from './Modal';
@ -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(

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

@ -17,9 +17,13 @@ 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} 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';
@ -40,51 +44,58 @@ import ToastStore from "./stores/ToastStore";
import {IntegrationManagers} from "./integrations/IntegrationManagers";
import {Mjolnir} from "./mjolnir/Mjolnir";
import DeviceListener from "./DeviceListener";
import RebrandListener from "./RebrandListener";
import {Jitsi} from "./widgets/Jitsi";
import {SSO_HOMESERVER_URL_KEY, SSO_ID_SERVER_URL_KEY} from "./BasePlatform";
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";
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;
@ -97,12 +108,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,
@ -110,7 +122,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) {
@ -118,7 +130,7 @@ export async function loadSession(opts) {
}
if (enableGuest) {
return _registerAsGuest(guestHsUrl, guestIsUrl, defaultDeviceDisplayName);
return registerAsGuest(guestHsUrl, guestIsUrl, defaultDeviceDisplayName);
}
// fall back to welcome screen
@ -129,7 +141,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);
}
}
@ -137,20 +149,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];
}
/**
@ -158,12 +163,17 @@ 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);
}
@ -172,6 +182,12 @@ export function attemptTokenLogin(queryParams, defaultDeviceDisplayName) {
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);
}
@ -184,19 +200,41 @@ export function attemptTokenLogin(queryParams, 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) {
@ -229,11 +267,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,
});
@ -243,7 +285,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,
@ -257,15 +299,42 @@ function _registerAsGuest(hsUrl, isUrl, defaultDeviceDisplayName) {
});
}
export interface IStoredSession {
hsUrl: string;
isUrl: string;
hasAccessToken: boolean;
accessToken: string | object;
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() {
export async function getStoredSessionVars(): Promise<IStoredSession> {
const hsUrl = localStorage.getItem(HOMESERVER_URL_KEY);
const isUrl = localStorage.getItem(ID_SERVER_URL_KEY);
const accessToken = localStorage.getItem("mx_access_token");
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");
@ -277,7 +346,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
@ -290,14 +395,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) {
@ -305,22 +414,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 {
@ -329,7 +448,7 @@ async function _restoreFromLocalStorage(opts) {
}
}
async function _handleLoadSessionFailure(e) {
async function handleLoadSessionFailure(e: Error): Promise<boolean> {
console.error("Unable to load session", e);
const SessionRestoreErrorDialog =
@ -342,7 +461,7 @@ async function _handleLoadSessionFailure(e) {
const [success] = await modal.finished;
if (success) {
// user clicked continue.
await _clearStorage();
await clearStorage();
return false;
}
@ -363,11 +482,12 @@ async function _handleLoadSessionFailure(e) {
*
* @returns {Promise} promise which resolves to the new MatrixClient once it has been started
*/
export async function setLoggedIn(credentials) {
export async function setLoggedIn(credentials: IMatrixClientCreds): Promise<MatrixClient> {
credentials.freshLogin = true;
stopMatrixClient();
const pickleKey = credentials.userId && credentials.deviceId
? await PlatformPeg.get().createPickleKey(credentials.userId, credentials.deviceId)
: null;
? await PlatformPeg.get().createPickleKey(credentials.userId, credentials.deviceId)
: null;
if (pickleKey) {
console.log("Created pickle key");
@ -375,7 +495,7 @@ export async function setLoggedIn(credentials) {
console.log("Pickle key not created");
}
return _doSetLoggedIn(Object.assign({}, credentials, {pickleKey}), true);
return doSetLoggedIn(Object.assign({}, credentials, {pickleKey}), true);
}
/**
@ -393,7 +513,7 @@ export async 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();
@ -406,7 +526,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);
}
/**
@ -418,7 +538,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();
@ -429,6 +552,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
@ -440,8 +564,8 @@ async function _doSetLoggedIn(credentials, clearStorage) {
// (dis.dispatch uses `setTimeout`, which does not guarantee ordering.)
dis.dispatch({action: 'on_logging_in'}, true);
if (clearStorage) {
await _clearStorage();
if (clearStorageEnabled) {
await clearStorage();
}
const results = await StorageManager.checkConsistency();
@ -449,32 +573,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);
}
@ -482,15 +605,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() {
function showStorageEvictedDialog(): Promise<boolean> {
const StorageEvictedDialog = sdk.getComponent('views.dialogs.StorageEvictedDialog');
return new Promise(resolve => {
Modal.createTrackedDialog('Storage evicted', '', StorageEvictedDialog, {
@ -503,18 +624,55 @@ function _showStorageEvictedDialog() {
// `instanceof`. Babel 7 supports this natively in their class handling.
class AbortLoginAndRebuildStorage extends Error { }
function _persistCredentialsToLocalStorage(credentials) {
async function persistCredentials(credentials: IMatrixClientCreds): Promise<void> {
localStorage.setItem(HOMESERVER_URL_KEY, credentials.homeserverUrl);
if (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));
if (credentials.pickleKey) {
localStorage.setItem("mx_has_pickle_key", true);
// 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.");
}
@ -529,6 +687,8 @@ function _persistCredentialsToLocalStorage(credentials) {
localStorage.setItem("mx_device_id", credentials.deviceId);
}
SecurityCustomisations.persistCredentials?.(credentials);
console.log(`Session persisted for ${credentials.userId}`);
}
@ -537,14 +697,18 @@ 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;
}
@ -566,7 +730,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
@ -587,11 +751,11 @@ export function softLogout() {
// 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;
}
@ -601,7 +765,7 @@ 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
@ -619,6 +783,7 @@ async function startMatrixClient(startSyncing=true) {
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
@ -647,8 +812,6 @@ async function startMatrixClient(startSyncing=true) {
// Now that we have a MatrixClientPeg, update the Jitsi info
await Jitsi.getInstance().start();
RebrandListener.sharedInstance().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'});
@ -662,24 +825,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);
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) {
@ -701,8 +882,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();
@ -710,7 +892,6 @@ export function stopMatrixClient(unsetClient=true) {
IntegrationManagers.sharedInstance().stopWatching();
Mjolnir.sharedInstance().stop();
DeviceListener.sharedInstance().stop();
RebrandListener.sharedInstance().stop();
if (DMRoomMap.shared()) DMRoomMap.shared().stop();
EventIndexPeg.stop();
const cli = MatrixClientPeg.get();

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,189 +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,
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: 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 = "org.matrix.gitlab",
Github = "org.matrix.github",
Apple = "org.matrix.apple",
Google = "org.matrix.google",
Facebook = "org.matrix.facebook",
Twitter = "org.matrix.twitter",
}
export interface IIdentityProvider {
id: string;
name: string;
icon?: string;
brand?: IdentityProviderBrand | string;
}
export interface ISSOFlow {
type: "m.login.sso" | "m.login.cas";
"org.matrix.msc2858.identity_providers": IIdentityProvider[]; // Unstable prefix for MSC2858
}
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

@ -14,8 +14,8 @@ 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'];
@ -23,6 +23,11 @@ const ALLOWED_HTML_TAGS = ['sub', 'sup', 'del', 'u'];
const TEXT_NODES = ['text', 'softbreak', 'linebreak', 'paragraph', 'document'];
function is_allowed_html_tag(node) {
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,6 +35,7 @@ function is_allowed_html_tag(node) {
const tag = matches[1];
return ALLOWED_HTML_TAGS.indexOf(tag) > -1;
}
return false;
}

View file

@ -17,6 +17,7 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import { ICreateClientOpts } from 'matrix-js-sdk/src/matrix';
import {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';
@ -31,17 +32,19 @@ 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 { crossSigningCallbacks, tryToUnlockSecretStorageWithDehydrationKey } from './SecurityManager';
import {SHOW_QR_CODE_METHOD} from "matrix-js-sdk/src/crypto/verification/QRCode";
import SecurityCustomisations from "./customisations/Security";
export interface IMatrixClientCreds {
homeserverUrl: string;
identityServerUrl: string;
userId: string;
deviceId: string;
deviceId?: string;
accessToken: string;
guest: boolean;
guest?: boolean;
pickleKey?: string;
freshLogin?: boolean;
}
// TODO: Move this to the js-sdk
@ -98,6 +101,12 @@ export interface IMatrixClientPeg {
*/
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
@ -148,6 +157,9 @@ class _MatrixClientPeg implements IMatrixClientPeg {
public setJustRegisteredUserId(uid: string): void {
this.justRegisteredUserId = uid;
if (uid) {
window.localStorage.setItem("mx_registration_time", String(new Date().getTime()));
}
}
public currentUserIsJustRegistered(): boolean {
@ -157,6 +169,15 @@ class _MatrixClientPeg implements IMatrixClientPeg {
);
}
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;
}
}
public replaceUsingCreds(creds: IMatrixClientCreds): void {
this.currentClientCreds = creds;
this.createClient(creds);
@ -192,6 +213,7 @@ class _MatrixClientPeg implements IMatrixClientPeg {
this.matrixClient.setCryptoTrustCrossSignedDevices(
!SettingsStore.getValue('e2ee.manuallyVerifyAllSessions'),
);
await tryToUnlockSecretStorageWithDehydrationKey(this.matrixClient);
StorageManager.setCryptoInitialised(true);
}
} catch (e) {
@ -239,7 +261,7 @@ class _MatrixClientPeg implements IMatrixClientPeg {
}
public getHomeserverName(): string {
const matches = /^@.+:(.+)$/.exec(this.matrixClient.credentials.userId);
const matches = /^@[^:]+:(.+)$/.exec(this.matrixClient.credentials.userId);
if (matches === null || matches.length < 1) {
throw new Error("Failed to derive homeserver name from user ID!");
}
@ -247,8 +269,7 @@ class _MatrixClientPeg implements IMatrixClientPeg {
}
private createClient(creds: IMatrixClientCreds): void {
// TODO: Make these opts typesafe with the js-sdk
const opts = {
const opts: ICreateClientOpts = {
baseUrl: creds.homeserverUrl,
idBaseUrl: creds.identityServerUrl,
accessToken: creds.accessToken,
@ -258,6 +279,10 @@ class _MatrixClientPeg implements IMatrixClientPeg {
timelineSupport: true,
forceTURN: !SettingsStore.getValue('webRtcAllowPeerToPeer'),
fallbackICEServerAllowed: !!SettingsStore.getValue('fallbackICEServerAllowed'),
// Gather up to 20 ICE candidates when a call arrives: this should be more than we'd
// ever normally need, so effectively this should make all the gathering happen when
// the call arrives.
iceCandidatePoolSize: 20,
verificationMethods: [
verificationMethods.SAS,
SHOW_QR_CODE_METHOD,
@ -272,6 +297,10 @@ class _MatrixClientPeg implements IMatrixClientPeg {
// 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);

View file

@ -28,7 +28,7 @@ import AsyncWrapper from './AsyncWrapper';
const DIALOG_CONTAINER_ID = "mx_Dialog_Container";
const STATIC_DIALOG_CONTAINER_ID = "mx_Dialog_StaticContainer";
interface IModal<T extends any[]> {
export interface IModal<T extends any[]> {
elem: React.ReactNode;
className?: string;
beforeClosePromise?: Promise<boolean>;
@ -36,9 +36,10 @@ interface IModal<T extends any[]> {
onBeforeClose?(reason?: string): Promise<boolean>;
onFinished(...args: T): void;
close(...args: T): void;
hidden?: boolean;
}
interface IHandle<T extends any[]> {
export interface IHandle<T extends any[]> {
finished: Promise<T>;
close(...args: T): void;
}
@ -93,6 +94,12 @@ export class ModalManager {
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;
}
@ -132,7 +139,7 @@ export class ModalManager {
public createTrackedDialogAsync<T extends any[]>(
analyticsAction: string,
analyticsInfo: string,
...rest: Parameters<ModalManager["appendDialogAsync"]>
...rest: Parameters<ModalManager["createDialogAsync"]>
) {
Analytics.trackEvent('Modal', analyticsAction, analyticsInfo);
return this.createDialogAsync<T>(...rest);
@ -147,11 +154,20 @@ export class ModalManager {
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>
options?: IOptions<T>,
) {
const modal: IModal<T> = {
onFinished: props ? props.onFinished : null,
@ -182,7 +198,7 @@ export class ModalManager {
private getCloseFn<T extends any[]>(
modal: IModal<T>,
props: IProps<T>
props: IProps<T>,
): [IHandle<T>["close"], IHandle<T>["finished"]] {
const deferred = defer<T>();
return [async (...args: T) => {
@ -264,7 +280,7 @@ export class ModalManager {
className?: string,
isPriorityModal = false,
isStaticModal = false,
options: IOptions<T> = {}
options: IOptions<T> = {},
): IHandle<T> {
const {modal, closeDialog, onFinishedProm} = this.buildModal<T>(prom, props, className, options);
if (isPriorityModal) {
@ -287,7 +303,7 @@ export class ModalManager {
private appendDialogAsync<T extends any[]>(
prom: Promise<React.ComponentType>,
props?: IProps<T>,
className?: string
className?: string,
): IHandle<T> {
const {modal, closeDialog, onFinishedProm} = this.buildModal<T>(prom, props, className, {});
@ -355,7 +371,7 @@ export class ModalManager {
}
const modal = this.getCurrentModal();
if (modal !== this.staticModal) {
if (modal !== this.staticModal && !modal.hidden) {
const classes = classNames("mx_Dialog_wrapper", modal.className, {
mx_Dialog_wrapperWithStaticUnder: this.staticModal,
});

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

@ -33,6 +33,10 @@ import Modal from './Modal';
import SettingsStore from "./settings/SettingsStore";
import { hideToast as hideNotificationsToast } from "./toasts/DesktopNotificationsToast";
import {SettingLevel} from "./settings/SettingLevel";
import {isPushNotifyDisabled} from "./settings/controllers/NotificationControllers";
import RoomViewStore from "./stores/RoomViewStore";
import UserActivity from "./UserActivity";
import {mediaFromMxc} from "./customisations/Media";
/*
* Dispatches:
@ -147,7 +151,7 @@ export 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,
@ -217,7 +221,7 @@ export 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
@ -258,7 +262,7 @@ export 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() {
@ -283,10 +287,10 @@ export const Notifier = {
return SettingsStore.getValue("audioNotificationsEnabled");
},
setToolbarHidden: function(hidden: boolean, 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));
hideNotificationsToast();
@ -296,17 +300,17 @@ export const Notifier = {
}
},
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";
@ -375,6 +379,15 @@ export 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);
}

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,9 +74,6 @@ 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({

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

@ -19,30 +19,34 @@ limitations under the License.
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,14 +69,14 @@ 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();
}
}
@ -81,13 +85,11 @@ class Presence {
* 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;
@ -97,9 +99,9 @@ class Presence {
try {
await MatrixClientPeg.get().setPresence(this.state);
console.info("Presence: %s", newState);
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

@ -1,184 +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 SdkConfig from "./SdkConfig";
import ToastStore from "./stores/ToastStore";
import GenericToast from "./components/views/toasts/GenericToast";
import RebrandDialog from "./components/views/dialogs/RebrandDialog";
import { RebrandDialogKind } from "./components/views/dialogs/RebrandDialog";
import Modal from './Modal';
import { _t } from './languageHandler';
const TOAST_KEY = 'rebrand';
const NAG_INTERVAL = 24 * 60 * 60 * 1000;
function getRedirectUrl(url): string {
const redirectUrl = new URL(url);
redirectUrl.hash = '';
if (SdkConfig.get()['redirectToNewBrandUrl']) {
const newUrl = new URL(SdkConfig.get()['redirectToNewBrandUrl']);
if (url.hostname !== newUrl.hostname || url.pathname !== newUrl.pathname) {
redirectUrl.hostname = newUrl.hostname;
redirectUrl.pathname = newUrl.pathname;
return redirectUrl.toString();
}
return null;
} else if (url.hostname === 'riot.im') {
if (url.pathname.startsWith('/app')) {
redirectUrl.hostname = 'app.element.io';
redirectUrl.pathname = '/';
} else if (url.pathname.startsWith('/staging')) {
redirectUrl.hostname = 'staging.element.io';
redirectUrl.pathname = '/';
} else if (url.pathname.startsWith('/develop')) {
redirectUrl.hostname = 'develop.element.io';
redirectUrl.pathname = '/';
}
return redirectUrl.href;
} else if (url.hostname.endsWith('.riot.im')) {
redirectUrl.hostname = url.hostname.substr(0, url.hostname.length - '.riot.im'.length) + '.element.io';
return redirectUrl.href;
} else {
return null;
}
}
/**
* Shows toasts informing the user that the name of the app has changed and,
* potentially, that they should head to a different URL and log in there
*/
export default class RebrandListener {
private _reshowTimer?: number;
private nagAgainAt?: number = null;
static sharedInstance() {
if (!window.mxRebrandListener) window.mxRebrandListener = new RebrandListener();
return window.mxRebrandListener;
}
constructor() {
this._reshowTimer = null;
}
start() {
this.recheck();
}
stop() {
if (this._reshowTimer) {
clearTimeout(this._reshowTimer);
this._reshowTimer = null;
}
}
onNagToastLearnMore = async () => {
const [doneClicked] = await Modal.createDialog(RebrandDialog, {
kind: RebrandDialogKind.NAG,
targetUrl: getRedirectUrl(window.location),
}).finished;
if (doneClicked) {
// open in new tab: they should come back here & log out
window.open(getRedirectUrl(window.location), '_blank');
}
// whatever the user clicks, we go away & nag again after however long:
// If they went to the new URL, we want to nag them to log out if they
// come back to this tab, and if they clicked, 'remind me later' we want
// to, well, remind them later.
this.nagAgainAt = Date.now() + NAG_INTERVAL;
this.recheck();
};
onOneTimeToastLearnMore = async () => {
const [doneClicked] = await Modal.createDialog(RebrandDialog, {
kind: RebrandDialogKind.ONE_TIME,
}).finished;
if (doneClicked) {
localStorage.setItem('mx_rename_dialog_dismissed', 'true');
this.recheck();
}
};
onOneTimeToastDismiss = async () => {
localStorage.setItem('mx_rename_dialog_dismissed', 'true');
this.recheck();
};
onNagTimerFired = () => {
this._reshowTimer = null;
this.nagAgainAt = null;
this.recheck();
};
private async recheck() {
// There are two types of toast/dialog we show: a 'one time' informing the user that
// the app is now called a different thing but no action is required from them (they
// may need to look for a different name name/icon to launch the app but don't need to
// log in again) and a nag toast where they need to log in to the app on a different domain.
let nagToast = false;
let oneTimeToast = false;
if (getRedirectUrl(window.location)) {
if (!this.nagAgainAt) {
// if we have redirectUrl, show the nag toast
nagToast = true;
}
} else {
// otherwise we show the 'one time' toast / dialog
const renameDialogDismissed = localStorage.getItem('mx_rename_dialog_dismissed');
if (renameDialogDismissed !== 'true') {
oneTimeToast = true;
}
}
if (nagToast || oneTimeToast) {
let description;
let rejectLabel = null;
let onReject = null;
if (nagToast) {
description = _t("Use your account to sign in to the latest version");
} else {
description = _t("Were excited to announce Riot is now Element");
rejectLabel = _t("Dismiss");
onReject = this.onOneTimeToastDismiss;
}
ToastStore.sharedInstance().addOrReplaceToast({
key: TOAST_KEY,
title: _t("Riot is now Element!"),
icon: 'element_logo',
props: {
description,
acceptLabel: _t("Learn More"),
onAccept: nagToast ? this.onNagToastLearnMore : this.onOneTimeToastLearnMore,
rejectLabel,
onReject,
},
component: GenericToast,
priority: 20,
});
} else {
ToastStore.sharedInstance().dismissToast(TOAST_KEY);
}
if (!this._reshowTimer && this.nagAgainAt) {
// XXX: Our build system picks up NodeJS bindings when we need browser bindings.
this._reshowTimer = setTimeout(this.onNagTimerFired, (this.nagAgainAt - Date.now()) + 100) as any as number;
}
}
}

View file

@ -24,7 +24,6 @@ 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/element-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

@ -17,15 +17,15 @@ limitations under the License.
import {MatrixClientPeg} from './MatrixClientPeg';
import dis from './dispatcher/dispatcher';
import { EventStatus } from 'matrix-js-sdk';
import { EventStatus } from 'matrix-js-sdk/src/models/event';
export default class Resend {
static resendUnsentEvents(room) {
room.getPendingEvents().filter(function(ev) {
return Promise.all(room.getPendingEvents().filter(function(ev) {
return ev.status === EventStatus.NOT_SENT;
}).forEach(function(event) {
Resend.resend(event);
});
}).map(function(event) {
return Resend.resend(event);
}));
}
static cancelUnsentEvents(room) {
@ -38,7 +38,7 @@ export default class Resend {
static resend(event) {
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,

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,7 +26,7 @@ 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];

View file

@ -22,7 +22,9 @@ 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";
import InviteDialog, {KIND_DM, KIND_INVITE} from "./components/views/dialogs/InviteDialog";
import CommunityPrototypeInviteDialog from "./components/views/dialogs/CommunityPrototypeInviteDialog";
import {CommunityPrototypeStore} from "./stores/CommunityPrototypeStore";
/**
* Invites multiple addresses to a room
@ -38,24 +40,44 @@ export function inviteMultipleToRoom(roomId, addrs) {
return inviter.invite(addrs).then(states => Promise.resolve({states, inviter}));
}
export function showStartChatInviteDialog() {
export function showStartChatInviteDialog(initialText) {
// 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},
'Start DM', '', InviteDialog, {kind: KIND_DM, initialText},
/*className=*/null, /*isPriority=*/false, /*isStatic=*/true,
);
}
export function showRoomInviteDialog(roomId) {
export function showRoomInviteDialog(roomId, initialText = "") {
// 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},
"Invite Users", "", InviteDialog, {
kind: KIND_INVITE,
initialText,
roomId,
},
/*className=*/null, /*isPriority=*/false, /*isStatic=*/true,
);
}
export function showCommunityRoomInviteDialog(roomId, communityName) {
Modal.createTrackedDialog(
'Invite Users to Community', '', CommunityPrototypeInviteDialog, {communityName, roomId},
/*className=*/null, /*isPriority=*/false, /*isStatic=*/true,
);
}
export function showCommunityInviteDialog(communityId) {
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
@ -77,7 +99,7 @@ export function isValid3pidInvite(event) {
export function inviteUsersToRoom(roomId, userIds) {
return inviteMultipleToRoom(roomId, userIds).then((result) => {
const room = MatrixClientPeg.get().getRoom(roomId);
return _showAnyInviteErrors(result.states, room, result.inviter);
showAnyInviteErrors(result.states, room, result.inviter);
}).catch((err) => {
console.error(err.stack);
const ErrorDialog = sdk.getComponent("dialogs.ErrorDialog");
@ -88,7 +110,7 @@ export function inviteUsersToRoom(roomId, userIds) {
});
}
function _showAnyInviteErrors(addrs, room, inviter) {
export 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) {
@ -100,6 +122,7 @@ function _showAnyInviteErrors(addrs, room, inviter) {
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) {
@ -118,8 +141,9 @@ function _showAnyInviteErrors(addrs, room, inviter) {
title: _t("Failed to invite the following users to the %(roomName)s room:", {roomName: room.name}),
description,
});
return false;
}
}
return addrs;
return true;
}

View file

@ -202,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;

View file

@ -21,63 +21,14 @@ import {MatrixClientPeg} from './MatrixClientPeg';
* 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];
}
/**
* 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];
}
return null;
}
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) {
const myMembership = room.getMyMembership();
const me = room.getMember(myUserId);

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,13 +16,14 @@ limitations under the License.
import url from 'url';
import SettingsStore from "./settings/SettingsStore";
import { Service, startTermsFlow, TermsNotSignedError } from './Terms';
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";
@ -31,9 +31,11 @@ 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.
@ -46,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
@ -56,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");
@ -64,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;
@ -100,7 +102,7 @@ export default class ScalarAuthClient {
}
}
_getAccountName(token) {
private getAccountName(token: string): Promise<string> {
const url = this.apiUrl + "/account";
return new Promise(function(resolve, reject) {
@ -125,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);
@ -153,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;
@ -165,22 +167,22 @@ 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) {
@ -194,7 +196,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 || !body.scalar_token) {
reject(new Error("Missing scalar_token in response"));
} else {
@ -204,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);
@ -218,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 {
@ -240,10 +242,10 @@ export default class ScalarAuthClient {
* @param {string} widgetId The widget ID to disable assets for
* @return {Promise} Resolves on completion
*/
disableWidgetAssets(widgetType: 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', // XXX: Actions shouldn't be GET requests
uri: url,
@ -257,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 {
@ -267,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;
@ -284,7 +286,7 @@ export default class ScalarAuthClient {
return url;
}
getStarterLink(starterLinkUrl) {
getStarterLink(starterLinkUrl: string): string {
return starterLinkUrl + "?scalar_token=" + encodeURIComponent(this.scalarToken);
}
}

View file

@ -237,7 +237,7 @@ Example:
*/
import {MatrixClientPeg} from './MatrixClientPeg';
import { MatrixEvent } from 'matrix-js-sdk';
import { MatrixEvent } from 'matrix-js-sdk/src/models/event';
import dis from './dispatcher/dispatcher';
import WidgetUtils from './utils/WidgetUtils';
import RoomViewStore from './stores/RoomViewStore';

View file

@ -33,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

@ -360,7 +360,7 @@ function combineEvents(previousSearchResult, localEvents = undefined, serverEven
let oldestEventFrom = previousSearchResult.oldestEventFrom;
response.highlights = previousSearchResult.highlights;
if (localEvents && serverEvents) {
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.
@ -379,7 +379,7 @@ function combineEvents(previousSearchResult, localEvents = undefined, serverEven
oldestEventFrom = "local";
}
combineEventSources(previousSearchResult, response, localEvents.results, cachedEvents);
} else if (serverEvents) {
} 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
@ -454,7 +454,7 @@ function combineResponses(previousSearchResult, localEvents = undefined, serverE
return response;
}
function restoreEncryptionInfo(searchResultSlice) {
function restoreEncryptionInfo(searchResultSlice = []) {
for (let i = 0; i < searchResultSlice.length; i++) {
const timeline = searchResultSlice[i].context.getTimeline();
@ -517,7 +517,7 @@ async function combinedPagination(searchResult) {
},
};
const oldResultCount = searchResult.results.length;
const oldResultCount = searchResult.results ? searchResult.results.length : 0;
// Let the client process the combined result.
const result = client._processRoomEventsSearch(searchResult, response);

460
src/SecurityManager.ts Normal file
View file

@ -0,0 +1,460 @@
/*
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, IDeviceTrustLevel, ISecretStorageKeyInfo } from 'matrix-js-sdk/src/matrix';
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";
// 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: IDeviceTrustLevel,
): 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 {
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

@ -15,13 +15,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 +41,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 +53,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

@ -20,6 +20,7 @@ limitations under the License.
import * as React from 'react';
import * as ContentHelpers from 'matrix-js-sdk/src/content-helpers';
import {MatrixClientPeg} from './MatrixClientPeg';
import dis from './dispatcher/dispatcher';
import * as sdk from './index';
@ -37,13 +38,18 @@ 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 } from "parse5";
import sendBugReport from "./rageshake/submit-rageshake";
import SdkConfig from "./SdkConfig";
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 } from "./utils/membership";
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";
// XXX: workaround for https://github.com/microsoft/TypeScript/issues/31816
interface HTMLInputEvent extends Event {
@ -75,6 +81,7 @@ export const CommandCategories = {
"actions": _td("Actions"),
"admin": _td("Admin"),
"advanced": _td("Advanced"),
"effects": _td("Effects"),
"other": _td("Other"),
};
@ -88,6 +95,7 @@ interface ICommandOpts {
runFn?: RunFn;
category: string;
hideCompletionAfterSpace?: boolean;
isEnabled?(): boolean;
}
export class Command {
@ -98,6 +106,7 @@ export class Command {
runFn: undefined | RunFn;
category: string;
hideCompletionAfterSpace: boolean;
_isEnabled?: () => boolean;
constructor(opts: ICommandOpts) {
this.command = opts.command;
@ -107,6 +116,7 @@ export class Command {
this.runFn = opts.runFn;
this.category = opts.category || CommandCategories.other;
this.hideCompletionAfterSpace = opts.hideCompletionAfterSpace || false;
this._isEnabled = opts.isEnabled;
}
getCommand() {
@ -117,15 +127,19 @@ export 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 reject(_t("Command error"));
return this.runFn.bind(this)(roomId, args, cmd);
return this.runFn.bind(this)(roomId, args);
}
getUsage() {
return _t('Usage') + ': ' + this.getCommandWithArgs();
}
isEnabled() {
return this._isEnabled ? this._isEnabled() : true;
}
}
function reject(error) {
@ -141,6 +155,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 success(ContentHelpers.makeHtmlMessage(
message,
`<span data-mx-spoiler>${message}</span>`,
));
},
category: CommandCategories.messages,
}),
new Command({
command: 'shrug',
args: '<message>',
@ -150,7 +176,46 @@ export const Commands = [
if (args) {
message = message + ' ' + args;
}
return success(MatrixClientPeg.get().sendTextMessage(roomId, message));
return success(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 success(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 success(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 success(ContentHelpers.makeTextMessage(message));
},
category: CommandCategories.messages,
}),
@ -159,7 +224,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 success(ContentHelpers.makeTextMessage(messages));
},
category: CommandCategories.messages,
}),
@ -168,7 +233,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 success(ContentHelpers.makeHtmlMessage(messages, messages));
},
category: CommandCategories.messages,
}),
@ -389,15 +454,14 @@ 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.
@ -438,7 +502,7 @@ export const Commands = [
}
const inviter = new MultiInviter(roomId);
return success(prom.then(() => {
return inviter.invite([address]);
return inviter.invite([address], reason);
}).then(() => {
if (inviter.getCompletionState(address) !== "invited") {
throw new Error(inviter.getErrorText(address));
@ -495,6 +559,7 @@ export const Commands = [
action: 'view_room',
room_alias: roomAlias,
auto_join: true,
_type: "slash_command", // instrumentation
});
return success();
} else if (params[0][0] === '!') {
@ -509,6 +574,7 @@ export const Commands = [
},
via_servers: viaServers, // for the rejoin button
auto_join: true,
_type: "slash_command", // instrumentation
});
return success();
} else if (isPermalink) {
@ -533,6 +599,7 @@ export const Commands = [
const dispatch = {
action: 'view_room',
auto_join: true,
_type: "slash_command", // instrumentation
};
if (entity[0] === '!') dispatch["room_id"] = entity;
@ -601,11 +668,7 @@ export const Commands = [
}
if (!targetRoomId) targetRoomId = roomId;
return success(
cli.leaveRoomChain(targetRoomId).then(function() {
dis.dispatch({action: 'view_next_room'});
}),
);
return success(leaveRoomBehaviour(targetRoomId));
},
category: CommandCategories.actions,
}),
@ -733,7 +796,7 @@ export const Commands = [
const cli = MatrixClientPeg.get();
const room = cli.getRoom(roomId);
if (!room) return reject(_t("Command failed"));
const member = room.getMember(args);
const member = room.getMember(userId);
if (!member || getEffectiveMembership(member.membership) === EffectiveMembership.Leave) {
return reject(_t("Could not find user in room"));
}
@ -781,6 +844,7 @@ export const Commands = [
command: 'addwidget',
args: '<url | embed code | Jitsi url>',
description: _td('Adds a custom widget by URL to the room'),
isEnabled: () => SettingsStore.getValue(UIFeature.Widgets),
runFn: function(roomId, widgetUrl) {
if (!widgetUrl) {
return reject(_t("Please supply a widget URL or embed code"));
@ -792,7 +856,7 @@ export const Commands = [
// 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];
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)");
@ -864,12 +928,12 @@ 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);
@ -883,7 +947,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>,
@ -914,7 +978,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 success(ContentHelpers.makeHtmlMessage(args, textToHtmlRainbow(args)));
},
category: CommandCategories.messages,
}),
@ -924,7 +988,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 success(ContentHelpers.makeHtmlEmote(args, textToHtmlRainbow(args)));
},
category: CommandCategories.messages,
}),
@ -963,19 +1027,13 @@ export const Commands = [
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(
sendBugReport(SdkConfig.get().bug_report_endpoint_url, {
userText: args,
sendLogs: true,
}).then(() => {
const InfoDialog = sdk.getComponent('dialogs.InfoDialog');
Modal.createTrackedDialog('Slash Commands', 'Rageshake sent', InfoDialog, {
title: _t('Logs sent'),
description: _t('Thank you!'),
});
}),
Modal.createTrackedDialog('Slash Commands', 'Bug Report Dialog', BugReportDialog, {
initialText: args,
}).finished,
);
},
category: CommandCategories.advanced,
@ -985,14 +1043,27 @@ export const Commands = [
description: _td("Opens chat with the given user"),
args: "<user-id>",
runFn: function(roomId, userId) {
if (!userId || !userId.startsWith("@") || !userId.includes(":")) {
// 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: await ensureDMExists(MatrixClientPeg.get(), userId),
room_id: roomId,
});
})());
},
@ -1026,6 +1097,50 @@ export const Commands = [
},
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
@ -1036,6 +1151,30 @@ 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.
@ -1047,13 +1186,13 @@ Commands.forEach(cmd => {
});
});
export function parseCommandString(input) {
export function parseCommandString(input: string) {
// trim any trailing whitespace, as it can confuse the parser for
// IRC-style commands
input = input.replace(/\s+$/, '');
if (input[0] !== '/') return {}; // not a command
const bits = input.match(/^(\S+?)(?: +((.|\n)*))?$/);
const bits = input.match(/^(\S+?)(?:[ \n]+((.|\n)*))?$/);
let cmd;
let args;
if (bits) {
@ -1074,10 +1213,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) {
export function getCommand(input: string) {
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.
@ -17,7 +17,7 @@ limitations under the License.
import classNames from 'classnames';
import {MatrixClientPeg} from './MatrixClientPeg';
import * as sdk from './';
import * as sdk from '.';
import Modal from './Modal';
export class TermsNotSignedError extends Error {}
@ -32,13 +32,30 @@ 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: string, public baseUrl: string, public accessToken: string) {
}
}
interface Policy {
// @ts-ignore: No great way to express indexed types together with other keys
version: string;
[lang: string]: {
url: string;
};
}
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 +68,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,7 +94,7 @@ 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
@ -158,10 +175,13 @@ 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);
const TermsDialog = sdk.getComponent("views.dialogs.TermsDialog");

View file

@ -14,12 +14,12 @@ 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";
import {WIDGET_LAYOUT_EVENT_TYPE} from "./stores/widgets/WidgetLayoutStore";
function textForMemberEvent(ev) {
// XXX: SYJS-16 "sender is sometimes null for join messages"
@ -28,7 +28,6 @@ function textForMemberEvent(ev) {
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': {
@ -43,11 +42,7 @@ function textForMemberEvent(ev) {
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});
}
return _t('%(senderName)s invited %(targetName)s.', {senderName, targetName});
}
}
case 'ban':
@ -84,17 +79,11 @@ function textForMemberEvent(ev) {
}
} 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});
}
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") {
if (prevContent.membership === "invite") {
return _t('%(targetName)s rejected the invitation.', {targetName});
} else {
return _t('%(targetName)s left the room.', {targetName});
@ -106,9 +95,10 @@ function textForMemberEvent(ev) {
senderName,
targetName,
}) + ' ' + reason;
} else {
// sender is not target and made the target leave, if not from invite/ban then this is a kick
} else if (prevContent.membership === "join") {
return _t('%(senderName)s kicked %(targetName)s.', {senderName, targetName}) + ' ' + reason;
} else {
return "";
}
}
}
@ -210,59 +200,30 @@ function textForRelatedGroupsEvent(ev) {
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: `;
text = _t("%(senderDisplayName)s set the server ACLs for this room.", {senderDisplayName});
} else {
text = `${senderDisplayName} changed the server ACLs for this room: `;
text = _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, don't bother showing the diff view */
// If we know for sure everyone is banned, mark the room as obliterated
if (current.allow.length === 0) {
return text + "🎉 All servers are banned from participating! This room can no longer be used.";
return text + " " + _t("🎉 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(" ");
return text;
}
function textForMessageEvent(ev) {
@ -341,14 +302,27 @@ function textForCallHangupEvent(event) {
reason = _t('(not supported by this browser)');
} else if (eventContent.reason) {
if (eventContent.reason === "ice_failed") {
// We couldn't establish a connection at all
reason = _t('(could not connect media)');
} else if (eventContent.reason === "ice_timeout") {
// We established a connection but it died
reason = _t('(connection failed)');
} else if (eventContent.reason === "user_media_failed") {
// The other side couldn't open capture devices
reason = _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)
reason = _t("(an error occurred)");
} else if (eventContent.reason === "invite_timeout") {
reason = _t('(no answer)');
} else if (eventContent.reason === "user hangup") {
} 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)
reason = '';
} else {
reason = _t('(unknown failure: %(reason)s)', {reason: eventContent.reason});
@ -357,6 +331,11 @@ function textForCallHangupEvent(event) {
return _t('%(senderName)s ended the call.', {senderName}) + ' ' + reason;
}
function textForCallRejectEvent(event) {
const senderName = event.sender ? event.sender.name : _t('Someone');
return _t('%(senderName)s declined the call.', {senderName});
}
function textForCallInviteEvent(event) {
const senderName = event.sender ? event.sender.name : _t('Someone');
// FIXME: Find a better way to determine this from the event?
@ -478,7 +457,7 @@ function textForWidgetEvent(event) {
let widgetName = name || prevName || type || prevType || '';
// Apply sentence case to widget name
if (widgetName && widgetName.length > 0) {
widgetName = widgetName[0].toUpperCase() + widgetName.slice(1) + ' ';
widgetName = widgetName[0].toUpperCase() + widgetName.slice(1);
}
// If the widget was removed, its content should be {}, but this is sufficiently
@ -500,6 +479,11 @@ function textForWidgetEvent(event) {
}
}
function textForWidgetLayoutEvent(event) {
const senderName = event.sender?.name || event.getSender();
return _t("%(senderName)s has updated the widget layout", {senderName});
}
function textForMjolnirEvent(event) {
const senderName = event.getSender();
const {entity: prevEntity} = event.getPrevContent();
@ -563,17 +547,23 @@ function textForMjolnirEvent(event) {
// 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 " +
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});
{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 " +
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});
{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 " +
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});
{senderName, oldGlob: prevEntity, newGlob: entity, reason},
);
}
// Unknown type. We'll say something but we shouldn't end up here.
@ -586,6 +576,7 @@ const handlers = {
'm.call.invite': textForCallInviteEvent,
'm.call.answer': textForCallAnswerEvent,
'm.call.hangup': textForCallHangupEvent,
'm.call.reject': textForCallRejectEvent,
};
const stateHandlers = {
@ -605,6 +596,7 @@ const stateHandlers = {
// 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

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

@ -16,12 +16,14 @@ limitations under the License.
import {MatrixClientPeg} from "./MatrixClientPeg";
import shouldHideEvent from './shouldHideEvent';
import * as sdk from "./index";
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) {
@ -38,12 +40,14 @@ export function eventTriggersUnreadCount(ev) {
return false;
} else if (ev.getType() == 'm.room.server_acl') {
return false;
} else if (ev.isRedacted()) {
return false;
}
return haveTileForEvent(ev);
}
export function doesRoomHaveUnreadMessages(room) {
const myUserId = MatrixClientPeg.get().credentials.userId;
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"),

View file

@ -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) {
@ -195,25 +192,25 @@ export default class UserActivity {
}
dis.dispatch({action: 'user_activity'});
if (!this._activeNowTimeout.isRunning()) {
this._activeNowTimeout.start();
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,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 Element specific code, but will be removed shortly when we
// switch over to Jitsi entirely for video conferencing.
// FIXME: This currently forces Element 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);
};

116
src/VoipUserMapper.ts Normal file
View file

@ -0,0 +1,116 @@
/*
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 {
private virtualRoomIdCache = new Set<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) 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,
});
return virtualRoomId;
}
public nativeRoomForVirtualRoom(roomId: string): string {
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.virtualRoomIdCache.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) {
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 true;
}
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.virtualRoomIdCache.add(invitedRoom.roomId);
}
}
}

View file

@ -14,19 +14,18 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
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,20 +52,21 @@ 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});
}
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,

View file

@ -1,205 +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 {
/**
* @param {string} widgetId The widget's ID
* @param {string} wurl The raw URL of the widget as in the event (the 'wURL')
* @param {string} renderedUrl The url used in the widget's iframe (either similar to the wURL
* or a different URL of the clients choosing if it is using its own impl).
* @param {bool} isUserWidget If true, the widget is a user widget, otherwise it's a room widget
* @param {object} target Where widget messages should be sent (eg. the iframe object)
*/
constructor(widgetId, wurl, renderedUrl, isUserWidget, target) {
this.widgetId = widgetId;
this.wurl = wurl;
this.renderedUrl = renderedUrl;
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 Element).
// 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,
});
}
/**
* Tells the widget that it should terminate now.
* @returns {Promise<*>} Resolves when widget has acknowledged the message.
*/
terminate() {
return this.messageToWidget({
api: OUTBOUND_API_NAME,
action: KnownWidgetActions.Terminate,
});
}
/**
* 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.renderedUrl);
this.fromWidget.addListener("get_openid", this._onOpenIdRequest);
}
stop() {
this.fromWidget.removeEndpoint(this.widgetId, this.renderedUrl);
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.wurl, 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.wurl,
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

@ -168,7 +168,13 @@ const shortcuts: Record<Categories, IShortcut[]> = {
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]: [
@ -257,6 +263,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"),
},
],

View file

@ -166,7 +166,8 @@ export const RovingTabIndexProvider: React.FC<IProps> = ({children, handleHomeEn
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:
@ -190,7 +191,7 @@ export const RovingTabIndexProvider: React.FC<IProps> = ({children, handleHomeEn
ev.preventDefault();
ev.stopPropagation();
} else if (onKeyDown) {
return onKeyDown(ev, state);
return onKeyDown(ev, context.state);
}
}, [context.state, onKeyDown, handleHomeEnd]);
@ -204,7 +205,7 @@ export const RovingTabIndexProvider: React.FC<IProps> = ({children, handleHomeEn
// 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: Ref): [FocusHandler, boolean, Ref] => {
export const useRovingTabIndex = (inputRef?: Ref): [FocusHandler, boolean, Ref] => {
const context = useContext(RovingTabIndexContext);
let ref = useRef<HTMLElement>(null);

View file

@ -28,8 +28,12 @@ interface IProps extends Omit<React.HTMLProps<HTMLDivElement>, "onKeyDown"> {
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:
@ -47,8 +51,6 @@ const Toolbar: React.FC<IProps> = ({children, ...props}) => {
}
break;
// HOME and END are handled by RovingTabIndexProvider
default:
handled = false;
}

View file

@ -20,7 +20,7 @@ import React from "react";
import AccessibleTooltipButton from "../../components/views/elements/AccessibleTooltipButton";
interface IProps extends React.ComponentProps<typeof AccessibleTooltipButton> {
interface IProps extends React.ComponentProps<typeof AccessibleTooltipButton> {
// whether or not the context menu is currently open
isExpanded: boolean;
}

View file

@ -19,15 +19,25 @@ 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, ...props}) => {
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={label}>
<AccessibleButton {...props} role="menuitem" tabIndex={-1} aria-label={ariaLabel}>
{ children }
</AccessibleButton>
);

View file

@ -20,7 +20,8 @@ import AccessibleTooltipButton from "../../components/views/elements/AccessibleT
import {useRovingTabIndex} from "../RovingTabIndex";
import {Ref} from "./types";
interface IProps extends Omit<React.ComponentProps<typeof AccessibleTooltipButton>, "onFocus" | "inputRef" | "tabIndex"> {
type ATBProps = React.ComponentProps<typeof AccessibleTooltipButton>;
interface IProps extends Omit<ATBProps, "onFocus" | "inputRef" | "tabIndex"> {
inputRef?: Ref;
}

View file

@ -16,7 +16,6 @@ limitations under the License.
import React from "react";
import AccessibleButton from "../../components/views/elements/AccessibleButton";
import {useRovingTabIndex} from "../RovingTabIndex";
import {FocusHandler, Ref} from "./types";

View file

@ -17,14 +17,14 @@ limitations under the License.
import Analytics from '../Analytics';
import { asyncAction } from './actionCreators';
import TagOrderStore from '../stores/TagOrderStore';
import GroupFilterOrderStore from '../stores/GroupFilterOrderStore';
import { AsyncActionPayload } from "../dispatcher/payloads";
import { MatrixClient } from "matrix-js-sdk/src/client";
export default class TagOrderActions {
/**
* Creates an action thunk that will do an asynchronous request to
* move a tag in TagOrderStore to destinationIx.
* move a tag in GroupFilterOrderStore to destinationIx.
*
* @param {MatrixClient} matrixClient the matrix client to set the
* account data on.
@ -36,8 +36,8 @@ export default class TagOrderActions {
*/
public static moveTag(matrixClient: MatrixClient, tag: string, destinationIx: number): AsyncActionPayload {
// Only commit tags if the state is ready, i.e. not null
let tags = TagOrderStore.getOrderedTags();
let removedTags = TagOrderStore.getRemovedTagsAccountData() || [];
let tags = GroupFilterOrderStore.getOrderedTags();
let removedTags = GroupFilterOrderStore.getRemovedTagsAccountData() || [];
if (!tags) {
return;
}
@ -47,7 +47,7 @@ export default class TagOrderActions {
removedTags = removedTags.filter((t) => t !== tag);
const storeId = TagOrderStore.getStoreId();
const storeId = GroupFilterOrderStore.getStoreId();
return asyncAction('TagOrderActions.moveTag', () => {
Analytics.trackEvent('TagOrderActions', 'commitTagOrdering');
@ -83,8 +83,8 @@ export default class TagOrderActions {
*/
public static removeTag(matrixClient: MatrixClient, tag: string): AsyncActionPayload {
// Don't change tags, just removedTags
const tags = TagOrderStore.getOrderedTags();
const removedTags = TagOrderStore.getRemovedTagsAccountData() || [];
const tags = GroupFilterOrderStore.getOrderedTags();
const removedTags = GroupFilterOrderStore.getRemovedTagsAccountData() || [];
if (removedTags.includes(tag)) {
// Return a thunk that doesn't do anything, we don't even need
@ -94,7 +94,7 @@ export default class TagOrderActions {
removedTags.push(tag);
const storeId = TagOrderStore.getStoreId();
const storeId = GroupFilterOrderStore.getStoreId();
return asyncAction('TagOrderActions.removeTag', () => {
Analytics.trackEvent('TagOrderActions', 'removeTag');

View file

@ -1,5 +1,5 @@
/*
Copyright 2020 The Matrix.org Foundation C.I.C.
Copyright 2020-2021 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
@ -16,7 +16,6 @@ limitations under the License.
import React from 'react';
import * as sdk from '../../../../index';
import PropTypes from 'prop-types';
import { _t } from '../../../../languageHandler';
import SdkConfig from '../../../../SdkConfig';
import SettingsStore from "../../../../settings/SettingsStore";
@ -26,14 +25,23 @@ import {formatBytes, formatCountLong} from "../../../../utils/FormattingUtils";
import EventIndexPeg from "../../../../indexing/EventIndexPeg";
import {SettingLevel} from "../../../../settings/SettingLevel";
interface IProps {
onFinished: (confirmed: boolean) => void;
}
interface IState {
eventIndexSize: number;
eventCount: number;
crawlingRoomsCount: number;
roomCount: number;
currentRoom: string;
crawlerSleepTime: number;
}
/*
* Allows the user to introspect the event index state and disable it.
*/
export default class ManageEventIndexDialog extends React.Component {
static propTypes = {
onFinished: PropTypes.func.isRequired,
};
export default class ManageEventIndexDialog extends React.Component<IProps, IState> {
constructor(props) {
super(props);
@ -84,7 +92,7 @@ export default class ManageEventIndexDialog extends React.Component {
}
}
async componentDidMount(): void {
async componentDidMount(): Promise<void> {
let eventIndexSize = 0;
let crawlingRoomsCount = 0;
let roomCount = 0;
@ -123,14 +131,14 @@ export default class ManageEventIndexDialog extends React.Component {
});
}
_onDisable = async () => {
private onDisable = async () => {
Modal.createTrackedDialogAsync("Disable message search", "Disable message search",
import("./DisableEventIndexDialog"),
null, null, /* priority = */ false, /* static = */ true,
);
};
_onCrawlerSleepTimeChange = (e) => {
private onCrawlerSleepTimeChange = (e) => {
this.setState({crawlerSleepTime: e.target.value});
SettingsStore.setValue("crawlerSleepTime", null, SettingLevel.DEVICE, e.target.value);
};
@ -144,7 +152,7 @@ export default class ManageEventIndexDialog extends React.Component {
crawlerState = _t("Not currently indexing messages for any room.");
} else {
crawlerState = (
_t("Currently indexing: %(currentRoom)s", { currentRoom: this.state.currentRoom })
_t("Currently indexing: %(currentRoom)s", { currentRoom: this.state.currentRoom })
);
}
@ -169,7 +177,7 @@ export default class ManageEventIndexDialog extends React.Component {
label={_t('Message downloading sleep time(ms)')}
type='number'
value={this.state.crawlerSleepTime}
onChange={this._onCrawlerSleepTimeChange} />
onChange={this.onCrawlerSleepTimeChange} />
</div>
</div>
);
@ -188,7 +196,7 @@ export default class ManageEventIndexDialog extends React.Component {
onPrimaryButtonClick={this.props.onFinished}
primaryButtonClass="primary"
cancelButton={_t("Disable")}
onCancel={this._onDisable}
onCancel={this.onDisable}
cancelButtonClass="danger"
/>
</BaseDialog>

View file

@ -1,70 +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 React from "react";
import PropTypes from "prop-types";
import * as sdk from "../../../../index";
import { _t } from "../../../../languageHandler";
export default class IgnoreRecoveryReminderDialog extends React.PureComponent {
static propTypes = {
onDontAskAgain: PropTypes.func.isRequired,
onFinished: PropTypes.func.isRequired,
onSetup: PropTypes.func.isRequired,
}
onDontAskAgainClick = () => {
this.props.onFinished();
this.props.onDontAskAgain();
}
onSetupClick = () => {
this.props.onFinished();
this.props.onSetup();
}
render() {
const BaseDialog = sdk.getComponent("views.dialogs.BaseDialog");
const DialogButtons = sdk.getComponent("views.elements.DialogButtons");
return (
<BaseDialog className="mx_IgnoreRecoveryReminderDialog"
onFinished={this.props.onFinished}
title={_t("Are you sure?")}
>
<div>
<p>{_t(
"Without setting up Secure Message Recovery, " +
"you'll lose your secure message history when you " +
"log out.",
)}</p>
<p>{_t(
"If you don't want to set this up now, you can later " +
"in Settings.",
)}</p>
<div className="mx_Dialog_buttons">
<DialogButtons
primaryButton={_t("Set up")}
onPrimaryButtonClick={this.onSetupClick}
cancelButton={_t("Don't ask again")}
onCancel={this.onDontAskAgainClick}
/>
</div>
</div>
</BaseDialog>
);
}
}

View file

@ -21,7 +21,7 @@ import * as sdk from '../../../../index';
import {MatrixClientPeg} from '../../../../MatrixClientPeg';
import PropTypes from 'prop-types';
import {_t, _td} from '../../../../languageHandler';
import { accessSecretStorage } from '../../../../CrossSigningManager';
import { accessSecretStorage } from '../../../../SecurityManager';
import AccessibleButton from "../../../../components/views/elements/AccessibleButton";
import {copyNode} from "../../../../utils/strings";
import PassphraseField from "../../../../components/views/auth/PassphraseField";
@ -95,7 +95,7 @@ export default class CreateKeyBackupDialog extends React.PureComponent {
const blob = new Blob([this._keyBackupInfo.recovery_key], {
type: 'text/plain;charset=us-ascii',
});
FileSaver.saveAs(blob, 'recovery-key.txt');
FileSaver.saveAs(blob, 'security-key.txt');
this.setState({
downloaded: true,
@ -238,7 +238,7 @@ export default class CreateKeyBackupDialog extends React.PureComponent {
)}</p>
<p>{_t(
"We'll store an encrypted copy of your keys on our server. " +
"Secure your backup with a recovery passphrase.",
"Secure your backup with a Security Phrase.",
)}</p>
<p>{_t("For maximum security, this should be different from your account password.")}</p>
@ -252,10 +252,10 @@ export default class CreateKeyBackupDialog extends React.PureComponent {
onValidate={this._onPassPhraseValidate}
fieldRef={this._passphraseField}
autoFocus={true}
label={_td("Enter a recovery passphrase")}
labelEnterPassword={_td("Enter a recovery passphrase")}
labelStrongPassword={_td("Great! This recovery passphrase looks strong enough.")}
labelAllowedButUnsafe={_td("Great! This recovery passphrase looks strong enough.")}
label={_td("Enter a Security Phrase")}
labelEnterPassword={_td("Enter a Security Phrase")}
labelStrongPassword={_td("Great! This Security Phrase looks strong enough.")}
labelAllowedButUnsafe={_td("Great! This Security Phrase looks strong enough.")}
/>
</div>
</div>
@ -270,7 +270,7 @@ export default class CreateKeyBackupDialog extends React.PureComponent {
<details>
<summary>{_t("Advanced")}</summary>
<AccessibleButton kind='primary' onClick={this._onSkipPassPhraseClick} >
{_t("Set up with a recovery key")}
{_t("Set up with a Security Key")}
</AccessibleButton>
</details>
</form>;
@ -310,7 +310,7 @@ export default class CreateKeyBackupDialog extends React.PureComponent {
const DialogButtons = sdk.getComponent('views.elements.DialogButtons');
return <form onSubmit={this._onPassPhraseConfirmNextClick}>
<p>{_t(
"Please enter your recovery passphrase a second time to confirm.",
"Enter your Security Phrase a second time to confirm it.",
)}</p>
<div className="mx_CreateKeyBackupDialog_primaryContainer">
<div className="mx_CreateKeyBackupDialog_passPhraseContainer">
@ -319,7 +319,7 @@ export default class CreateKeyBackupDialog extends React.PureComponent {
onChange={this._onPassPhraseConfirmChange}
value={this.state.passPhraseConfirm}
className="mx_CreateKeyBackupDialog_passPhraseInput"
placeholder={_t("Repeat your recovery passphrase...")}
placeholder={_t("Repeat your Security Phrase...")}
autoFocus={true}
/>
</div>
@ -338,15 +338,15 @@ export default class CreateKeyBackupDialog extends React.PureComponent {
_renderPhaseShowKey() {
return <div>
<p>{_t(
"Your recovery key is a safety net - you can use it to restore " +
"access to your encrypted messages if you forget your recovery passphrase.",
"Your Security Key is a safety net - you can use it to restore " +
"access to your encrypted messages if you forget your Security Phrase.",
)}</p>
<p>{_t(
"Keep a copy of it somewhere secure, like a password manager or even a safe.",
)}</p>
<div className="mx_CreateKeyBackupDialog_primaryContainer">
<div className="mx_CreateKeyBackupDialog_recoveryKeyHeader">
{_t("Your recovery key")}
{_t("Your Security Key")}
</div>
<div className="mx_CreateKeyBackupDialog_recoveryKeyContainer">
<div className="mx_CreateKeyBackupDialog_recoveryKey">
@ -369,12 +369,12 @@ export default class CreateKeyBackupDialog extends React.PureComponent {
let introText;
if (this.state.copied) {
introText = _t(
"Your recovery key has been <b>copied to your clipboard</b>, paste it to:",
"Your Security Key has been <b>copied to your clipboard</b>, paste it to:",
{}, {b: s => <b>{s}</b>},
);
} else if (this.state.downloaded) {
introText = _t(
"Your recovery key is in your <b>Downloads</b> folder.",
"Your Security Key is in your <b>Downloads</b> folder.",
{}, {b: s => <b>{s}</b>},
);
}
@ -433,14 +433,14 @@ export default class CreateKeyBackupDialog extends React.PureComponent {
_titleForPhase(phase) {
switch (phase) {
case PHASE_PASSPHRASE:
return _t('Secure your backup with a recovery passphrase');
return _t('Secure your backup with a Security Phrase');
case PHASE_PASSPHRASE_CONFIRM:
return _t('Confirm your recovery passphrase');
return _t('Confirm your Security Phrase');
case PHASE_OPTOUT_CONFIRM:
return _t('Warning!');
case PHASE_SHOWKEY:
case PHASE_KEEPITSAFE:
return _t('Make a copy of your recovery key');
return _t('Make a copy of your Security Key');
case PHASE_BACKINGUP:
return _t('Starting backup...');
case PHASE_DONE:
@ -498,9 +498,9 @@ export default class CreateKeyBackupDialog extends React.PureComponent {
title={this._titleForPhase(this.state.phase)}
hasCancel={[PHASE_PASSPHRASE, PHASE_DONE].includes(this.state.phase)}
>
<div>
{content}
</div>
<div>
{content}
</div>
</BaseDialog>
);
}

View file

@ -22,7 +22,7 @@ import {MatrixClientPeg} from '../../../../MatrixClientPeg';
import FileSaver from 'file-saver';
import {_t, _td} from '../../../../languageHandler';
import Modal from '../../../../Modal';
import { promptForBackupPassphrase } from '../../../../CrossSigningManager';
import { promptForBackupPassphrase } from '../../../../SecurityManager';
import {copyNode} from "../../../../utils/strings";
import {SSOAuthEntry} from "../../../../components/views/auth/InteractiveAuthEntryComponents";
import PassphraseField from "../../../../components/views/auth/PassphraseField";
@ -30,6 +30,9 @@ import StyledRadioButton from '../../../../components/views/elements/StyledRadio
import AccessibleButton from "../../../../components/views/elements/AccessibleButton";
import DialogButtons from "../../../../components/views/elements/DialogButtons";
import InlineSpinner from "../../../../components/views/elements/InlineSpinner";
import RestoreKeyBackupDialog from "../../../../components/views/dialogs/security/RestoreKeyBackupDialog";
import { getSecureBackupSetupMethods, isSecureBackupRequired } from '../../../../utils/WellKnownUtils';
import SecurityCustomisations from "../../../../customisations/Security";
const PHASE_LOADING = 0;
const PHASE_LOADERROR = 1;
@ -55,12 +58,12 @@ export default class CreateSecretStorageDialog extends React.PureComponent {
static propTypes = {
hasCancel: PropTypes.bool,
accountPassword: PropTypes.string,
force: PropTypes.bool,
forceReset: PropTypes.bool,
};
static defaultProps = {
hasCancel: true,
force: false,
forceReset: false,
};
constructor(props) {
@ -85,13 +88,20 @@ export default class CreateSecretStorageDialog extends React.PureComponent {
canUploadKeysWithPasswordOnly: null,
accountPassword: props.accountPassword || "",
accountPasswordCorrect: null,
passPhraseKeySelected: CREATE_STORAGE_OPTION_KEY,
canSkip: !isSecureBackupRequired(),
};
const setupMethods = getSecureBackupSetupMethods();
if (setupMethods.includes("key")) {
this.state.passPhraseKeySelected = CREATE_STORAGE_OPTION_KEY;
} else {
this.state.passPhraseKeySelected = CREATE_STORAGE_OPTION_PASSPHRASE;
}
this._passphraseField = createRef();
this._fetchBackupInfo();
MatrixClientPeg.get().on('crypto.keyBackupStatus', this._onKeyBackupStatusChange);
if (this.state.accountPassword) {
// If we have an account password in memory, let's simplify and
// assume it means password auth is also supported for device
@ -102,13 +112,27 @@ export default class CreateSecretStorageDialog extends React.PureComponent {
this._queryKeyUploadAuth();
}
MatrixClientPeg.get().on('crypto.keyBackupStatus', this._onKeyBackupStatusChange);
this._getInitialPhase();
}
componentWillUnmount() {
MatrixClientPeg.get().removeListener('crypto.keyBackupStatus', this._onKeyBackupStatusChange);
}
_getInitialPhase() {
const keyFromCustomisations = SecurityCustomisations.createSecretStorageKey?.();
if (keyFromCustomisations) {
console.log("Created key via customisations, jumping to bootstrap step");
this._recoveryKey = {
privateKey: keyFromCustomisations,
};
this._bootstrapSecretStorage();
return;
}
this._fetchBackupInfo();
}
async _fetchBackupInfo() {
try {
const backupInfo = await MatrixClientPeg.get().getKeyBackupVersion();
@ -117,8 +141,8 @@ export default class CreateSecretStorageDialog extends React.PureComponent {
MatrixClientPeg.get().isCryptoEnabled() && await MatrixClientPeg.get().isKeyBackupTrusted(backupInfo)
);
const { force } = this.props;
const phase = (backupInfo && !force) ? PHASE_MIGRATE : PHASE_CHOOSE_KEY_PASSPHRASE;
const { forceReset } = this.props;
const phase = (backupInfo && !forceReset) ? PHASE_MIGRATE : PHASE_CHOOSE_KEY_PASSPHRASE;
this.setState({
phase,
@ -211,7 +235,7 @@ export default class CreateSecretStorageDialog extends React.PureComponent {
const blob = new Blob([this._recoveryKey.encodedPrivateKey], {
type: 'text/plain;charset=us-ascii',
});
FileSaver.saveAs(blob, 'recovery-key.txt');
FileSaver.saveAs(blob, 'security-key.txt');
this.setState({
downloaded: true,
@ -276,20 +300,28 @@ export default class CreateSecretStorageDialog extends React.PureComponent {
const cli = MatrixClientPeg.get();
const { force } = this.props;
const { forceReset } = this.props;
try {
if (force) {
console.log("Forcing secret storage reset"); // log something so we can debug this later
if (forceReset) {
console.log("Forcing secret storage reset");
await cli.bootstrapSecretStorage({
authUploadDeviceSigningKeys: this._doBootstrapUIAuth,
createSecretStorageKey: async () => this._recoveryKey,
setupNewKeyBackup: true,
setupNewSecretStorage: true,
});
} else {
await cli.bootstrapSecretStorage({
// For password authentication users after 2020-09, this cross-signing
// step will be a no-op since it is now setup during registration or login
// when needed. We should keep this here to cover other cases such as:
// * Users with existing sessions prior to 2020-09 changes
// * SSO authentication users which require interactive auth to upload
// keys (and also happen to skip all post-authentication flows at the
// moment via token login)
await cli.bootstrapCrossSigning({
authUploadDeviceSigningKeys: this._doBootstrapUIAuth,
});
await cli.bootstrapSecretStorage({
createSecretStorageKey: async () => this._recoveryKey,
keyBackupInfo: this.state.backupInfo,
setupNewKeyBackup: !this.state.backupInfo,
@ -332,7 +364,6 @@ export default class CreateSecretStorageDialog extends React.PureComponent {
// so let's stash it here, rather than prompting for it twice.
const keyCallback = k => this._backupKey = k;
const RestoreKeyBackupDialog = sdk.getComponent('dialogs.keybackup.RestoreKeyBackupDialog');
const { finished } = Modal.createTrackedDialog(
'Restore Backup', '', RestoreKeyBackupDialog,
{
@ -432,45 +463,63 @@ export default class CreateSecretStorageDialog extends React.PureComponent {
});
}
_renderOptionKey() {
return (
<StyledRadioButton
key={CREATE_STORAGE_OPTION_KEY}
value={CREATE_STORAGE_OPTION_KEY}
name="keyPassphrase"
checked={this.state.passPhraseKeySelected === CREATE_STORAGE_OPTION_KEY}
onChange={this._onKeyPassphraseChange}
outlined
>
<div className="mx_CreateSecretStorageDialog_optionTitle">
<span className="mx_CreateSecretStorageDialog_optionIcon mx_CreateSecretStorageDialog_optionIcon_secureBackup"></span>
{_t("Generate a Security Key")}
</div>
<div>{_t("Well generate a Security Key for you to store somewhere safe, like a password manager or a safe.")}</div>
</StyledRadioButton>
);
}
_renderOptionPassphrase() {
return (
<StyledRadioButton
key={CREATE_STORAGE_OPTION_PASSPHRASE}
value={CREATE_STORAGE_OPTION_PASSPHRASE}
name="keyPassphrase"
checked={this.state.passPhraseKeySelected === CREATE_STORAGE_OPTION_PASSPHRASE}
onChange={this._onKeyPassphraseChange}
outlined
>
<div className="mx_CreateSecretStorageDialog_optionTitle">
<span className="mx_CreateSecretStorageDialog_optionIcon mx_CreateSecretStorageDialog_optionIcon_securePhrase"></span>
{_t("Enter a Security Phrase")}
</div>
<div>{_t("Use a secret phrase only you know, and optionally save a Security Key to use for backup.")}</div>
</StyledRadioButton>
);
}
_renderPhaseChooseKeyPassphrase() {
const setupMethods = getSecureBackupSetupMethods();
const optionKey = setupMethods.includes("key") ? this._renderOptionKey() : null;
const optionPassphrase = setupMethods.includes("passphrase") ? this._renderOptionPassphrase() : null;
return <form onSubmit={this._onChooseKeyPassphraseFormSubmit}>
<p className="mx_CreateSecretStorageDialog_centeredBody">{_t(
"Safeguard against losing access to encrypted messages & data by " +
"backing up encryption keys on your server.",
)}</p>
<div className="mx_CreateSecretStorageDialog_primaryContainer" role="radiogroup" onChange={this._onKeyPassphraseChange}>
<StyledRadioButton
key={CREATE_STORAGE_OPTION_KEY}
value={CREATE_STORAGE_OPTION_KEY}
name="keyPassphrase"
checked={this.state.passPhraseKeySelected === CREATE_STORAGE_OPTION_KEY}
outlined
>
<div className="mx_CreateSecretStorageDialog_optionTitle">
<span className="mx_CreateSecretStorageDialog_optionIcon mx_CreateSecretStorageDialog_optionIcon_secureBackup"></span>
{_t("Generate a Security Key")}
</div>
<div>{_t("Well generate a Security Key for you to store somewhere safe, like a password manager or a safe.")}</div>
</StyledRadioButton>
<StyledRadioButton
key={CREATE_STORAGE_OPTION_PASSPHRASE}
value={CREATE_STORAGE_OPTION_PASSPHRASE}
name="keyPassphrase"
checked={this.state.passPhraseKeySelected === CREATE_STORAGE_OPTION_PASSPHRASE}
outlined
>
<div className="mx_CreateSecretStorageDialog_optionTitle">
<span className="mx_CreateSecretStorageDialog_optionIcon mx_CreateSecretStorageDialog_optionIcon_securePhrase"></span>
{_t("Enter a Security Phrase")}
</div>
<div>{_t("Use a secret phrase only you know, and optionally save a Security Key to use for backup.")}</div>
</StyledRadioButton>
<div className="mx_CreateSecretStorageDialog_primaryContainer" role="radiogroup">
{optionKey}
{optionPassphrase}
</div>
<DialogButtons
primaryButton={_t("Continue")}
onPrimaryButtonClick={this._onChooseKeyPassphraseFormSubmit}
onCancel={this._onCancelClick}
hasCancel={true}
hasCancel={this.state.canSkip}
/>
</form>;
}
@ -544,10 +593,10 @@ export default class CreateSecretStorageDialog extends React.PureComponent {
onValidate={this._onPassPhraseValidate}
fieldRef={this._passphraseField}
autoFocus={true}
label={_td("Enter a recovery passphrase")}
labelEnterPassword={_td("Enter a recovery passphrase")}
labelStrongPassword={_td("Great! This recovery passphrase looks strong enough.")}
labelAllowedButUnsafe={_td("Great! This recovery passphrase looks strong enough.")}
label={_td("Enter a Security Phrase")}
labelEnterPassword={_td("Enter a Security Phrase")}
labelStrongPassword={_td("Great! This Security Phrase looks strong enough.")}
labelAllowedButUnsafe={_td("Great! This Security Phrase looks strong enough.")}
/>
</div>
@ -598,7 +647,7 @@ export default class CreateSecretStorageDialog extends React.PureComponent {
}
return <form onSubmit={this._onPassPhraseConfirmNextClick}>
<p>{_t(
"Enter your recovery passphrase a second time to confirm it.",
"Enter your Security Phrase a second time to confirm it.",
)}</p>
<div className="mx_CreateSecretStorageDialog_passPhraseContainer">
<Field
@ -606,7 +655,7 @@ export default class CreateSecretStorageDialog extends React.PureComponent {
onChange={this._onPassPhraseConfirmChange}
value={this.state.passPhraseConfirm}
className="mx_CreateSecretStorageDialog_passPhraseField"
label={_t("Confirm your recovery passphrase")}
label={_t("Confirm your Security Phrase")}
autoFocus={true}
autoComplete="new-password"
/>
@ -687,7 +736,7 @@ export default class CreateSecretStorageDialog extends React.PureComponent {
<div className="mx_Dialog_buttons">
<DialogButtons primaryButton={_t('Retry')}
onPrimaryButtonClick={this._onLoadRetryClick}
hasCancel={true}
hasCancel={this.state.canSkip}
onCancel={this._onCancel}
/>
</div>
@ -714,7 +763,7 @@ export default class CreateSecretStorageDialog extends React.PureComponent {
_titleForPhase(phase) {
switch (phase) {
case PHASE_CHOOSE_KEY_PASSPHRASE:
return _t('Set up Secure backup');
return _t('Set up Secure Backup');
case PHASE_MIGRATE:
return _t('Upgrade your encryption');
case PHASE_PASSPHRASE:
@ -742,7 +791,7 @@ export default class CreateSecretStorageDialog extends React.PureComponent {
<div className="mx_Dialog_buttons">
<DialogButtons primaryButton={_t('Retry')}
onPrimaryButtonClick={this._bootstrapSecretStorage}
hasCancel={true}
hasCancel={this.state.canSkip}
onCancel={this._onCancel}
/>
</div>
@ -807,9 +856,9 @@ export default class CreateSecretStorageDialog extends React.PureComponent {
hasCancel={this.props.hasCancel && [PHASE_PASSPHRASE].includes(this.state.phase)}
fixedWidth={false}
>
<div>
{content}
</div>
<div>
{content}
</div>
</BaseDialog>
);
}

View file

@ -17,44 +17,40 @@ limitations under the License.
import FileSaver from 'file-saver';
import React, {createRef} from 'react';
import PropTypes from 'prop-types';
import createReactClass from 'create-react-class';
import { _t } from '../../../languageHandler';
import { _t } from '../../../../languageHandler';
import { MatrixClient } from 'matrix-js-sdk';
import * as MegolmExportEncryption from '../../../utils/MegolmExportEncryption';
import * as sdk from '../../../index';
import { MatrixClient } from 'matrix-js-sdk/src/client';
import * as MegolmExportEncryption from '../../../../utils/MegolmExportEncryption';
import * as sdk from '../../../../index';
const PHASE_EDIT = 1;
const PHASE_EXPORTING = 2;
export default createReactClass({
displayName: 'ExportE2eKeysDialog',
propTypes: {
export default class ExportE2eKeysDialog extends React.Component {
static propTypes = {
matrixClient: PropTypes.instanceOf(MatrixClient).isRequired,
onFinished: PropTypes.func.isRequired,
},
};
getInitialState: function() {
return {
phase: PHASE_EDIT,
errStr: null,
};
},
constructor(props) {
super(props);
// TODO: [REACT-WARNING] Replace component with real class, use constructor for refs
UNSAFE_componentWillMount: function() {
this._unmounted = false;
this._passphrase1 = createRef();
this._passphrase2 = createRef();
},
componentWillUnmount: function() {
this.state = {
phase: PHASE_EDIT,
errStr: null,
};
}
componentWillUnmount() {
this._unmounted = true;
},
}
_onPassphraseFormSubmit: function(ev) {
_onPassphraseFormSubmit = (ev) => {
ev.preventDefault();
const passphrase = this._passphrase1.current.value;
@ -69,9 +65,9 @@ export default createReactClass({
this._startExport(passphrase);
return false;
},
};
_startExport: function(passphrase) {
_startExport(passphrase) {
// extra Promise.resolve() to turn synchronous exceptions into
// asynchronous ones.
Promise.resolve().then(() => {
@ -102,15 +98,15 @@ export default createReactClass({
errStr: null,
phase: PHASE_EXPORTING,
});
},
}
_onCancelClick: function(ev) {
_onCancelClick = (ev) => {
ev.preventDefault();
this.props.onFinished(false);
return false;
},
};
render: function() {
render() {
const BaseDialog = sdk.getComponent('views.dialogs.BaseDialog');
const disableForm = (this.state.phase === PHASE_EXPORTING);
@ -174,8 +170,11 @@ export default createReactClass({
</div>
</div>
<div className='mx_Dialog_buttons'>
<input className='mx_Dialog_primary' type='submit' value={_t('Export')}
disabled={disableForm}
<input
className='mx_Dialog_primary'
type='submit'
value={_t('Export')}
disabled={disableForm}
/>
<button onClick={this._onCancelClick} disabled={disableForm}>
{ _t("Cancel") }
@ -184,5 +183,5 @@ export default createReactClass({
</form>
</BaseDialog>
);
},
});
}
}

View file

@ -16,12 +16,11 @@ limitations under the License.
import React, {createRef} from 'react';
import PropTypes from 'prop-types';
import createReactClass from 'create-react-class';
import { MatrixClient } from 'matrix-js-sdk';
import * as MegolmExportEncryption from '../../../utils/MegolmExportEncryption';
import * as sdk from '../../../index';
import { _t } from '../../../languageHandler';
import { MatrixClient } from 'matrix-js-sdk/src/client';
import * as MegolmExportEncryption from '../../../../utils/MegolmExportEncryption';
import * as sdk from '../../../../index';
import { _t } from '../../../../languageHandler';
function readFileAsArrayBuffer(file) {
return new Promise((resolve, reject) => {
@ -38,48 +37,45 @@ function readFileAsArrayBuffer(file) {
const PHASE_EDIT = 1;
const PHASE_IMPORTING = 2;
export default createReactClass({
displayName: 'ImportE2eKeysDialog',
propTypes: {
export default class ImportE2eKeysDialog extends React.Component {
static propTypes = {
matrixClient: PropTypes.instanceOf(MatrixClient).isRequired,
onFinished: PropTypes.func.isRequired,
},
};
getInitialState: function() {
return {
enableSubmit: false,
phase: PHASE_EDIT,
errStr: null,
};
},
constructor(props) {
super(props);
// TODO: [REACT-WARNING] Replace component with real class, use constructor for refs
UNSAFE_componentWillMount: function() {
this._unmounted = false;
this._file = createRef();
this._passphrase = createRef();
},
componentWillUnmount: function() {
this.state = {
enableSubmit: false,
phase: PHASE_EDIT,
errStr: null,
};
}
componentWillUnmount() {
this._unmounted = true;
},
}
_onFormChange: function(ev) {
_onFormChange = (ev) => {
const files = this._file.current.files || [];
this.setState({
enableSubmit: (this._passphrase.current.value !== "" && files.length > 0),
});
},
};
_onFormSubmit: function(ev) {
_onFormSubmit = (ev) => {
ev.preventDefault();
this._startImport(this._file.current.files[0], this._passphrase.current.value);
return false;
},
};
_startImport: function(file, passphrase) {
_startImport(file, passphrase) {
this.setState({
errStr: null,
phase: PHASE_IMPORTING,
@ -105,15 +101,15 @@ export default createReactClass({
phase: PHASE_EDIT,
});
});
},
}
_onCancelClick: function(ev) {
_onCancelClick = (ev) => {
ev.preventDefault();
this.props.onFinished(false);
return false;
},
};
render: function() {
render() {
const BaseDialog = sdk.getComponent('views.dialogs.BaseDialog');
const disableForm = (this.state.phase !== PHASE_EDIT);
@ -144,36 +140,36 @@ export default createReactClass({
</div>
<div className='mx_E2eKeysDialog_inputTable'>
<div className='mx_E2eKeysDialog_inputRow'>
<div className='mx_E2eKeysDialog_inputLabel'>
<label htmlFor='importFile'>
{ _t("File to import") }
</label>
</div>
<div className='mx_E2eKeysDialog_inputCell'>
<input
ref={this._file}
id='importFile'
type='file'
autoFocus={true}
onChange={this._onFormChange}
disabled={disableForm} />
</div>
<div className='mx_E2eKeysDialog_inputLabel'>
<label htmlFor='importFile'>
{ _t("File to import") }
</label>
</div>
<div className='mx_E2eKeysDialog_inputCell'>
<input
ref={this._file}
id='importFile'
type='file'
autoFocus={true}
onChange={this._onFormChange}
disabled={disableForm} />
</div>
</div>
<div className='mx_E2eKeysDialog_inputRow'>
<div className='mx_E2eKeysDialog_inputLabel'>
<label htmlFor='passphrase'>
{ _t("Enter passphrase") }
</label>
</div>
<div className='mx_E2eKeysDialog_inputCell'>
<input
ref={this._passphrase}
id='passphrase'
size='64'
type='password'
onChange={this._onFormChange}
disabled={disableForm} />
</div>
<div className='mx_E2eKeysDialog_inputLabel'>
<label htmlFor='passphrase'>
{ _t("Enter passphrase") }
</label>
</div>
<div className='mx_E2eKeysDialog_inputCell'>
<input
ref={this._passphrase}
id='passphrase'
size='64'
type='password'
onChange={this._onFormChange}
disabled={disableForm} />
</div>
</div>
</div>
</div>
@ -188,5 +184,5 @@ export default createReactClass({
</form>
</BaseDialog>
);
},
});
}
}

View file

@ -22,6 +22,7 @@ import {MatrixClientPeg} from '../../../../MatrixClientPeg';
import dis from "../../../../dispatcher/dispatcher";
import { _t } from "../../../../languageHandler";
import Modal from "../../../../Modal";
import RestoreKeyBackupDialog from "../../../../components/views/dialogs/security/RestoreKeyBackupDialog";
import {Action} from "../../../../dispatcher/actions";
export default class NewRecoveryMethodDialog extends React.PureComponent {
@ -41,7 +42,6 @@ export default class NewRecoveryMethodDialog extends React.PureComponent {
}
onSetupClick = async () => {
const RestoreKeyBackupDialog = sdk.getComponent('dialogs.keybackup.RestoreKeyBackupDialog');
Modal.createTrackedDialog(
'Restore Backup', '', RestoreKeyBackupDialog, {
onFinished: this.props.onFinished,
@ -58,7 +58,7 @@ export default class NewRecoveryMethodDialog extends React.PureComponent {
</span>;
const newMethodDetected = <p>{_t(
"A new recovery passphrase and key for Secure Messages have been detected.",
"A new Security Phrase and key for Secure Messages have been detected.",
)}</p>;
const hackWarning = <p className="warning">{_t(

View file

@ -56,7 +56,7 @@ export default class RecoveryMethodRemovedDialog extends React.PureComponent {
>
<div>
<p>{_t(
"This session has detected that your recovery passphrase and key " +
"This session has detected that your Security Phrase and key " +
"for Secure Messages have been removed.",
)}</p>
<p>{_t(

View file

@ -93,7 +93,12 @@ export default class AutocompleteProvider {
};
}
async getCompletions(query: string, selection: ISelectionRange, force = false): Promise<ICompletion[]> {
async getCompletions(
query: string,
selection: ISelectionRange,
force = false,
limit = -1,
): Promise<ICompletion[]> {
return [];
}

View file

@ -26,6 +26,8 @@ import EmojiProvider from './EmojiProvider';
import NotifProvider from './NotifProvider';
import {timeout} from "../utils/promise";
import AutocompleteProvider, {ICommand} from "./AutocompleteProvider";
import SettingsStore from "../settings/SettingsStore";
import SpaceProvider from "./SpaceProvider";
export interface ISelectionRange {
beginning?: boolean; // whether the selection is in the first block of the editor or not
@ -56,6 +58,11 @@ const PROVIDERS = [
DuckDuckGoProvider,
];
// as the spaces feature is device configurable only, and toggling it refreshes the page, we can do this here
if (SettingsStore.getValue("feature_spaces")) {
PROVIDERS.push(SpaceProvider);
}
// Providers will get rejected if they take longer than this.
const PROVIDER_COMPLETION_TIMEOUT = 3000;
@ -82,15 +89,24 @@ export default class Autocompleter {
});
}
async getCompletions(query: string, selection: ISelectionRange, force = false): Promise<IProviderCompletions[]> {
async getCompletions(
query: string,
selection: ISelectionRange,
force = false,
limit = -1,
): Promise<IProviderCompletions[]> {
/* Note: This intentionally waits for all providers to return,
otherwise, we run into a condition where new completions are displayed
while the user is interacting with the list, which makes it difficult
to predict whether an action will actually do what is intended
*/
// list of results from each provider, each being a list of completions or null if it times out
const completionsList: ICompletion[][] = await Promise.all(this.providers.map(provider => {
return timeout(provider.getCompletions(query, selection, force), null, PROVIDER_COMPLETION_TIMEOUT);
const completionsList: ICompletion[][] = await Promise.all(this.providers.map(async provider => {
return await timeout(
provider.getCompletions(query, selection, force, limit),
null,
PROVIDER_COMPLETION_TIMEOUT,
);
}));
// map then filter to maintain the index for the map-operation, for this.providers to line up

View file

@ -38,7 +38,12 @@ export default class CommandProvider extends AutocompleteProvider {
});
}
async getCompletions(query: string, selection: ISelectionRange, force?: boolean): Promise<ICompletion[]> {
async getCompletions(
query: string,
selection: ISelectionRange,
force?: boolean,
limit = -1,
): Promise<ICompletion[]> {
const {command, range} = this.getCurrentCommand(query, selection);
if (!command) return [];
@ -47,7 +52,7 @@ export default class CommandProvider extends AutocompleteProvider {
if (command[0] !== command[1]) {
// The input looks like a command with arguments, perform exact match
const name = command[1].substr(1); // strip leading `/`
if (CommandMap.has(name)) {
if (CommandMap.has(name) && CommandMap.get(name).isEnabled()) {
// some commands, namely `me` and `ddg` don't suit having the usage shown whilst typing their arguments
if (CommandMap.get(name).hideCompletionAfterSpace) return [];
matches = [CommandMap.get(name)];
@ -55,15 +60,16 @@ export default class CommandProvider extends AutocompleteProvider {
} else {
if (query === '/') {
// If they have just entered `/` show everything
// We exclude the limit on purpose to have a comprehensive list
matches = Commands;
} else {
// otherwise fuzzy match against all of the fields
matches = this.matcher.match(command[1]);
matches = this.matcher.match(command[1], limit);
}
}
return matches.map((result) => {
return matches.filter(cmd => cmd.isEnabled()).map((result) => {
let completion = result.getCommand() + ' ';
const usedAlias = result.aliases.find(alias => `/${alias}` === command[1]);
// If the command (or an alias) is the same as the one they entered, we don't want to discard their arguments
@ -89,7 +95,11 @@ export default class CommandProvider extends AutocompleteProvider {
renderCompletions(completions: React.ReactNode[]): React.ReactNode {
return (
<div className="mx_Autocomplete_Completion_container_block" role="listbox" aria-label={_t("Command Autocomplete")}>
<div
className="mx_Autocomplete_Completion_container_block"
role="listbox"
aria-label={_t("Command Autocomplete")}
>
{ completions }
</div>
);

View file

@ -23,10 +23,11 @@ import {MatrixClientPeg} from '../MatrixClientPeg';
import QueryMatcher from './QueryMatcher';
import {PillCompletion} from './Components';
import * as sdk from '../index';
import _sortBy from 'lodash/sortBy';
import {sortBy} from "lodash";
import {makeGroupPermalink} from "../utils/permalinks/Permalinks";
import {ICompletion, ISelectionRange} from "./Autocompleter";
import FlairStore from "../stores/FlairStore";
import {mediaFromMxc} from "../customisations/Media";
const COMMUNITY_REGEX = /\B\+\S*/g;
@ -49,7 +50,12 @@ export default class CommunityProvider extends AutocompleteProvider {
});
}
async getCompletions(query: string, selection: ISelectionRange, force = false): Promise<ICompletion[]> {
async getCompletions(
query: string,
selection: ISelectionRange,
force = false,
limit = -1,
): Promise<ICompletion[]> {
const BaseAvatar = sdk.getComponent('views.avatars.BaseAvatar');
// Disable autocompletions when composing commands because of various issues
@ -80,8 +86,8 @@ export default class CommunityProvider extends AutocompleteProvider {
this.matcher.setObjects(groups);
const matchedString = command[0];
completions = this.matcher.match(matchedString);
completions = _sortBy(completions, [
completions = this.matcher.match(matchedString, limit);
completions = sortBy(completions, [
(c) => score(matchedString, c.groupId),
(c) => c.groupId.length,
]).map(({avatarUrl, groupId, name}) => ({
@ -91,15 +97,15 @@ export default class CommunityProvider extends AutocompleteProvider {
href: makeGroupPermalink(groupId),
component: (
<PillCompletion title={name} description={groupId}>
<BaseAvatar name={name || groupId}
width={24}
height={24}
url={avatarUrl ? cli.mxcUrlToHttp(avatarUrl, 24, 24) : null} />
<BaseAvatar
name={name || groupId}
width={24}
height={24}
url={avatarUrl ? mediaFromMxc(avatarUrl).getSquareThumbnailHttp(24) : null} />
</PillCompletion>
),
range,
}))
.slice(0, 4);
})).slice(0, 4);
}
return completions;
}

View file

@ -34,9 +34,9 @@ export const TextualCompletion = forwardRef<ITextualCompletionProps, any>((props
const {title, subtitle, description, className, ...restProps} = props;
return (
<div {...restProps}
className={classNames('mx_Autocomplete_Completion_block', className)}
role="option"
ref={ref}
className={classNames('mx_Autocomplete_Completion_block', className)}
role="option"
ref={ref}
>
<span className="mx_Autocomplete_Completion_title">{ title }</span>
<span className="mx_Autocomplete_Completion_subtitle">{ subtitle }</span>
@ -53,9 +53,9 @@ export const PillCompletion = forwardRef<IPillCompletionProps, any>((props, ref)
const {title, subtitle, description, className, children, ...restProps} = props;
return (
<div {...restProps}
className={classNames('mx_Autocomplete_Completion_pill', className)}
role="option"
ref={ref}
className={classNames('mx_Autocomplete_Completion_pill', className)}
role="option"
ref={ref}
>
{ children }
<span className="mx_Autocomplete_Completion_title">{ title }</span>

View file

@ -36,7 +36,12 @@ export default class DuckDuckGoProvider extends AutocompleteProvider {
+ `&format=json&no_redirect=1&no_html=1&t=${encodeURIComponent(REFERRER)}`;
}
async getCompletions(query: string, selection: ISelectionRange, force= false): Promise<ICompletion[]> {
async getCompletions(
query: string,
selection: ISelectionRange,
force = false,
limit = -1,
): Promise<ICompletion[]> {
const {command, range} = this.getCurrentCommand(query, selection);
if (!query || !command) {
return [];
@ -46,7 +51,8 @@ export default class DuckDuckGoProvider extends AutocompleteProvider {
method: 'GET',
});
const json = await response.json();
const results = json.Results.map((result) => {
const maxLength = limit > -1 ? limit : json.Results.length;
const results = json.Results.slice(0, maxLength).map((result) => {
return {
completion: result.Text,
component: (

View file

@ -23,8 +23,7 @@ import AutocompleteProvider from './AutocompleteProvider';
import QueryMatcher from './QueryMatcher';
import {PillCompletion} from './Components';
import {ICompletion, ISelectionRange} from './Autocompleter';
import _uniq from 'lodash/uniq';
import _sortBy from 'lodash/sortBy';
import {uniq, sortBy} from 'lodash';
import SettingsStore from "../settings/SettingsStore";
import { shortcodeToUnicode } from '../HtmlUtils';
import { EMOJI, IEmoji } from '../emoji';
@ -85,7 +84,12 @@ export default class EmojiProvider extends AutocompleteProvider {
});
}
async getCompletions(query: string, selection: ISelectionRange, force?: boolean): Promise<ICompletion[]> {
async getCompletions(
query: string,
selection: ISelectionRange,
force?: boolean,
limit = -1,
): Promise<ICompletion[]> {
if (!SettingsStore.getValue("MessageComposerInput.suggestEmoji")) {
return []; // don't give any suggestions if the user doesn't want them
}
@ -94,7 +98,7 @@ export default class EmojiProvider extends AutocompleteProvider {
const {command, range} = this.getCurrentCommand(query, selection);
if (command) {
const matchedString = command[0];
completions = this.matcher.match(matchedString);
completions = this.matcher.match(matchedString, limit);
// Do second match with shouldMatchWordsOnly in order to match against 'name'
completions = completions.concat(this.nameMatcher.match(matchedString));
@ -115,7 +119,7 @@ export default class EmojiProvider extends AutocompleteProvider {
}
// Finally, sort by original ordering
sorters.push((c) => c._orderBy);
completions = _sortBy(_uniq(completions), sorters);
completions = sortBy(uniq(completions), sorters);
completions = completions.map(({shortname}) => {
const unicode = shortcodeToUnicode(shortname);
@ -139,7 +143,11 @@ export default class EmojiProvider extends AutocompleteProvider {
renderCompletions(completions: React.ReactNode[]): React.ReactNode {
return (
<div className="mx_Autocomplete_Completion_container_pill" role="listbox" aria-label={_t("Emoji Autocomplete")}>
<div
className="mx_Autocomplete_Completion_container_pill"
role="listbox"
aria-label={_t("Emoji Autocomplete")}
>
{ completions }
</div>
);

View file

@ -33,7 +33,12 @@ export default class NotifProvider extends AutocompleteProvider {
this.room = room;
}
async getCompletions(query: string, selection: ISelectionRange, force= false): Promise<ICompletion[]> {
async getCompletions(
query: string,
selection: ISelectionRange,
force = false,
limit = -1,
): Promise<ICompletion[]> {
const RoomAvatar = sdk.getComponent('views.avatars.RoomAvatar');
const client = MatrixClientPeg.get();

View file

@ -16,15 +16,13 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import _at from 'lodash/at';
import _uniq from 'lodash/uniq';
import {at, uniq} from 'lodash';
import {removeHiddenChars} from "matrix-js-sdk/src/utils";
interface IOptions<T extends {}> {
keys: Array<string | keyof T>;
funcs?: Array<(T) => string>;
shouldMatchWordsOnly?: boolean;
shouldMatchPrefix?: boolean;
// whether to apply unhomoglyph and strip diacritics to fuzz up the search. Defaults to true
fuzzy?: boolean;
}
@ -57,12 +55,6 @@ export default class QueryMatcher<T extends Object> {
if (this._options.shouldMatchWordsOnly === undefined) {
this._options.shouldMatchWordsOnly = true;
}
// By default, match anywhere in the string being searched. If enabled, only return
// matches that are prefixed with the query.
if (this._options.shouldMatchPrefix === undefined) {
this._options.shouldMatchPrefix = false;
}
}
setObjects(objects: T[]) {
@ -73,7 +65,7 @@ export default class QueryMatcher<T extends Object> {
// type for their values. We assume that those values who's keys have
// been specified will be string. Also, we cannot infer all the
// types of the keys of the objects at compile.
const keyValues = _at<string>(<any>object, this._options.keys);
const keyValues = at<string>(<any>object, this._options.keys);
if (this._options.funcs) {
for (const f of this._options.funcs) {
@ -95,7 +87,7 @@ export default class QueryMatcher<T extends Object> {
}
}
match(query: string): T[] {
match(query: string, limit = -1): T[] {
query = this.processQuery(query);
if (this._options.shouldMatchWordsOnly) {
query = query.replace(/[^\w]/g, '');
@ -113,7 +105,7 @@ export default class QueryMatcher<T extends Object> {
resultKey = resultKey.replace(/[^\w]/g, '');
}
const index = resultKey.indexOf(query);
if (index !== -1 && (!this._options.shouldMatchPrefix || index === 0)) {
if (index !== -1) {
matches.push(
...candidates.map((candidate) => ({index, ...candidate})),
);
@ -137,7 +129,10 @@ export default class QueryMatcher<T extends Object> {
});
// Now map the keys to the result objects. Also remove any duplicates.
return _uniq(matches.map((match) => match.object));
const dedupped = uniq(matches.map((match) => match.object));
const maxLength = limit === -1 ? dedupped.length : limit;
return dedupped.slice(0, maxLength);
}
private processQuery(query: string): string {

View file

@ -1,8 +1,7 @@
/*
Copyright 2016 Aviral Dasgupta
Copyright 2017 Vector Creations Ltd
Copyright 2017, 2018 New Vector Ltd
Copyright 2018 Michael Telatynski <7t3chguy@gmail.com>
Copyright 2017, 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.
@ -17,17 +16,19 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import React from 'react';
import React from "react";
import {uniqBy, sortBy} from "lodash";
import Room from "matrix-js-sdk/src/models/room";
import { _t } from '../languageHandler';
import AutocompleteProvider from './AutocompleteProvider';
import {MatrixClientPeg} from '../MatrixClientPeg';
import QueryMatcher from './QueryMatcher';
import {PillCompletion} from './Components';
import * as sdk from '../index';
import {makeRoomPermalink} from "../utils/permalinks/Permalinks";
import {ICompletion, ISelectionRange} from "./Autocompleter";
import { uniqBy, sortBy } from 'lodash';
import RoomAvatar from '../components/views/avatars/RoomAvatar';
import SettingsStore from "../settings/SettingsStore";
const ROOM_REGEX = /\B#\S*/g;
@ -49,7 +50,7 @@ function matcherObject(room: Room, displayedAlias: string, matchName = "") {
}
export default class RoomProvider extends AutocompleteProvider {
matcher: QueryMatcher<Room>;
protected matcher: QueryMatcher<Room>;
constructor() {
super(ROOM_REGEX);
@ -58,15 +59,28 @@ export default class RoomProvider extends AutocompleteProvider {
});
}
async getCompletions(query: string, selection: ISelectionRange, force = false): Promise<ICompletion[]> {
const RoomAvatar = sdk.getComponent('views.avatars.RoomAvatar');
protected getRooms() {
const cli = MatrixClientPeg.get();
let rooms = cli.getVisibleRooms();
const client = MatrixClientPeg.get();
if (SettingsStore.getValue("feature_spaces")) {
rooms = rooms.filter(r => !r.isSpaceRoom());
}
return rooms;
}
async getCompletions(
query: string,
selection: ISelectionRange,
force = false,
limit = -1,
): Promise<ICompletion[]> {
let completions = [];
const {command, range} = this.getCurrentCommand(query, selection, force);
if (command) {
// the only reason we need to do this is because Fuse only matches on properties
let matcherObjects = client.getVisibleRooms().reduce((aliases, room) => {
let matcherObjects = this.getRooms().reduce((aliases, room) => {
if (room.getCanonicalAlias()) {
aliases = aliases.concat(matcherObject(room, room.getCanonicalAlias(), room.name));
}
@ -90,7 +104,7 @@ export default class RoomProvider extends AutocompleteProvider {
this.matcher.setObjects(matcherObjects);
const matchedString = command[0];
completions = this.matcher.match(matchedString);
completions = this.matcher.match(matchedString, limit);
completions = sortBy(completions, [
(c) => score(matchedString, c.displayedAlias),
(c) => c.displayedAlias.length,
@ -110,9 +124,7 @@ export default class RoomProvider extends AutocompleteProvider {
),
range,
};
})
.filter((completion) => !!completion.completion && completion.completion.length > 0)
.slice(0, 4);
}).filter((completion) => !!completion.completion && completion.completion.length > 0);
}
return completions;
}

View file

@ -0,0 +1,43 @@
/*
Copyright 2021 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import React from "react";
import { _t } from '../languageHandler';
import {MatrixClientPeg} from '../MatrixClientPeg';
import RoomProvider from "./RoomProvider";
export default class SpaceProvider extends RoomProvider {
protected getRooms() {
return MatrixClientPeg.get().getVisibleRooms().filter(r => r.isSpaceRoom());
}
getName() {
return _t("Spaces");
}
renderCompletions(completions: React.ReactNode[]): React.ReactNode {
return (
<div
className="mx_Autocomplete_Completion_container_pill mx_Autocomplete_Completion_container_truncate"
role="listbox"
aria-label={_t("Space Autocomplete")}
>
{ completions }
</div>
);
}
}

View file

@ -23,7 +23,7 @@ import AutocompleteProvider from './AutocompleteProvider';
import {PillCompletion} from './Components';
import * as sdk from '../index';
import QueryMatcher from './QueryMatcher';
import _sortBy from 'lodash/sortBy';
import {sortBy} from 'lodash';
import {MatrixClientPeg} from '../MatrixClientPeg';
import MatrixEvent from "matrix-js-sdk/src/models/event";
@ -56,7 +56,6 @@ export default class UserProvider extends AutocompleteProvider {
this.matcher = new QueryMatcher([], {
keys: ['name'],
funcs: [obj => obj.userId.slice(1)], // index by user id minus the leading '@'
shouldMatchPrefix: true,
shouldMatchWordsOnly: false,
});
@ -71,8 +70,13 @@ export default class UserProvider extends AutocompleteProvider {
}
}
private onRoomTimeline = (ev: MatrixEvent, room: Room, toStartOfTimeline: boolean, removed: boolean,
data: IRoomTimelineData) => {
private onRoomTimeline = (
ev: MatrixEvent,
room: Room,
toStartOfTimeline: boolean,
removed: boolean,
data: IRoomTimelineData,
) => {
if (!room) return;
if (removed) return;
if (room.roomId !== this.room.roomId) return;
@ -98,7 +102,12 @@ export default class UserProvider extends AutocompleteProvider {
this.users = null;
};
async getCompletions(rawQuery: string, selection: ISelectionRange, force = false): Promise<ICompletion[]> {
async getCompletions(
rawQuery: string,
selection: ISelectionRange,
force = false,
limit = -1,
): Promise<ICompletion[]> {
const MemberAvatar = sdk.getComponent('views.avatars.MemberAvatar');
// lazy-load user list into matcher
@ -114,7 +123,7 @@ export default class UserProvider extends AutocompleteProvider {
if (fullMatch && fullMatch !== '@') {
// Don't include the '@' in our search query - it's only used as a way to trigger completion
const query = fullMatch.startsWith('@') ? fullMatch.substring(1) : fullMatch;
completions = this.matcher.match(query).map((user) => {
completions = this.matcher.match(query, limit).map((user) => {
const displayName = (user.name || user.userId || '');
return {
// Length of completion should equal length of text in decorator. draft-js
@ -150,8 +159,9 @@ export default class UserProvider extends AutocompleteProvider {
const currentUserId = MatrixClientPeg.get().credentials.userId;
this.users = this.room.getJoinedMembers().filter(({userId}) => userId !== currentUserId);
this.users = this.users.concat(this.room.getMembersWithMembership("invite"));
this.users = _sortBy(this.users, (member) => 1E20 - lastSpoken[member.userId] || 1E20);
this.users = sortBy(this.users, (member) => 1E20 - lastSpoken[member.userId] || 1E20);
this.matcher.setObjects(this.users);
}
@ -171,7 +181,11 @@ export default class UserProvider extends AutocompleteProvider {
renderCompletions(completions: React.ReactNode[]): React.ReactNode {
return (
<div className="mx_Autocomplete_Completion_container_pill" role="listbox" aria-label={_t("User Autocomplete")}>
<div
className="mx_Autocomplete_Completion_container_pill"
role="listbox"
aria-label={_t("User Autocomplete")}
>
{ completions }
</div>
);

View file

@ -1,90 +0,0 @@
/*
Copyright 2015, 2016 OpenMarket Ltd
Copyright 2019 Michael Telatynski <7t3chguy@gmail.com>
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 React from 'react';
import createReactClass from 'create-react-class';
import PropTypes from 'prop-types';
import { _t } from '../../languageHandler';
import SdkConfig from '../../SdkConfig';
export default createReactClass({
displayName: 'CompatibilityPage',
propTypes: {
onAccept: PropTypes.func,
},
getDefaultProps: function() {
return {
onAccept: function() {}, // NOP
};
},
onAccept: function() {
this.props.onAccept();
},
render: function() {
const brand = SdkConfig.get().brand;
return (
<div className="mx_CompatibilityPage">
<div className="mx_CompatibilityPage_box">
<p>{_t(
"Sorry, your browser is <b>not</b> able to run %(brand)s.",
{
brand,
},
{
'b': (sub) => <b>{sub}</b>,
})
}</p>
<p>
{ _t(
"%(brand)s uses many advanced browser features, some of which are not available " +
"or experimental in your current browser.",
{ brand },
) }
</p>
<p>
{ _t(
'Please install <chromeLink>Chrome</chromeLink>, <firefoxLink>Firefox</firefoxLink>, ' +
'or <safariLink>Safari</safariLink> for the best experience.',
{},
{
'chromeLink': (sub) => <a href="https://www.google.com/chrome">{sub}</a>,
'firefoxLink': (sub) => <a href="https://firefox.com">{sub}</a>,
'safariLink': (sub) => <a href="https://apple.com/safari">{sub}</a>,
},
)}
</p>
<p>
{ _t(
"With your current browser, the look and feel of the application may be " +
"completely incorrect, and some or all features may not function. " +
"If you want to try it anyway you can continue, but you are on your own in terms " +
"of any issues you may encounter!",
) }
</p>
<button onClick={this.onAccept}>
{ _t("I understand the risks and wish to continue") }
</button>
</div>
</div>
);
},
});

View file

@ -16,12 +16,13 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import React, {CSSProperties, useRef, useState} from "react";
import React, {CSSProperties, RefObject, useRef, useState} from "react";
import ReactDOM from "react-dom";
import classNames from "classnames";
import {Key} from "../../Keyboard";
import {Writeable} from "../../@types/common";
import {replaceableComponent} from "../../utils/replaceableComponent";
// Shamelessly ripped off Modal.js. There's probably a better way
// of doing reusable widgets like dialog boxes & menus where we go and
@ -76,6 +77,7 @@ export interface IProps extends IPosition {
hasBackground?: boolean;
// whether this context menu should be focus managed. If false it must handle itself
managed?: boolean;
wrapperClassName?: string;
// Function to be called on menu close
onFinished();
@ -90,6 +92,7 @@ interface IState {
// Generic ContextMenu Portal wrapper
// all options inside the menu should be of role=menuitem/menuitemcheckbox/menuitemradiobutton and have tabIndex={-1}
// this will allow the ContextMenu to manage its own focus using arrow keys as per the ARIA guidelines.
@replaceableComponent("structures.ContextMenu")
export class ContextMenu extends React.PureComponent<IProps, IState> {
private initialFocus: HTMLElement;
@ -219,10 +222,12 @@ export class ContextMenu extends React.PureComponent<IProps, IState> {
};
private onKeyDown = (ev: React.KeyboardEvent) => {
// don't let keyboard handling escape the context menu
ev.stopPropagation();
if (!this.props.managed) {
if (ev.key === Key.ESCAPE) {
this.props.onFinished();
ev.stopPropagation();
ev.preventDefault();
}
return;
@ -233,8 +238,7 @@ export class ContextMenu extends React.PureComponent<IProps, IState> {
switch (ev.key) {
case Key.TAB:
case Key.ESCAPE:
// close on left and right arrows too for when it is a context menu on a <Toolbar />
case Key.ARROW_LEFT:
case Key.ARROW_LEFT: // close on left and right arrows too for when it is a context menu on a <Toolbar />
case Key.ARROW_RIGHT:
this.props.onFinished();
break;
@ -256,7 +260,6 @@ export class ContextMenu extends React.PureComponent<IProps, IState> {
if (handled) {
// consume all other keys in context menu
ev.stopPropagation();
ev.preventDefault();
}
};
@ -300,7 +303,7 @@ export class ContextMenu extends React.PureComponent<IProps, IState> {
// such that it does not leave the (padded) window.
if (contextMenuRect) {
const padding = 10;
adjusted = Math.min(position.top, document.body.clientHeight - contextMenuRect.height + padding);
adjusted = Math.min(position.top, document.body.clientHeight - contextMenuRect.height - padding);
}
position.top = adjusted;
@ -366,7 +369,7 @@ export class ContextMenu extends React.PureComponent<IProps, IState> {
return (
<div
className="mx_ContextualMenu_wrapper"
className={classNames("mx_ContextualMenu_wrapper", this.props.wrapperClassName)}
style={{...position, ...wrapperStyle}}
onKeyDown={this.onKeyDown}
onContextMenu={this.onContextMenuPreventBubbling}
@ -391,15 +394,16 @@ export class ContextMenu extends React.PureComponent<IProps, IState> {
}
// Placement method for <ContextMenu /> to position context menu to right of elementRect with chevronOffset
export const toRightOf = (elementRect: DOMRect, chevronOffset = 12) => {
export const toRightOf = (elementRect: Pick<DOMRect, "right" | "top" | "height">, chevronOffset = 12) => {
const left = elementRect.right + window.pageXOffset + 3;
let top = elementRect.top + (elementRect.height / 2) + window.pageYOffset;
top -= chevronOffset + 8; // where 8 is half the height of the chevron
return {left, top, chevronOffset};
};
// Placement method for <ContextMenu /> to position context menu right-aligned and flowing to the left of elementRect
export const aboveLeftOf = (elementRect: DOMRect, chevronFace = ChevronFace.None) => {
// Placement method for <ContextMenu /> to position context menu right-aligned and flowing to the left of elementRect,
// and either above or below: wherever there is more space (maybe this should be aboveOrBelowLeftOf?)
export const aboveLeftOf = (elementRect: DOMRect, chevronFace = ChevronFace.None, vPadding = 0) => {
const menuOptions: IPosition & { chevronFace: ChevronFace } = { chevronFace };
const buttonRight = elementRect.right + window.pageXOffset;
@ -409,16 +413,52 @@ export const aboveLeftOf = (elementRect: DOMRect, chevronFace = ChevronFace.None
menuOptions.right = window.innerWidth - buttonRight;
// Align the menu vertically on whichever side of the button has more space available.
if (buttonBottom < window.innerHeight / 2) {
menuOptions.top = buttonBottom;
menuOptions.top = buttonBottom + vPadding;
} else {
menuOptions.bottom = window.innerHeight - buttonTop;
menuOptions.bottom = (window.innerHeight - buttonTop) + vPadding;
}
return menuOptions;
};
export const useContextMenu = () => {
const button = useRef(null);
// Placement method for <ContextMenu /> to position context menu right-aligned and flowing to the left of elementRect
// and always above elementRect
export const alwaysAboveLeftOf = (elementRect: DOMRect, chevronFace = ChevronFace.None, vPadding = 0) => {
const menuOptions: IPosition & { chevronFace: ChevronFace } = { chevronFace };
const buttonRight = elementRect.right + window.pageXOffset;
const buttonBottom = elementRect.bottom + window.pageYOffset;
const buttonTop = elementRect.top + window.pageYOffset;
// Align the right edge of the menu to the right edge of the button
menuOptions.right = window.innerWidth - buttonRight;
// Align the menu vertically on whichever side of the button has more space available.
if (buttonBottom < window.innerHeight / 2) {
menuOptions.top = buttonBottom + vPadding;
} else {
menuOptions.bottom = (window.innerHeight - buttonTop) + vPadding;
}
return menuOptions;
};
// Placement method for <ContextMenu /> to position context menu right-aligned and flowing to the right of elementRect
// and always above elementRect
export const alwaysAboveRightOf = (elementRect: DOMRect, chevronFace = ChevronFace.None, vPadding = 0) => {
const menuOptions: IPosition & { chevronFace: ChevronFace } = { chevronFace };
const buttonLeft = elementRect.left + window.pageXOffset;
const buttonTop = elementRect.top + window.pageYOffset;
// Align the left edge of the menu to the left edge of the button
menuOptions.left = buttonLeft;
// Align the menu vertically above the menu
menuOptions.bottom = (window.innerHeight - buttonTop) + vPadding;
return menuOptions;
};
type ContextMenuTuple<T> = [boolean, RefObject<T>, () => void, () => void, (val: boolean) => void];
export const useContextMenu = <T extends any = HTMLElement>(): ContextMenuTuple<T> => {
const button = useRef<T>(null);
const [isOpen, setIsOpen] = useState(false);
const open = () => {
setIsOpen(true);
@ -430,6 +470,7 @@ export const useContextMenu = () => {
return [isOpen, button, open, close, setIsOpen];
};
@replaceableComponent("structures.LegacyContextMenu")
export default class LegacyContextMenu extends ContextMenu {
render() {
return this.renderMenu(false);

View file

@ -21,7 +21,9 @@ import * as sdk from '../../index';
import dis from '../../dispatcher/dispatcher';
import classNames from 'classnames';
import * as FormattingUtils from '../../utils/FormattingUtils';
import {replaceableComponent} from "../../utils/replaceableComponent";
@replaceableComponent("structures.CustomRoomTagPanel")
class CustomRoomTagPanel extends React.Component {
constructor(props) {
super(props);

View file

@ -16,8 +16,6 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
'use strict';
import React from 'react';
import PropTypes from 'prop-types';
import request from 'browser-request';
@ -43,8 +41,8 @@ export default class EmbeddedPage extends React.PureComponent {
static contextType = MatrixClientContext;
constructor(props) {
super(props);
constructor(props, context) {
super(props, context);
this._dispatcherRef = null;

View file

@ -16,36 +16,38 @@ limitations under the License.
*/
import React from 'react';
import createReactClass from 'create-react-class';
import PropTypes from 'prop-types';
import {Filter} from 'matrix-js-sdk';
import {Filter} from 'matrix-js-sdk/src/filter';
import * as sdk from '../../index';
import {MatrixClientPeg} from '../../MatrixClientPeg';
import EventIndexPeg from "../../indexing/EventIndexPeg";
import { _t } from '../../languageHandler';
import BaseCard from "../views/right_panel/BaseCard";
import {RightPanelPhases} from "../../stores/RightPanelStorePhases";
import DesktopBuildsNotice, {WarningKind} from "../views/elements/DesktopBuildsNotice";
import {replaceableComponent} from "../../utils/replaceableComponent";
/*
* Component which shows the filtered file using a TimelinePanel
*/
const FilePanel = createReactClass({
displayName: 'FilePanel',
@replaceableComponent("structures.FilePanel")
class FilePanel extends React.Component {
static propTypes = {
roomId: PropTypes.string.isRequired,
onClose: PropTypes.func.isRequired,
};
// This is used to track if a decrypted event was a live event and should be
// added to the timeline.
decryptingEvents: new Set(),
decryptingEvents = new Set();
propTypes: {
roomId: PropTypes.string.isRequired,
},
state = {
timelineSet: null,
};
getInitialState: function() {
return {
timelineSet: null,
};
},
onRoomTimeline(ev, room, toStartOfTimeline, removed, data) {
if (room.roomId !== this.props.roomId) return;
onRoomTimeline = (ev, room, toStartOfTimeline, removed, data) => {
if (room?.roomId !== this.props?.roomId) return;
if (toStartOfTimeline || !data || !data.liveEvent || ev.isRedacted()) return;
if (ev.isBeingDecrypted()) {
@ -53,9 +55,9 @@ const FilePanel = createReactClass({
} else {
this.addEncryptedLiveEvent(ev);
}
},
};
onEventDecrypted(ev, err) {
onEventDecrypted = (ev, err) => {
if (ev.getRoomId() !== this.props.roomId) return;
const eventId = ev.getId();
@ -63,7 +65,7 @@ const FilePanel = createReactClass({
if (err) return;
this.addEncryptedLiveEvent(ev);
},
};
addEncryptedLiveEvent(ev, toStartOfTimeline) {
if (!this.state.timelineSet) return;
@ -77,7 +79,7 @@ const FilePanel = createReactClass({
if (!this.state.timelineSet.eventIdToTimeline(ev.getId())) {
this.state.timelineSet.addEventToTimeline(ev, timeline, false);
}
},
}
async componentDidMount() {
const client = MatrixClientPeg.get();
@ -98,7 +100,7 @@ const FilePanel = createReactClass({
client.on('Room.timeline', this.onRoomTimeline);
client.on('Event.decrypted', this.onEventDecrypted);
}
},
}
componentWillUnmount() {
const client = MatrixClientPeg.get();
@ -110,7 +112,7 @@ const FilePanel = createReactClass({
client.removeListener('Room.timeline', this.onRoomTimeline);
client.removeListener('Event.decrypted', this.onEventDecrypted);
}
},
}
async fetchFileEventsServer(room) {
const client = MatrixClientPeg.get();
@ -134,9 +136,9 @@ const FilePanel = createReactClass({
const timelineSet = room.getOrCreateFilteredTimelineSet(filter);
return timelineSet;
},
}
onPaginationRequest(timelineWindow, direction, limit) {
onPaginationRequest = (timelineWindow, direction, limit) => {
const client = MatrixClientPeg.get();
const eventIndex = EventIndexPeg.get();
const roomId = this.props.roomId;
@ -152,7 +154,7 @@ const FilePanel = createReactClass({
} else {
return timelineWindow.paginate(direction, limit);
}
},
};
async updateTimelineSet(roomId: string) {
const client = MatrixClientPeg.get();
@ -188,22 +190,30 @@ const FilePanel = createReactClass({
} else {
console.error("Failed to add filtered timelineSet for FilePanel as no room!");
}
},
}
render: function() {
render() {
if (MatrixClientPeg.get().isGuest()) {
return <div className="mx_FilePanel mx_RoomView_messageListWrapper">
return <BaseCard
className="mx_FilePanel mx_RoomView_messageListWrapper"
onClose={this.props.onClose}
previousPhase={RightPanelPhases.RoomSummary}
>
<div className="mx_RoomView_empty">
{ _t("You must <a>register</a> to use this functionality",
{},
{ 'a': (sub) => <a href="#/register" key="sub">{ sub }</a> })
}
{ _t("You must <a>register</a> to use this functionality",
{},
{ 'a': (sub) => <a href="#/register" key="sub">{ sub }</a> })
}
</div>
</div>;
</BaseCard>;
} else if (this.noRoom) {
return <div className="mx_FilePanel mx_RoomView_messageListWrapper">
return <BaseCard
className="mx_FilePanel mx_RoomView_messageListWrapper"
onClose={this.props.onClose}
previousPhase={RightPanelPhases.RoomSummary}
>
<div className="mx_RoomView_empty">{ _t("You must join the room to see its files") }</div>
</div>;
</BaseCard>;
}
// wrap a TimelinePanel with the jump-to-event bits turned off.
@ -215,12 +225,20 @@ const FilePanel = createReactClass({
<p>{_t('Attach files from chat or just drag and drop them anywhere in a room.')}</p>
</div>);
const isRoomEncrypted = this.noRoom ? false : MatrixClientPeg.get().isRoomEncrypted(this.props.roomId);
if (this.state.timelineSet) {
// console.log("rendering TimelinePanel for timelineSet " + this.state.timelineSet.room.roomId + " " +
// "(" + this.state.timelineSet._timelines.join(", ") + ")" + " with key " + this.props.roomId);
return (
<div className="mx_FilePanel" role="tabpanel">
<TimelinePanel key={"filepanel_" + this.props.roomId}
<BaseCard
className="mx_FilePanel"
onClose={this.props.onClose}
previousPhase={RightPanelPhases.RoomSummary}
withoutScrollContainer
>
<DesktopBuildsNotice isRoomEncrypted={isRoomEncrypted} kind={WarningKind.Files} />
<TimelinePanel
manageReadReceipts={false}
manageReadMarkers={false}
timelineSet={this.state.timelineSet}
@ -230,16 +248,20 @@ const FilePanel = createReactClass({
resizeNotifier={this.props.resizeNotifier}
empty={emptyState}
/>
</div>
</BaseCard>
);
} else {
return (
<div className="mx_FilePanel" role="tabpanel">
<BaseCard
className="mx_FilePanel"
onClose={this.props.onClose}
previousPhase={RightPanelPhases.RoomSummary}
>
<Loader />
</div>
</BaseCard>
);
}
},
});
}
}
export default FilePanel;

View file

@ -16,7 +16,9 @@ limitations under the License.
import React from 'react';
import PropTypes from 'prop-types';
import {replaceableComponent} from "../../utils/replaceableComponent";
@replaceableComponent("structures.GenericErrorPage")
export default class GenericErrorPage extends React.PureComponent {
static propTypes = {
title: PropTypes.object.isRequired, // jsx for title

View file

@ -16,8 +16,7 @@ limitations under the License.
*/
import React from 'react';
import createReactClass from 'create-react-class';
import TagOrderStore from '../../stores/TagOrderStore';
import GroupFilterOrderStore from '../../stores/GroupFilterOrderStore';
import GroupActions from '../../actions/GroupActions';
@ -29,54 +28,52 @@ import { Droppable } from 'react-beautiful-dnd';
import classNames from 'classnames';
import MatrixClientContext from "../../contexts/MatrixClientContext";
import AutoHideScrollbar from "./AutoHideScrollbar";
import SettingsStore from "../../settings/SettingsStore";
import UserTagTile from "../views/elements/UserTagTile";
import {replaceableComponent} from "../../utils/replaceableComponent";
const TagPanel = createReactClass({
displayName: 'TagPanel',
@replaceableComponent("structures.GroupFilterPanel")
class GroupFilterPanel extends React.Component {
static contextType = MatrixClientContext;
statics: {
contextType: MatrixClientContext,
},
state = {
orderedTags: [],
selectedTags: [],
};
getInitialState() {
return {
orderedTags: [],
selectedTags: [],
};
},
componentDidMount: function() {
componentDidMount() {
this.unmounted = false;
this.context.on("Group.myMembership", this._onGroupMyMembership);
this.context.on("sync", this._onClientSync);
this._tagOrderStoreToken = TagOrderStore.addListener(() => {
this._groupFilterOrderStoreToken = GroupFilterOrderStore.addListener(() => {
if (this.unmounted) {
return;
}
this.setState({
orderedTags: TagOrderStore.getOrderedTags() || [],
selectedTags: TagOrderStore.getSelectedTags(),
orderedTags: GroupFilterOrderStore.getOrderedTags() || [],
selectedTags: GroupFilterOrderStore.getSelectedTags(),
});
});
// This could be done by anything with a matrix client
dis.dispatch(GroupActions.fetchJoinedGroups(this.context));
},
}
componentWillUnmount() {
this.unmounted = true;
this.context.removeListener("Group.myMembership", this._onGroupMyMembership);
this.context.removeListener("sync", this._onClientSync);
if (this._tagOrderStoreToken) {
this._tagOrderStoreToken.remove();
if (this._groupFilterOrderStoreToken) {
this._groupFilterOrderStoreToken.remove();
}
},
}
_onGroupMyMembership() {
_onGroupMyMembership = () => {
if (this.unmounted) return;
dis.dispatch(GroupActions.fetchJoinedGroups(this.context));
},
};
_onClientSync(syncState, prevState) {
_onClientSync = (syncState, prevState) => {
// Consider the client reconnected if there is no error with syncing.
// This means the state could be RECONNECTING, SYNCING, PREPARED or CATCHUP.
const reconnected = syncState !== "ERROR" && prevState !== syncState;
@ -84,29 +81,33 @@ const TagPanel = createReactClass({
// Load joined groups
dis.dispatch(GroupActions.fetchJoinedGroups(this.context));
}
},
};
onMouseDown(e) {
onMouseDown = e => {
// only dispatch if its not a no-op
if (this.state.selectedTags.length > 0) {
dis.dispatch({action: 'deselect_tags'});
}
},
};
onCreateGroupClick(ev) {
ev.stopPropagation();
dis.dispatch({action: 'view_create_group'});
},
onClearFilterClick(ev) {
onClearFilterClick = ev => {
dis.dispatch({action: 'deselect_tags'});
},
};
renderGlobalIcon() {
if (!SettingsStore.getValue("feature_communities_v2_prototypes")) return null;
return (
<div>
<UserTagTile />
<hr className="mx_GroupFilterPanel_divider" />
</div>
);
}
render() {
const DNDTagTile = sdk.getComponent('elements.DNDTagTile');
const AccessibleButton = sdk.getComponent('elements.AccessibleButton');
const ActionButton = sdk.getComponent('elements.ActionButton');
const TintableSvg = sdk.getComponent('elements.TintableSvg');
const tags = this.state.orderedTags.map((tag, index) => {
return <DNDTagTile
@ -118,28 +119,38 @@ const TagPanel = createReactClass({
});
const itemsSelected = this.state.selectedTags.length > 0;
let clearButton;
if (itemsSelected) {
clearButton = <AccessibleButton className="mx_TagPanel_clearButton" onClick={this.onClearFilterClick}>
<TintableSvg src={require("../../../res/img/icons-close.svg")} width="24" height="24"
alt={_t("Clear filter")}
title={_t("Clear filter")}
/>
</AccessibleButton>;
}
const classes = classNames('mx_TagPanel', {
mx_TagPanel_items_selected: itemsSelected,
const classes = classNames('mx_GroupFilterPanel', {
mx_GroupFilterPanel_items_selected: itemsSelected,
});
return <div className={classes}>
<div className="mx_TagPanel_clearButton_container">
{ clearButton }
</div>
<div className="mx_TagPanel_divider" />
let betaDot;
if (SettingsStore.getBetaInfo("feature_spaces") && !localStorage.getItem("mx_seenSpacesBeta")) {
betaDot = <div className="mx_BetaDot" />;
}
let createButton = (
<ActionButton
tooltip
label={_t("Communities")}
action="toggle_my_groups"
className="mx_TagTile mx_TagTile_plus">
{ betaDot }
</ActionButton>
);
if (SettingsStore.getValue("feature_communities_v2_prototypes")) {
createButton = (
<ActionButton
tooltip
label={_t("Create community")}
action="view_create_group"
className="mx_TagTile mx_TagTile_plus" />
);
}
return <div className={classes} onClick={this.onClearFilterClick}>
<AutoHideScrollbar
className="mx_TagPanel_scroller"
className="mx_GroupFilterPanel_scroller"
// XXX: Use onMouseDown as a workaround for https://github.com/atlassian/react-beautiful-dnd/issues/273
// instead of onClick. Otherwise we experience https://github.com/vector-im/element-web/issues/6253
onMouseDown={this.onMouseDown}
@ -149,24 +160,21 @@ const TagPanel = createReactClass({
type="draggable-TagTile"
>
{ (provided, snapshot) => (
<div
className="mx_TagPanel_tagTileContainer"
ref={provided.innerRef}
>
{ tags }
<div>
<ActionButton
tooltip
label={_t("Communities")}
action="toggle_my_groups"
className="mx_TagTile mx_TagTile_plus" />
</div>
{ provided.placeholder }
<div
className="mx_GroupFilterPanel_tagTileContainer"
ref={provided.innerRef}
>
{ this.renderGlobalIcon() }
{ tags }
<div>
{createButton}
</div>
{ provided.placeholder }
</div>
) }
</Droppable>
</AutoHideScrollbar>
</div>;
},
});
export default TagPanel;
}
}
export default GroupFilterPanel;

View file

@ -17,7 +17,6 @@ limitations under the License.
*/
import React from 'react';
import createReactClass from 'create-react-class';
import PropTypes from 'prop-types';
import {MatrixClientPeg} from '../../MatrixClientPeg';
import * as sdk from '../../index';
@ -36,19 +35,21 @@ import GroupStore from '../../stores/GroupStore';
import FlairStore from '../../stores/FlairStore';
import { showGroupAddRoomDialog } from '../../GroupAddressPicker';
import {makeGroupPermalink, makeUserPermalink} from "../../utils/permalinks/Permalinks";
import {Group} from "matrix-js-sdk";
import {Group} from "matrix-js-sdk/src/models/group";
import {allSettled, sleep} from "../../utils/promise";
import RightPanelStore from "../../stores/RightPanelStore";
import AutoHideScrollbar from "./AutoHideScrollbar";
import {mediaFromMxc} from "../../customisations/Media";
import {replaceableComponent} from "../../utils/replaceableComponent";
const LONG_DESC_PLACEHOLDER = _td(
`<h1>HTML for your community's page</h1>
`<h1>HTML for your community's page</h1>
<p>
Use the long description to introduce new members to the community, or distribute
some important <a href="foo">links</a>
</p>
<p>
You can even use 'img' tags
You can even add images with Matrix URLs <img src="mxc://url" />
</p>
`);
@ -70,10 +71,8 @@ const UserSummaryType = PropTypes.shape({
}).isRequired,
});
const CategoryRoomList = createReactClass({
displayName: 'CategoryRoomList',
props: {
class CategoryRoomList extends React.Component {
static propTypes = {
rooms: PropTypes.arrayOf(RoomSummaryType).isRequired,
category: PropTypes.shape({
profile: PropTypes.shape({
@ -84,9 +83,9 @@ const CategoryRoomList = createReactClass({
// Whether the list should be editable
editing: PropTypes.bool.isRequired,
},
};
onAddRoomsToSummaryClicked: function(ev) {
onAddRoomsToSummaryClicked = (ev) => {
ev.preventDefault();
const AddressPickerDialog = sdk.getComponent("dialogs.AddressPickerDialog");
Modal.createTrackedDialog('Add Rooms to Group Summary', '', AddressPickerDialog, {
@ -111,20 +110,22 @@ const CategoryRoomList = createReactClass({
const ErrorDialog = sdk.getComponent("dialogs.ErrorDialog");
Modal.createTrackedDialog(
'Failed to add the following room to the group summary',
'', ErrorDialog,
{
title: _t(
"Failed to add the following rooms to the summary of %(groupId)s:",
{groupId: this.props.groupId},
),
description: errorList.join(", "),
});
'',
ErrorDialog,
{
title: _t(
"Failed to add the following rooms to the summary of %(groupId)s:",
{groupId: this.props.groupId},
),
description: errorList.join(", "),
},
);
});
},
}, /*className=*/null, /*isPriority=*/false, /*isStatic=*/true);
},
};
render: function() {
render() {
const TintableSvg = sdk.getComponent("elements.TintableSvg");
const addButton = this.props.editing ?
(<AccessibleButton className="mx_GroupView_featuredThings_addButton"
@ -147,27 +148,25 @@ const CategoryRoomList = createReactClass({
let catHeader = <div />;
if (this.props.category && this.props.category.profile) {
catHeader = <div className="mx_GroupView_featuredThings_category">
{ this.props.category.profile.name }
</div>;
{ this.props.category.profile.name }
</div>;
}
return <div className="mx_GroupView_featuredThings_container">
{ catHeader }
{ roomNodes }
{ addButton }
</div>;
},
});
}
}
const FeaturedRoom = createReactClass({
displayName: 'FeaturedRoom',
props: {
class FeaturedRoom extends React.Component {
static propTypes = {
summaryInfo: RoomSummaryType.isRequired,
editing: PropTypes.bool.isRequired,
groupId: PropTypes.string.isRequired,
},
};
onClick: function(e) {
onClick = (e) => {
e.preventDefault();
e.stopPropagation();
@ -176,9 +175,9 @@ const FeaturedRoom = createReactClass({
room_alias: this.props.summaryInfo.profile.canonical_alias,
room_id: this.props.summaryInfo.room_id,
});
},
};
onDeleteClicked: function(e) {
onDeleteClicked = (e) => {
e.preventDefault();
e.stopPropagation();
GroupStore.removeRoomFromGroupSummary(
@ -193,17 +192,18 @@ const FeaturedRoom = createReactClass({
Modal.createTrackedDialog(
'Failed to remove room from group summary',
'', ErrorDialog,
{
title: _t(
"Failed to remove the room from the summary of %(groupId)s",
{groupId: this.props.groupId},
),
description: _t("The room '%(roomName)s' could not be removed from the summary.", {roomName}),
});
{
title: _t(
"Failed to remove the room from the summary of %(groupId)s",
{groupId: this.props.groupId},
),
description: _t("The room '%(roomName)s' could not be removed from the summary.", {roomName}),
},
);
});
},
};
render: function() {
render() {
const RoomAvatar = sdk.getComponent("avatars.RoomAvatar");
const roomName = this.props.summaryInfo.profile.name ||
@ -243,13 +243,11 @@ const FeaturedRoom = createReactClass({
<div className="mx_GroupView_featuredThing_name">{ roomNameNode }</div>
{ deleteButton }
</AccessibleButton>;
},
});
}
}
const RoleUserList = createReactClass({
displayName: 'RoleUserList',
props: {
class RoleUserList extends React.Component {
static propTypes = {
users: PropTypes.arrayOf(UserSummaryType).isRequired,
role: PropTypes.shape({
profile: PropTypes.shape({
@ -260,9 +258,9 @@ const RoleUserList = createReactClass({
// Whether the list should be editable
editing: PropTypes.bool.isRequired,
},
};
onAddUsersClicked: function(ev) {
onAddUsersClicked = (ev) => {
ev.preventDefault();
const AddressPickerDialog = sdk.getComponent("dialogs.AddressPickerDialog");
Modal.createTrackedDialog('Add Users to Group Summary', '', AddressPickerDialog, {
@ -288,27 +286,28 @@ const RoleUserList = createReactClass({
Modal.createTrackedDialog(
'Failed to add the following users to the community summary',
'', ErrorDialog,
{
title: _t(
"Failed to add the following users to the summary of %(groupId)s:",
{groupId: this.props.groupId},
),
description: errorList.join(", "),
});
{
title: _t(
"Failed to add the following users to the summary of %(groupId)s:",
{groupId: this.props.groupId},
),
description: errorList.join(", "),
},
);
});
},
}, /*className=*/null, /*isPriority=*/false, /*isStatic=*/true);
},
};
render: function() {
render() {
const TintableSvg = sdk.getComponent("elements.TintableSvg");
const addButton = this.props.editing ?
(<AccessibleButton className="mx_GroupView_featuredThings_addButton" onClick={this.onAddUsersClicked}>
<TintableSvg src={require("../../../res/img/icons-create-room.svg")} width="64" height="64" />
<div className="mx_GroupView_featuredThings_addButton_label">
{ _t('Add a User') }
</div>
</AccessibleButton>) : <div />;
<TintableSvg src={require("../../../res/img/icons-create-room.svg")} width="64" height="64" />
<div className="mx_GroupView_featuredThings_addButton_label">
{ _t('Add a User') }
</div>
</AccessibleButton>) : <div />;
const userNodes = this.props.users.map((u) => {
return <FeaturedUser
key={u.user_id}
@ -325,19 +324,17 @@ const RoleUserList = createReactClass({
{ userNodes }
{ addButton }
</div>;
},
});
}
}
const FeaturedUser = createReactClass({
displayName: 'FeaturedUser',
props: {
class FeaturedUser extends React.Component {
static propTypes = {
summaryInfo: UserSummaryType.isRequired,
editing: PropTypes.bool.isRequired,
groupId: PropTypes.string.isRequired,
},
};
onClick: function(e) {
onClick = (e) => {
e.preventDefault();
e.stopPropagation();
@ -345,9 +342,9 @@ const FeaturedUser = createReactClass({
action: 'view_start_chat_or_reuse',
user_id: this.props.summaryInfo.user_id,
});
},
};
onDeleteClicked: function(e) {
onDeleteClicked = (e) => {
e.preventDefault();
e.stopPropagation();
GroupStore.removeUserFromGroupSummary(
@ -359,25 +356,26 @@ const FeaturedUser = createReactClass({
const ErrorDialog = sdk.getComponent("dialogs.ErrorDialog");
Modal.createTrackedDialog(
'Failed to remove user from community summary',
'', ErrorDialog,
{
title: _t(
"Failed to remove a user from the summary of %(groupId)s",
{groupId: this.props.groupId},
),
description: _t("The user '%(displayName)s' could not be removed from the summary.", {displayName}),
});
'',
ErrorDialog,
{
title: _t(
"Failed to remove a user from the summary of %(groupId)s",
{groupId: this.props.groupId},
),
description: _t("The user '%(displayName)s' could not be removed from the summary.", {displayName}),
},
);
});
},
};
render: function() {
render() {
const BaseAvatar = sdk.getComponent("avatars.BaseAvatar");
const name = this.props.summaryInfo.displayname || this.props.summaryInfo.user_id;
const permalink = makeUserPermalink(this.props.summaryInfo.user_id);
const userNameNode = <a href={permalink} onClick={this.onClick}>{ name }</a>;
const httpUrl = MatrixClientPeg.get()
.mxcUrlToHttp(this.props.summaryInfo.avatar_url, 64, 64);
const httpUrl = mediaFromMxc(this.props.summaryInfo.avatar_url).getSquareThumbnailHttp(64);
const deleteButton = this.props.editing ?
<img
@ -394,41 +392,38 @@ const FeaturedUser = createReactClass({
<div className="mx_GroupView_featuredThing_name">{ userNameNode }</div>
{ deleteButton }
</AccessibleButton>;
},
});
}
}
const GROUP_JOINPOLICY_OPEN = "open";
const GROUP_JOINPOLICY_INVITE = "invite";
export default createReactClass({
displayName: 'GroupView',
propTypes: {
@replaceableComponent("structures.GroupView")
export default class GroupView extends React.Component {
static propTypes = {
groupId: PropTypes.string.isRequired,
// Whether this is the first time the group admin is viewing the group
groupIsNew: PropTypes.bool,
},
};
getInitialState: function() {
return {
summary: null,
isGroupPublicised: null,
isUserPrivileged: null,
groupRooms: null,
groupRoomsLoading: null,
error: null,
editing: false,
saving: false,
uploadingAvatar: false,
avatarChanged: false,
membershipBusy: false,
publicityBusy: false,
inviterProfile: null,
showRightPanel: RightPanelStore.getSharedInstance().isOpenForGroup,
};
},
state = {
summary: null,
isGroupPublicised: null,
isUserPrivileged: null,
groupRooms: null,
groupRoomsLoading: null,
error: null,
editing: false,
saving: false,
uploadingAvatar: false,
avatarChanged: false,
membershipBusy: false,
publicityBusy: false,
inviterProfile: null,
showRightPanel: RightPanelStore.getSharedInstance().isOpenForGroup,
};
componentDidMount: function() {
componentDidMount() {
this._unmounted = false;
this._matrixClient = MatrixClientPeg.get();
this._matrixClient.on("Group.myMembership", this._onGroupMyMembership);
@ -437,9 +432,9 @@ export default createReactClass({
this._dispatcherRef = dis.register(this._onAction);
this._rightPanelStoreToken = RightPanelStore.getSharedInstance().addListener(this._onRightPanelStoreUpdate);
},
}
componentWillUnmount: function() {
componentWillUnmount() {
this._unmounted = true;
this._matrixClient.removeListener("Group.myMembership", this._onGroupMyMembership);
dis.unregister(this._dispatcherRef);
@ -448,10 +443,11 @@ export default createReactClass({
if (this._rightPanelStoreToken) {
this._rightPanelStoreToken.remove();
}
},
}
// TODO: [REACT-WARNING] Replace with appropriate lifecycle event
UNSAFE_componentWillReceiveProps: function(newProps) {
// eslint-disable-next-line camelcase
UNSAFE_componentWillReceiveProps(newProps) {
if (this.props.groupId !== newProps.groupId) {
this.setState({
summary: null,
@ -460,24 +456,24 @@ export default createReactClass({
this._initGroupStore(newProps.groupId);
});
}
},
}
_onRightPanelStoreUpdate: function() {
_onRightPanelStoreUpdate = () => {
this.setState({
showRightPanel: RightPanelStore.getSharedInstance().isOpenForGroup,
});
},
};
_onGroupMyMembership: function(group) {
_onGroupMyMembership = (group) => {
if (this._unmounted || group.groupId !== this.props.groupId) return;
if (group.myMembership === 'leave') {
// Leave settings - the user might have clicked the "Leave" button
this._closeSettings();
}
this.setState({membershipBusy: false});
},
};
_initGroupStore: function(groupId, firstInit) {
_initGroupStore(groupId, firstInit) {
const group = this._matrixClient.getGroup(groupId);
if (group && group.inviter && group.inviter.userId) {
this._fetchInviterProfile(group.inviter.userId);
@ -506,9 +502,9 @@ export default createReactClass({
});
}
});
},
}
onGroupStoreUpdated(firstInit) {
onGroupStoreUpdated = (firstInit) => {
if (this._unmounted) return;
const summary = GroupStore.getSummary(this.props.groupId);
if (summary.profile) {
@ -533,7 +529,7 @@ export default createReactClass({
if (this.props.groupIsNew && firstInit) {
this._onEditClick();
}
},
};
_fetchInviterProfile(userId) {
this.setState({
@ -555,9 +551,9 @@ export default createReactClass({
inviterProfileBusy: false,
});
});
},
}
_onEditClick: function() {
_onEditClick = () => {
this.setState({
editing: true,
profileForm: Object.assign({}, this.state.summary.profile),
@ -568,20 +564,20 @@ export default createReactClass({
GROUP_JOINPOLICY_INVITE,
},
});
},
};
_onShareClick: function() {
_onShareClick = () => {
const ShareDialog = sdk.getComponent("dialogs.ShareDialog");
Modal.createTrackedDialog('share community dialog', '', ShareDialog, {
target: this._matrixClient.getGroup(this.props.groupId) || new Group(this.props.groupId),
});
},
};
_onCancelClick: function() {
_onCancelClick = () => {
this._closeSettings();
},
};
_onAction(payload) {
_onAction = (payload) => {
switch (payload.action) {
// NOTE: close_settings is an app-wide dispatch; as it is dispatched from MatrixChat
case 'close_settings':
@ -593,34 +589,34 @@ export default createReactClass({
default:
break;
}
},
};
_closeSettings() {
_closeSettings = () => {
dis.dispatch({action: 'close_settings'});
},
};
_onNameChange: function(value) {
_onNameChange = (value) => {
const newProfileForm = Object.assign(this.state.profileForm, { name: value });
this.setState({
profileForm: newProfileForm,
});
},
};
_onShortDescChange: function(value) {
_onShortDescChange = (value) => {
const newProfileForm = Object.assign(this.state.profileForm, { short_description: value });
this.setState({
profileForm: newProfileForm,
});
},
};
_onLongDescChange: function(e) {
_onLongDescChange = (e) => {
const newProfileForm = Object.assign(this.state.profileForm, { long_description: e.target.value });
this.setState({
profileForm: newProfileForm,
});
},
};
_onAvatarSelected: function(ev) {
_onAvatarSelected = ev => {
const file = ev.target.files[0];
if (!file) return;
@ -632,7 +628,7 @@ export default createReactClass({
profileForm: newProfileForm,
// Indicate that FlairStore needs to be poked to show this change
// in TagTile (TagPanel), Flair and GroupTile (MyGroups).
// in TagTile (GroupFilterPanel), Flair and GroupTile (MyGroups).
avatarChanged: true,
});
}).catch((e) => {
@ -644,15 +640,15 @@ export default createReactClass({
description: _t('Failed to upload image'),
});
});
},
};
_onJoinableChange: function(ev) {
_onJoinableChange = ev => {
this.setState({
joinableForm: { policyType: ev.target.value },
});
},
};
_onSaveClick: function() {
_onSaveClick = () => {
this.setState({saving: true});
const savePromise = this.state.isUserPrivileged ? this._saveGroup() : Promise.resolve();
savePromise.then((result) => {
@ -661,7 +657,6 @@ export default createReactClass({
editing: false,
summary: null,
});
dis.dispatch({action: 'panel_disable'});
this._initGroupStore(this.props.groupId);
if (this.state.avatarChanged) {
@ -683,16 +678,16 @@ export default createReactClass({
avatarChanged: false,
});
});
},
};
_saveGroup: async function() {
async _saveGroup() {
await this._matrixClient.setGroupProfile(this.props.groupId, this.state.profileForm);
await this._matrixClient.setGroupJoinPolicy(this.props.groupId, {
type: this.state.joinableForm.policyType,
});
},
}
_onAcceptInviteClick: async function() {
_onAcceptInviteClick = async () => {
this.setState({membershipBusy: true});
// Wait 500ms to prevent flashing. Do this before sending a request otherwise we risk the
@ -709,9 +704,9 @@ export default createReactClass({
description: _t("Unable to accept invite"),
});
});
},
};
_onRejectInviteClick: async function() {
_onRejectInviteClick = async () => {
this.setState({membershipBusy: true});
// Wait 500ms to prevent flashing. Do this before sending a request otherwise we risk the
@ -728,9 +723,9 @@ export default createReactClass({
description: _t("Unable to reject invite"),
});
});
},
};
_onJoinClick: async function() {
_onJoinClick = async () => {
if (this._matrixClient.isGuest()) {
dis.dispatch({action: 'require_registration', screen_after: {screen: `group/${this.props.groupId}`}});
return;
@ -752,9 +747,9 @@ export default createReactClass({
description: _t("Unable to join community"),
});
});
},
};
_leaveGroupWarnings: function() {
_leaveGroupWarnings() {
const warnings = [];
if (this.state.isUserPrivileged) {
@ -768,10 +763,9 @@ export default createReactClass({
}
return warnings;
},
}
_onLeaveClick: function() {
_onLeaveClick = () => {
const QuestionDialog = sdk.getComponent("dialogs.QuestionDialog");
const warnings = this._leaveGroupWarnings();
@ -779,8 +773,8 @@ export default createReactClass({
title: _t("Leave Community"),
description: (
<span>
{ _t("Leave %(groupName)s?", {groupName: this.props.groupId}) }
{ warnings }
{ _t("Leave %(groupName)s?", {groupName: this.props.groupId}) }
{ warnings }
</span>
),
button: _t("Leave"),
@ -806,13 +800,13 @@ export default createReactClass({
});
},
});
},
};
_onAddRoomsClick: function() {
_onAddRoomsClick = () => {
showGroupAddRoomDialog(this.props.groupId);
},
};
_getGroupSection: function() {
_getGroupSection() {
const groupSettingsSectionClasses = classnames({
"mx_GroupView_group": this.state.editing,
"mx_GroupView_group_disabled": this.state.editing && !this.state.isUserPrivileged,
@ -856,9 +850,9 @@ export default createReactClass({
{ this._getLongDescriptionNode() }
{ this._getRoomsNode() }
</div>;
},
}
_getRoomsNode: function() {
_getRoomsNode() {
const RoomDetailList = sdk.getComponent('rooms.RoomDetailList');
const AccessibleButton = sdk.getComponent('elements.AccessibleButton');
const TintableSvg = sdk.getComponent('elements.TintableSvg');
@ -883,10 +877,7 @@ export default createReactClass({
{ _t('Add rooms to this community') }
</div>
</AccessibleButton>) : <div />;
const roomDetailListClassName = classnames({
"mx_fadable": true,
"mx_fadable_faded": this.state.editing,
});
return <div className="mx_GroupView_rooms">
<div className="mx_GroupView_rooms_header">
<h3>
@ -897,14 +888,12 @@ export default createReactClass({
</div>
{ this.state.groupRoomsLoading ?
<Spinner /> :
<RoomDetailList
rooms={this.state.groupRooms}
className={roomDetailListClassName} />
<RoomDetailList rooms={this.state.groupRooms} />
}
</div>;
},
}
_getFeaturedRoomsNode: function() {
_getFeaturedRoomsNode() {
const summary = this.state.summary;
const defaultCategoryRooms = [];
@ -943,9 +932,9 @@ export default createReactClass({
{ defaultCategoryNode }
{ categoryRoomNodes }
</div>;
},
}
_getFeaturedUsersNode: function() {
_getFeaturedUsersNode() {
const summary = this.state.summary;
const noRoleUsers = [];
@ -984,9 +973,9 @@ export default createReactClass({
{ noRoleNode }
{ roleUserNodes }
</div>;
},
}
_getMembershipSection: function() {
_getMembershipSection() {
const Spinner = sdk.getComponent("elements.Spinner");
const BaseAvatar = sdk.getComponent("avatars.BaseAvatar");
@ -998,14 +987,14 @@ export default createReactClass({
<Spinner />
</div>;
}
const httpInviterAvatar = this.state.inviterProfile ?
this._matrixClient.mxcUrlToHttp(
this.state.inviterProfile.avatarUrl, 36, 36,
) : null;
const httpInviterAvatar = this.state.inviterProfile && this.state.inviterProfile.avatarUrl
? mediaFromMxc(this.state.inviterProfile.avatarUrl).getSquareThumbnailHttp(36)
: null;
let inviterName = group.inviter.userId;
const inviter = group.inviter || {};
let inviterName = inviter.userId;
if (this.state.inviterProfile) {
inviterName = this.state.inviterProfile.displayName || group.inviter.userId;
inviterName = this.state.inviterProfile.displayName || inviter.userId;
}
return <div className="mx_GroupView_membershipSection mx_GroupView_membershipSection_invited">
<div className="mx_GroupView_membershipSubSection">
@ -1016,7 +1005,7 @@ export default createReactClass({
height={36}
/>
{ _t("%(inviter)s has invited you to join this community", {
inviter: inviterName,
inviter: inviterName || _t("Someone"),
}) }
</div>
<div className="mx_GroupView_membership_buttonContainer">
@ -1072,10 +1061,11 @@ export default createReactClass({
return null;
}
const membershipButtonClasses = classnames([
'mx_RoomHeader_textButton',
'mx_GroupView_textButton',
],
const membershipButtonClasses = classnames(
[
'mx_RoomHeader_textButton',
'mx_GroupView_textButton',
],
membershipButtonExtraClasses,
);
@ -1099,9 +1089,9 @@ export default createReactClass({
</div>
</div>
</div>;
},
}
_getJoinableNode: function() {
_getJoinableNode() {
const InlineSpinner = sdk.getComponent('elements.InlineSpinner');
return this.state.editing ? <div>
<h3>
@ -1135,9 +1125,9 @@ export default createReactClass({
</label>
</div>
</div> : null;
},
}
_getLongDescriptionNode: function() {
_getLongDescriptionNode() {
const summary = this.state.summary;
let description = null;
if (summary.profile && summary.profile.long_description) {
@ -1174,9 +1164,9 @@ export default createReactClass({
<div className="mx_GroupView_groupDesc">
{ description }
</div>;
},
}
render: function() {
render() {
const GroupAvatar = sdk.getComponent("avatars.GroupAvatar");
const Spinner = sdk.getComponent("elements.Spinner");
@ -1334,7 +1324,7 @@ export default createReactClass({
</div>
<GroupHeaderButtons />
</div>
<MainSplit panel={rightPanel}>
<MainSplit panel={rightPanel} resizeNotifier={this.props.resizeNotifier}>
<AutoHideScrollbar className="mx_GroupView_body">
{ this._getMembershipSection() }
{ this._getGroupSection() }
@ -1365,5 +1355,5 @@ export default createReactClass({
console.error("Invalid state for GroupView");
return <div />;
}
},
});
}
}

View file

@ -15,20 +15,83 @@ limitations under the License.
*/
import * as React from "react";
import {useContext, useState} from "react";
import AutoHideScrollbar from './AutoHideScrollbar';
import { getHomePageUrl } from "../../utils/pages";
import { _t } from "../../languageHandler";
import {getHomePageUrl} from "../../utils/pages";
import {_t} from "../../languageHandler";
import SdkConfig from "../../SdkConfig";
import * as sdk from "../../index";
import dis from "../../dispatcher/dispatcher";
import { Action } from "../../dispatcher/actions";
import {Action} from "../../dispatcher/actions";
import BaseAvatar from "../views/avatars/BaseAvatar";
import {OwnProfileStore} from "../../stores/OwnProfileStore";
import AccessibleButton from "../views/elements/AccessibleButton";
import {UPDATE_EVENT} from "../../stores/AsyncStore";
import {useEventEmitter} from "../../hooks/useEventEmitter";
import MatrixClientContext from "../../contexts/MatrixClientContext";
import MiniAvatarUploader, {AVATAR_SIZE} from "../views/elements/MiniAvatarUploader";
import Analytics from "../../Analytics";
import CountlyAnalytics from "../../CountlyAnalytics";
const onClickSendDm = () => dis.dispatch({action: 'view_create_chat'});
const onClickExplore = () => dis.fire(Action.ViewRoomDirectory);
const onClickNewRoom = () => dis.dispatch({action: 'view_create_room'});
const onClickSendDm = () => {
Analytics.trackEvent('home_page', 'button', 'dm');
CountlyAnalytics.instance.track("home_page_button", { button: "dm" });
dis.dispatch({action: 'view_create_chat'});
};
const HomePage = () => {
const onClickExplore = () => {
Analytics.trackEvent('home_page', 'button', 'room_directory');
CountlyAnalytics.instance.track("home_page_button", { button: "room_directory" });
dis.fire(Action.ViewRoomDirectory);
};
const onClickNewRoom = () => {
Analytics.trackEvent('home_page', 'button', 'create_room');
CountlyAnalytics.instance.track("home_page_button", { button: "create_room" });
dis.dispatch({action: 'view_create_room'});
};
interface IProps {
justRegistered?: boolean;
}
const getOwnProfile = (userId: string) => ({
displayName: OwnProfileStore.instance.displayName || userId,
avatarUrl: OwnProfileStore.instance.getHttpAvatarUrl(AVATAR_SIZE),
});
const UserWelcomeTop = () => {
const cli = useContext(MatrixClientContext);
const userId = cli.getUserId();
const [ownProfile, setOwnProfile] = useState(getOwnProfile(userId));
useEventEmitter(OwnProfileStore.instance, UPDATE_EVENT, () => {
setOwnProfile(getOwnProfile(userId));
});
return <div>
<MiniAvatarUploader
hasAvatar={!!ownProfile.avatarUrl}
hasAvatarLabel={_t("Great, that'll help people know it's you")}
noAvatarLabel={_t("Add a photo so people know it's you.")}
setAvatarUrl={url => cli.setAvatarUrl(url)}
>
<BaseAvatar
idName={userId}
name={ownProfile.displayName}
url={ownProfile.avatarUrl}
width={AVATAR_SIZE}
height={AVATAR_SIZE}
resizeMethod="crop"
/>
</MiniAvatarUploader>
<h1>{ _t("Welcome %(name)s", { name: ownProfile.displayName }) }</h1>
<h4>{ _t("Now, let's help you get started") }</h4>
</div>;
};
const HomePage: React.FC<IProps> = ({ justRegistered = false }) => {
const config = SdkConfig.get();
const pageUrl = getHomePageUrl(config);
@ -37,18 +100,27 @@ const HomePage = () => {
return <EmbeddedPage className="mx_HomePage" url={pageUrl} scrollbar={true} />;
}
const brandingConfig = config.branding;
let logoUrl = "themes/element/img/logos/element-logo.svg";
if (brandingConfig && brandingConfig.authHeaderLogoUrl) {
logoUrl = brandingConfig.authHeaderLogoUrl;
let introSection;
if (justRegistered) {
introSection = <UserWelcomeTop />;
} else {
const brandingConfig = config.branding;
let logoUrl = "themes/element/img/logos/element-logo.svg";
if (brandingConfig && brandingConfig.authHeaderLogoUrl) {
logoUrl = brandingConfig.authHeaderLogoUrl;
}
introSection = <React.Fragment>
<img src={logoUrl} alt={config.brand} />
<h1>{ _t("Welcome to %(appName)s", { appName: config.brand }) }</h1>
<h4>{ _t("Liberate your communication") }</h4>
</React.Fragment>;
}
const AccessibleButton = sdk.getComponent("elements.AccessibleButton");
return <AutoHideScrollbar className="mx_HomePage mx_HomePage_default">
<div className="mx_HomePage_default_wrapper">
<img src={logoUrl} alt={config.brand || "Element"} />
<h1>{ _t("Welcome to %(appName)s", { appName: config.brand || "Element" }) }</h1>
<h4>{ _t("Liberate your communication") }</h4>
{ introSection }
<div className="mx_HomePage_default_buttons">
<AccessibleButton onClick={onClickSendDm} className="mx_HomePage_button_sendDm">
{ _t("Send a Direct Message") }

View file

@ -0,0 +1,58 @@
/*
Copyright 2021 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import React from "react";
import {
IconizedContextMenuOption,
IconizedContextMenuOptionList,
} from "../views/context_menus/IconizedContextMenu";
import { _t } from "../../languageHandler";
import { HostSignupStore } from "../../stores/HostSignupStore";
import SdkConfig from "../../SdkConfig";
import {replaceableComponent} from "../../utils/replaceableComponent";
interface IProps {}
interface IState {}
@replaceableComponent("structures.HostSignupAction")
export default class HostSignupAction extends React.PureComponent<IProps, IState> {
private openDialog = async () => {
await HostSignupStore.instance.setHostSignupActive(true);
}
public render(): React.ReactNode {
const hostSignupConfig = SdkConfig.get().hostSignup;
if (!hostSignupConfig?.brand) {
return null;
}
return (
<IconizedContextMenuOptionList>
<IconizedContextMenuOption
iconClassName="mx_UserMenu_iconHosting"
label={_t(
"Upgrade to %(hostSignupBrand)s",
{
hostSignupBrand: hostSignupConfig.brand,
},
)}
onClick={this.openDialog}
/>
</IconizedContextMenuOptionList>
);
}
}

View file

@ -17,7 +17,9 @@ limitations under the License.
import React from "react";
import PropTypes from "prop-types";
import AutoHideScrollbar from "./AutoHideScrollbar";
import {replaceableComponent} from "../../utils/replaceableComponent";
@replaceableComponent("structures.IndicatorScrollbar")
export default class IndicatorScrollbar extends React.Component {
static propTypes = {
// If true, the scrollbar will append mx_IndicatorScrollbar_leftOverflowIndicator

View file

@ -15,21 +15,20 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import {InteractiveAuth} from "matrix-js-sdk";
import {InteractiveAuth} from "matrix-js-sdk/src/interactive-auth";
import React, {createRef} from 'react';
import createReactClass from 'create-react-class';
import PropTypes from 'prop-types';
import getEntryComponentForLoginType from '../views/auth/InteractiveAuthEntryComponents';
import * as sdk from '../../index';
import {replaceableComponent} from "../../utils/replaceableComponent";
export const ERROR_USER_CANCELLED = new Error("User cancelled auth session");
export default createReactClass({
displayName: 'InteractiveAuth',
propTypes: {
@replaceableComponent("structures.InteractiveAuthComponent")
export default class InteractiveAuthComponent extends React.Component {
static propTypes = {
// matrix client to use for UI auth requests
matrixClient: PropTypes.object.isRequired,
@ -86,20 +85,19 @@ export default createReactClass({
// continueText and continueKind are passed straight through to the AuthEntryComponent.
continueText: PropTypes.string,
continueKind: PropTypes.string,
},
};
getInitialState: function() {
return {
constructor(props) {
super(props);
this.state = {
authStage: null,
busy: false,
errorText: null,
stageErrorText: null,
submitButtonEnabled: false,
};
},
// TODO: [REACT-WARNING] Replace component with real class, use constructor for refs
UNSAFE_componentWillMount: function() {
this._unmounted = false;
this._authLogic = new InteractiveAuth({
authData: this.props.authData,
@ -114,6 +112,18 @@ export default createReactClass({
requestEmailToken: this._requestEmailToken,
});
this._intervalId = null;
if (this.props.poll) {
this._intervalId = setInterval(() => {
this._authLogic.poll();
}, 2000);
}
this._stageComponent = createRef();
}
// TODO: [REACT-WARNING] Replace component with real class, use constructor for refs
UNSAFE_componentWillMount() { // eslint-disable-line camelcase
this._authLogic.attemptAuth().then((result) => {
const extra = {
emailSid: this._authLogic.getEmailSid(),
@ -132,26 +142,17 @@ export default createReactClass({
errorText: msg,
});
});
}
this._intervalId = null;
if (this.props.poll) {
this._intervalId = setInterval(() => {
this._authLogic.poll();
}, 2000);
}
this._stageComponent = createRef();
},
componentWillUnmount: function() {
componentWillUnmount() {
this._unmounted = true;
if (this._intervalId !== null) {
clearInterval(this._intervalId);
}
},
}
_requestEmailToken: async function(...args) {
_requestEmailToken = async (...args) => {
this.setState({
busy: true,
});
@ -162,15 +163,15 @@ export default createReactClass({
busy: false,
});
}
},
};
tryContinue: function() {
tryContinue = () => {
if (this._stageComponent.current && this._stageComponent.current.tryContinue) {
this._stageComponent.current.tryContinue();
}
},
};
_authStateUpdated: function(stageType, stageState) {
_authStateUpdated = (stageType, stageState) => {
const oldStage = this.state.authStage;
this.setState({
busy: false,
@ -178,18 +179,25 @@ export default createReactClass({
stageState: stageState,
errorText: stageState.error,
}, () => {
if (oldStage != stageType) this._setFocus();
if (oldStage !== stageType) {
this._setFocus();
} else if (
!stageState.error && this._stageComponent.current &&
this._stageComponent.current.attemptFailed
) {
this._stageComponent.current.attemptFailed();
}
});
},
};
_requestCallback: function(auth) {
_requestCallback = (auth) => {
// This wrapper just exists because the js-sdk passes a second
// 'busy' param for backwards compat. This throws the tests off
// so discard it here.
return this.props.makeRequest(auth);
},
};
_onBusyChanged: function(busy) {
_onBusyChanged = (busy) => {
// if we've started doing stuff, reset the error messages
if (busy) {
this.setState({
@ -204,29 +212,29 @@ export default createReactClass({
// there's a new screen to show the user. This is implemented by setting
// `busy: false` in `_authStateUpdated`.
// See also https://github.com/vector-im/element-web/issues/12546
},
};
_setFocus: function() {
_setFocus() {
if (this._stageComponent.current && this._stageComponent.current.focus) {
this._stageComponent.current.focus();
}
},
}
_submitAuthDict: function(authData) {
_submitAuthDict = authData => {
this._authLogic.submitAuthDict(authData);
},
};
_onPhaseChange: function(newPhase) {
_onPhaseChange = newPhase => {
if (this.props.onStagePhaseChange) {
this.props.onStagePhaseChange(this.state.authStage, newPhase || 0);
}
},
};
_onStageCancel: function() {
_onStageCancel = () => {
this.props.onAuthFinished(false, ERROR_USER_CANCELLED);
},
};
_renderCurrentStage: function() {
_renderCurrentStage() {
const stage = this.state.authStage;
if (!stage) {
if (this.state.busy) {
@ -260,16 +268,17 @@ export default createReactClass({
onCancel={this._onStageCancel}
/>
);
},
}
_onAuthStageFailed: function(e) {
_onAuthStageFailed = e => {
this.props.onAuthFinished(false, e);
},
_setEmailSid: function(sid) {
this._authLogic.setEmailSid(sid);
},
};
render: function() {
_setEmailSid = sid => {
this._authLogic.setEmailSid(sid);
};
render() {
let error = null;
if (this.state.errorText) {
error = (
@ -287,5 +296,5 @@ export default createReactClass({
</div>
</div>
);
},
});
}
}

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